GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
src/widget/input/text_box.rs (126.1K)
1 //! Narrow-trait `TextBox` (Phase 5q). The widest-surface leaf so far: real selection-aware
2 //! clipboard (the new `Input` cut/copy/paste/select-all/clear hooks — their defaults replicate
3 //! the whole-value `WidgetHost` defaults for everyone else), load-bearing glyph shaping through
4 //! `Paint::prepare_text` (cursor↔pixel mapping reads the measured advances), the row-hit
5 //! restoration (`Layout::hit_row_rect` — cce-files' save-name box relies on row hits), a
6 //! width/max-width clamp on both rect paths (`Layout::adjust_rect` + `adjust_row_rect`), the
7 //! ungated `Layout::rect_assigned` (scroll re-clamp on every `set_rect`, hidden or not), and
8 //! `Input::tracks_base_focus = false` (legacy `focus()` never set the base flag — the detached
9 //! label must not color as focused).
10 //!
11 //! Parity notes:
12 //! - The legacy render split is asymmetric and preserved faithfully: the non-rounded path
13 //! (`extra_quads`) draws at the full base x/width with a disabled special-case; the rounded
14 //! path (`all_rounded_quads`) has NO disabled branch.
15 //! - Releases: legacy `mouse_input` hit-gated releases too (out-of-rect releases were dropped).
16 //! The adapter delivers releases ungated, so the model re-checks containment itself against
17 //! the plain rect (the row-substituted release geometry is approximated — flagged).
18 //! - Wheel scrolling is now hit-gated by the adapter (legacy hosts called `mouse_wheel`
19 //! directly on the hovered widget, so the gate should be a no-op in practice — flagged).
20
21 use crate::widget::*;
22 use crate::scene::layout::{Rect, Size};
23 use crate::scene::paint::PaintCtx;
24 use crate::widget::model::{Adapted, EventCtx, Input, Layout, Paint};
25 use crate::history::History;
26 use std::sync::OnceLock;
27
28 static FONT_DB: OnceLock<resvg::usvg::fontdb::Database> = OnceLock::new();
29
30 pub fn get_font_db() -> &'static resvg::usvg::fontdb::Database {
31 FONT_DB.get_or_init(|| {
32 let mut db = resvg::usvg::fontdb::Database::new();
33 #[cfg(not(target_arch = "wasm32"))]
34 {
35 db.load_system_fonts();
36 db.load_fonts_dir(crate::fonts_dir());
37 }
38 // A page has neither: the fonts it handed the browser shell.
39 #[cfg(target_arch = "wasm32")]
40 crate::page_fonts::load_into(&mut db);
41 db
42 })
43 }
44
45 /// Everything [`TextBox`]'s `prepare_text` output depends on.
46 #[derive(Debug, Clone, PartialEq)]
47 struct PrepKey {
48 text: String,
49 placeholder: bool,
50 password: bool,
51 font_size_bits: u32,
52 font: Option<String>,
53 attrs: crate::scene::paint::TextAttrs,
54 scale_bits: u32,
55 vertical: bool,
56 /// `Some(the wrap width's bits)` for a multiline box ([`TextBox::wrap_width`]).
57 wrap: Option<u32>,
58 /// The width a right-to-left paragraph is set against the right of (the box less its
59 /// padding), as bits.
60 room_bits: u32,
61 }
62
63 /// What a [`TextBox`]'s cached advances were measured for.
64 #[derive(Debug, Clone, PartialEq)]
65 struct AdvanceKey {
66 text: String,
67 font_size_bits: u32,
68 font: Option<String>,
69 attrs: crate::scene::paint::TextAttrs,
70 scale_bits: u32,
71 }
72
73 #[derive(Debug, Clone)]
74 pub struct TextBox {
75 pub text: String,
76 pub editing: bool,
77 pub edit_buffer: String,
78 pub(crate) just_changed: bool,
79 pub disabled: bool,
80 pub all_selected: bool,
81 pub cursor_idx: usize,
82 pub select_anchor: Option<usize>,
83 pub dragging: bool,
84 pub just_focused: bool,
85 pub drag_start_idx: Option<usize>,
86 pub max_width: Option<f32>,
87 pub width: Option<f32>,
88 pub is_password: bool,
89 pub multiline: bool,
90 /// Per-widget wrap override; `None` defers to the global `textbox_line_wrap()`
91 /// style. Flowed-text consumers (an email body) set this to keep wrapping even
92 /// when the user's config turns multiline wrap off DE-wide.
93 pub line_wrap_override: Option<bool>,
94 pub draw_bg_border: bool,
95 /// The box's right end meets another control (a parameter pane's
96 /// completion picker): its well's right corners are square, so the two
97 /// read as one field with a seam between them.
98 pub joined_right: bool,
99 pub text_color: Option<[u8; 3]>,
100 pub font_size: f32,
101 pub font_family: String,
102 /// Italic / weight the value text is drawn AND measured with (see
103 /// `value_font`) — default for every box but one showing a particular
104 /// face of `font_family`.
105 pub font_attrs: crate::scene::paint::TextAttrs,
106 pub placeholder: Option<String>,
107 /// A search box (`with_search`): its menu offers Clear. Known by this flag, never by
108 /// its placeholder's text, which is translated.
109 pub is_search: bool,
110 pub editor_state: TextEditorState,
111 /// Edit history for the current editing session (cleared by
112 /// `begin_editing`): a typed run is one step, a deleted run one, a
113 /// paste/cut/selection-replacement one. Stepped by `ContextAction::Undo`
114 /// / `Redo`, which the runner routes here on the `undo` / `redo` chords
115 /// while the box is focused and editing.
116 pub history: History<TextEditorState>,
117 pub scroll_y: f32,
118 pub scroll_x: f32,
119 /// Smooth-scroll driver behind `scroll_x`/`scroll_y` (see `ScrollRegion::motion`).
120 scroll_motion: ScrollMotion,
121 /// `(max_x, max_y)` as of the last wheel — the glide's bounds, so `tick`
122 /// never re-wraps the text just to re-derive them.
123 scroll_max: (f32, f32),
124 default_font_size: f32,
125 default_font_family: String,
126 pub cursor_x_offset: f32,
127 pub glyph_positions: Vec<f32>,
128 pub total_text_width: f32,
129 /// The shaped advance of one column, cached by [`Paint::prepare_text`] from the same
130 /// cosmic-text path that draws the value text. `char_width()` prefers this over the
131 /// SVG-rasterized `measure_text_width`, which reports inked extent (and resolves generic
132 /// families through fontdb, not cosmic-text) — a per-column error that made the multiline
133 /// selection highlight drift off the glyphs. 0.0 until the first `prepare_text`.
134 shaped_char_advance: f32,
135 /// Each char's shaped advance in the text being wrapped (logical px; a cluster's
136 /// whole width on its first char, 0 on the rest and on a newline), for the key it was
137 /// measured for. Wrapping sums these against the width, so a proportional face and
138 /// two-column CJK wrap where they are drawn. Filled from the paint's font system in
139 /// `prepare_text`, else from `geometry_font_system` when an edit outruns the frame.
140 advances: std::cell::RefCell<Option<(AdvanceKey, Vec<f32>)>>,
141 /// How far right a right-to-left paragraph is set so its right edge meets the box's
142 /// (0 for left-to-right text, and for a line too wide to fit): the single line's, and
143 /// each wrapped line's. Already in `glyph_positions` / `line_glyph_positions`, so
144 /// carets, clicks and selections follow; the drawing adds it to the text's x.
145 glyph_shift: f32,
146 line_shift: Vec<f32>,
147 /// The shaped runs those positions came from, for selections drawn as the boxes of
148 /// the clusters they cover (two pieces where one crosses a change of direction).
149 glyph_run: Option<crate::backend::text::ShapedRun>,
150 line_runs: Vec<crate::backend::text::ShapedRun>,
151 /// Multiline counterpart of `glyph_positions`: per WRAPPED line, per-column x
152 /// offsets of that line as drawn (`[line][col]`, one extra entry per line = its
153 /// total advance), recorded by [`Paint::prepare_text`] over the same wrap the
154 /// paint uses. The multiline caret, selection, click→column, and
155 /// scroll-to-cursor read these; the uniform `col * char_width()` grid they used
156 /// before is exact only for monospace. Empty for single-line boxes or until the
157 /// first shape (readers fall back to the grid).
158 line_glyph_positions: Vec<Vec<f32>>,
159 /// What the last `prepare_text` shaped. The runner calls `prepare_text` on
160 /// every registered box before every frame; when none of these inputs moved,
161 /// the offsets above are still right and only the caret is re-read.
162 prep_key: Option<PrepKey>,
163 pub update_on_type: bool,
164 /// Synced control label ([`Paint::sync_label`]) — drives the detached strip offset.
165 label: Option<String>,
166 /// Own hover flag, maintained from `MouseEnter`/`MouseLeave` (adapter bookkeeping).
167 hovered: bool,
168 /// The laid-out base rect, cached from [`Layout::rect_assigned`] — the cursor/scroll math
169 /// reads geometry between events, which the narrow traits don't otherwise carry.
170 rect: Rect,
171 /// An input method's composition, shown in `edit_buffer` as a PROVISIONAL run:
172 /// its start and length in chars. Everything that draws the buffer — wrap,
173 /// scroll, caret, the glyph advances — draws it as it draws typed text, and
174 /// the selection quads underline it. It is never committed, recorded in the
175 /// history or reported as a change: `committed_buffer` is the buffer without
176 /// it. See `crate::ime`.
177 composing: Option<(usize, usize)>,
178 /// The `ime::generation` the run shows.
179 ime_seen: u64,
180 /// Recessed style: a `Recess` overlay is carved over the box's own fill —
181 /// an inset well, the input-direction counterpart of the raised controls.
182 recessed: Option<bool>,
183 }
184
185 impl TextBox {
186 /// The style in force: the per-widget override (`with_recessed`) when set, else
187 /// the DE's `control_relief`, read live so a runtime switch
188 /// (`layout::set_control_relief`) restyles every control at once.
189 fn recessed(&self) -> bool {
190 self.recessed.unwrap_or_else(crate::layout::control_relief)
191 }
192
193 pub fn new(text: String) -> Adapted<TextBox> {
194 let (style_family, style_size) = crate::layout::control_label_font_detached_parsed();
195 let editor_state = TextEditorState::new(text.clone());
196 Adapted::new(TextBox {
197 text,
198 editing: false,
199 edit_buffer: String::new(),
200 just_changed: false,
201 disabled: false,
202 all_selected: false,
203 cursor_idx: 0,
204 select_anchor: None,
205 dragging: false,
206 just_focused: false,
207 drag_start_idx: None,
208 max_width: None,
209 width: None,
210 is_password: false,
211 multiline: false,
212 line_wrap_override: None,
213 draw_bg_border: true,
214 joined_right: false,
215 text_color: None,
216 font_size: style_size,
217 font_family: style_family.clone(),
218 font_attrs: crate::scene::paint::TextAttrs::default(),
219 placeholder: None,
220 is_search: false,
221 editor_state,
222 history: History::new(),
223 scroll_y: 0.0,
224 scroll_x: 0.0,
225 scroll_motion: ScrollMotion::new(),
226 scroll_max: (0.0, 0.0),
227 default_font_size: style_size,
228 default_font_family: style_family,
229 cursor_x_offset: 0.0,
230 glyph_positions: Vec::new(),
231 total_text_width: 0.0,
232 shaped_char_advance: 0.0,
233 advances: std::cell::RefCell::new(None),
234 glyph_shift: 0.0,
235 line_shift: Vec::new(),
236 glyph_run: None,
237 line_runs: Vec::new(),
238 line_glyph_positions: Vec::new(),
239 prep_key: None,
240 update_on_type: false,
241 label: None,
242 hovered: false,
243 rect: Rect { x: 0.0, y: 0.0, width: 0.0, height: 0.0 },
244 recessed: None,
245 composing: None,
246 ime_seen: 0,
247 })
248 }
249
250 /// The detached-label strip height above the content (zero unlabeled) — the
251 /// adapter's `Widget::label_offset` over the synced label.
252 fn label_top(&self) -> f32 {
253 crate::widget::input::slider::detached_strip(&self.label)
254 }
255
256 fn map_x_to_idx(&self, click_x: f32) -> usize {
257 let pad = self.pad();
258 let relative_x = click_x - (self.rect.x + pad) + self.scroll_x;
259 if self.glyph_positions.is_empty() {
260 let char_width = self.char_width();
261 return ((relative_x / char_width).round() as isize)
262 .max(0)
263 .min(self.edit_buffer.chars().count() as isize) as usize;
264 }
265
266 let mut closest_idx = 0;
267 let mut min_diff = f32::MAX;
268 for (i, &pos) in self.glyph_positions.iter().enumerate() {
269 let diff = (pos - relative_x).abs();
270 if diff < min_diff {
271 min_diff = diff;
272 closest_idx = i;
273 }
274 }
275 // When the box is empty, `prepare_text` shapes the PLACEHOLDER into
276 // `glyph_positions`, so the nearest-glyph snap above can land on a
277 // placeholder column. Clamp to the real text: the placeholder is
278 // painted, not caret-addressable.
279 let text_len = if self.editing { self.edit_buffer.chars().count() } else { self.text.chars().count() };
280 closest_idx.min(text_len)
281 }
282
283 /// The x offset of `col` on wrapped line `line`, from the shaped per-line
284 /// offsets when recorded, else the uniform-grid estimate.
285 fn line_col_x(&self, line: usize, col: usize) -> f32 {
286 self.line_glyph_positions
287 .get(line)
288 .and_then(|l| l.get(col).copied())
289 .unwrap_or_else(|| col as f32 * self.char_width())
290 }
291
292 /// An x offset (relative to the text origin) → nearest column on wrapped
293 /// line `line`, from the shaped offsets when recorded.
294 fn line_x_to_col(&self, line: usize, relative_x: f32) -> usize {
295 let Some(offsets) = self.line_glyph_positions.get(line).filter(|l| !l.is_empty()) else {
296 return ((relative_x / self.char_width()).round() as isize).max(0) as usize;
297 };
298 let mut closest = 0;
299 let mut min_diff = f32::MAX;
300 for (i, &pos) in offsets.iter().enumerate() {
301 let diff = (pos - relative_x).abs();
302 if diff < min_diff {
303 min_diff = diff;
304 closest = i;
305 }
306 }
307 closest
308 }
309
310 /// The font the value text is drawn in — AND measured in: `prepare_text`
311 /// (whose per-glyph positions place the caret, the selection and
312 /// click-to-column) shapes with exactly this, and `paint` emits it. It is
313 /// [`Paint::text_font`], because that is what actually draws the text:
314 /// `Adapted::paint_self` drops the Text prims `paint` emits and redraws
315 /// the value through its own-labels bridge in `text_font()` — the box's
316 /// family when customized (cce-text-editor's monospace editor), else the
317 /// configured control font string.
318 ///
319 /// Until 2026-09-25 the measurement shaped `font_family` instead (or
320 /// `monospace` for a password), a different face from the drawn one, so
321 /// the caret drifted off the text a little more with every character —
322 /// two dots short after twelve in the login greeter's password box.
323 pub fn value_font(&self) -> Option<String> {
324 <Self as Paint>::text_font(self)
325 }
326
327 /// The shaping half of `prepare_text`: `glyph_positions` and
328 /// `total_text_width` for the whole display text, and, multiline,
329 /// `line_glyph_positions` per wrapped line as the paint draws them.
330 fn shape_columns(&mut self, fs: &mut cosmic_text::FontSystem, key: &PrepKey) {
331 let scale = f32::from_bits(key.scale_bits);
332 let font_fam = key.font.as_deref();
333 let attrs = key.attrs;
334 let shared = crate::backend::text::shared_text_buffer;
335
336 let render_text = if key.password {
337 "•".repeat(key.text.chars().count())
338 } else {
339 key.text.clone()
340 };
341
342 // A multiline box reads only the per-line offsets; shaping the whole
343 // document as one buffer would be its most expensive and least used step.
344 self.line_glyph_positions.clear();
345 if let Some(bits) = key.wrap {
346 // Measure the wrapped text's advances with the paint's font system first, so
347 // the wrap below never reaches for the shared one (which may be this one).
348 let src = if self.editing { self.edit_buffer.clone() } else { self.text.clone() };
349 self.cache_advances(fs, &src);
350 let (lines, map) = self.wrap_text(f32::from_bits(bits));
351 // Each wrapped line's paragraph, and whether that paragraph is right to left: a
352 // paragraph's direction is its first strong character's, for all its lines.
353 let para_rtl: Vec<bool> = src.split('\n').map(crate::backend::text::paragraph_rtl).collect();
354 let mut para_of_line = vec![0usize; lines.len()];
355 let mut para = 0usize;
356 let mut seen = vec![false; lines.len()];
357 for (ci, ch) in src.chars().enumerate() {
358 let line = map[ci].0.min(lines.len() - 1);
359 if !seen[line] {
360 seen[line] = true;
361 para_of_line[line] = para;
362 }
363 if ch == '\n' {
364 para += 1;
365 }
366 }
367 for (line, s) in seen.iter().enumerate() {
368 if !s {
369 para_of_line[line] = para;
370 }
371 }
372 let room = f32::from_bits(key.room_bits);
373 self.line_shift.clear();
374 self.line_runs.clear();
375 for (li, line) in lines.iter().enumerate() {
376 let line_buffer = shared(fs, line, self.font_size, font_fam, attrs);
377 let run = crate::backend::text::shaped_run(&line_buffer, line, scale);
378 let rtl = para_rtl.get(para_of_line[li]).copied().unwrap_or(false);
379 let shift = if rtl { (room - run.width).max(0.0) } else { 0.0 };
380 self.line_glyph_positions.push(run.stops.iter().map(|s| s.1 + shift).collect());
381 self.line_shift.push(shift);
382 self.line_runs.push(run);
383 }
384 self.glyph_positions = vec![0.0; render_text.chars().count() + 1];
385 self.total_text_width = 0.0;
386 return;
387 }
388
389 let buffer = shared(fs, &render_text, self.font_size, font_fam, attrs);
390 let run = crate::backend::text::shaped_run(&buffer, &render_text, scale);
391 let total_w = run.width;
392 // A right-to-left line that fits is set against the right. One that does not
393 // overflows to the LEFT, its start being at the right: shown and not being edited,
394 // it is scrolled to its right end, where a left-to-right line shows its left (until
395 // 2026-10-08 it showed its left end too, which is the END of a right-to-left line).
396 // While editing, the caret is followed (`scroll_to_cursor`) as for any line.
397 let room = f32::from_bits(key.room_bits);
398 let rtl = crate::backend::text::paragraph_rtl(&render_text);
399 let shift = if rtl && total_w < room { room - total_w } else { 0.0 };
400 self.glyph_positions = run.stops.iter().map(|s| s.1 + shift).collect();
401 self.glyph_shift = shift;
402 self.glyph_run = Some(run);
403 self.total_text_width = total_w;
404 if rtl && total_w > room && !self.editing && key.wrap.is_none() && !self.multiline {
405 self.scroll_x = total_w - room;
406 }
407 }
408
409 pub fn char_width(&self) -> f32 {
410 if self.shaped_char_advance > 0.0 {
411 self.shaped_char_advance
412 } else {
413 crate::widget::display::measure_text_width("M", &self.font_family, self.font_size)
414 }
415 }
416
417 pub fn line_height(&self) -> f32 {
418 self.font_size * 1.333
419 }
420
421 /// The width a multiline box wraps its lines at, for a box `outer_w` wide: its width
422 /// less the padding, or no limit with wrapping off.
423 pub fn wrap_width(&self, outer_w: f32) -> f32 {
424 if self.line_wrap_enabled() {
425 (outer_w - 2.0 * self.pad()).max(1.0)
426 } else {
427 f32::INFINITY
428 }
429 }
430
431 fn advance_key(&self, text: &str) -> AdvanceKey {
432 AdvanceKey {
433 text: text.to_string(),
434 font_size_bits: self.font_size.to_bits(),
435 font: self.value_font(),
436 attrs: self.font_attrs,
437 scale_bits: crate::scale::scale_factor().to_bits(),
438 }
439 }
440
441 /// Each char's shaped advance in `text` (see the `advances` field), shaped with `fs`.
442 fn measure_advances(&self, fs: &mut cosmic_text::FontSystem, text: &str) -> Vec<f32> {
443 let scale = crate::scale::scale_factor().max(0.01);
444 let font = self.value_font();
445 let mut out = Vec::with_capacity(text.chars().count());
446 for (pi, para) in text.split('\n').enumerate() {
447 if pi > 0 {
448 out.push(0.0); // the newline
449 }
450 let shown = if self.is_password { "•".repeat(para.chars().count()) } else { para.to_string() };
451 let buf = crate::backend::text::shared_text_buffer(fs, &shown, self.font_size, font.as_deref(), self.font_attrs);
452 let run = crate::backend::text::shaped_run(&buf, &shown, scale);
453 let mut adv = vec![0.0f32; shown.chars().count()];
454 let char_at: std::collections::HashMap<usize, usize> =
455 shown.char_indices().enumerate().map(|(ci, (b, _))| (b, ci)).collect();
456 for c in &run.clusters {
457 if let Some(&ci) = char_at.get(&c.start) {
458 adv[ci] += c.x1 - c.x0;
459 }
460 }
461 out.extend(adv);
462 }
463 out
464 }
465
466 /// Measure `text`'s advances with `fs` into the cache, unless it already holds them.
467 fn cache_advances(&self, fs: &mut cosmic_text::FontSystem, text: &str) {
468 let key = self.advance_key(text);
469 if self.advances.borrow().as_ref().is_some_and(|(k, _)| *k == key) {
470 return;
471 }
472 let adv = self.measure_advances(fs, text);
473 *self.advances.borrow_mut() = Some((key, adv));
474 }
475
476 /// `text`'s char advances: the cache, else measured with the shared geometry font
477 /// system, else — that one held, by this thread further up the stack or by another —
478 /// with a font system of this thread's own, from the same font set. Always measured,
479 /// never a grid: two wraps of one text must agree.
480 fn char_advances(&self, text: &str) -> Vec<f32> {
481 thread_local! {
482 static OWN_FS: std::cell::RefCell<Option<cosmic_text::FontSystem>> = const { std::cell::RefCell::new(None) };
483 }
484 let key = self.advance_key(text);
485 if let Some((k, adv)) = self.advances.borrow().as_ref() {
486 if *k == key {
487 return adv.clone();
488 }
489 }
490 let adv = match crate::geometry_font_system().try_lock() {
491 Ok(mut fs) => self.measure_advances(&mut fs, text),
492 Err(_) => OWN_FS.with(|own| {
493 let mut own = own.borrow_mut();
494 let fs = own.get_or_insert_with(crate::create_font_system);
495 self.measure_advances(fs, text)
496 }),
497 };
498 *self.advances.borrow_mut() = Some((key, adv.clone()));
499 adv
500 }
501
502 /// The box's text wrapped at `max_width` px ([`TextBox::wrap_width`]): its lines, and
503 /// where each char lands as (line, column). Words move whole to the next line; a word
504 /// wider than the line breaks where it overflows. Widths are the shaped advances, so a
505 /// proportional face and two-column CJK wrap where they are drawn (until 2026-10-08 it
506 /// counted chars against one monospace advance).
507 pub fn wrap_text(&self, max_width: f32) -> (Vec<String>, Vec<(usize, usize)>) {
508 let text_src = if self.editing { &self.edit_buffer } else { &self.text };
509 self.wrap_str(text_src, max_width)
510 }
511
512 /// [`TextBox::wrap_text`] for any text in the box's face (its placeholder).
513 pub fn wrap_str(&self, text_src: &str, max_width: f32) -> (Vec<String>, Vec<(usize, usize)>) {
514 let chars: Vec<char> = text_src.chars().collect();
515 let mut lines = Vec::new();
516 let mut current_line = Vec::new();
517 let mut index_map = vec![(0, 0); chars.len() + 1];
518
519 if !self.line_wrap_enabled() || !max_width.is_finite() {
520 let mut i = 0;
521 while i < chars.len() {
522 let ch = chars[i];
523 if ch == '\n' {
524 index_map[i] = (lines.len(), current_line.len());
525 lines.push(current_line.iter().collect::<String>());
526 current_line.clear();
527 } else {
528 current_line.push(ch);
529 index_map[i] = (lines.len(), current_line.len() - 1);
530 }
531 i += 1;
532 }
533 index_map[chars.len()] = (lines.len(), current_line.len());
534 lines.push(current_line.iter().collect::<String>());
535 return (lines, index_map);
536 }
537
538 let adv = self.char_advances(text_src);
539 let max_width = max_width.max(1.0);
540 // The current line's width: the advances of the chars it holds.
541 let mut line_w = 0.0f32;
542
543 let mut i = 0;
544 while i < chars.len() {
545 let ch = chars[i];
546
547 if ch == '\n' {
548 index_map[i] = (lines.len(), current_line.len());
549 lines.push(current_line.iter().collect::<String>());
550 current_line.clear();
551 line_w = 0.0;
552 i += 1;
553 continue;
554 }
555
556 current_line.push(ch);
557 line_w += adv.get(i).copied().unwrap_or(0.0);
558 index_map[i] = (lines.len(), current_line.len() - 1);
559
560 // A space may hang past the edge, as it is drawn there; anything else that
561 // overflows breaks the line after its last space, else before itself.
562 if line_w > max_width + 0.5 && current_line.len() > 1 && !ch.is_whitespace() {
563 let split = match current_line.iter().rposition(|c| c.is_whitespace()) {
564 Some(s_idx) => s_idx + 1,
565 None => current_line.len() - 1,
566 };
567 let line_to_push: Vec<char> = current_line[..split].to_vec();
568 let remaining: Vec<char> = current_line[split..].to_vec();
569
570 let line_idx = lines.len();
571 lines.push(line_to_push.iter().collect::<String>());
572
573 current_line = remaining;
574 let start_orig = i + 1 - current_line.len();
575 line_w = 0.0;
576 for c_idx in 0..current_line.len() {
577 index_map[start_orig + c_idx] = (line_idx + 1, c_idx);
578 line_w += adv.get(start_orig + c_idx).copied().unwrap_or(0.0);
579 }
580 }
581 i += 1;
582 }
583
584 index_map[chars.len()] = (lines.len(), current_line.len());
585 lines.push(current_line.iter().collect::<String>());
586
587 (lines, index_map)
588 }
589
590 pub fn map_2d_to_1d(&self, index_map: &[(usize, usize)], target_line: usize, target_col: usize, max_line_idx: usize) -> usize {
591 let line = target_line.min(max_line_idx);
592 let mut best_idx = 0;
593 let mut best_dist = usize::MAX;
594
595 for (i, &(l, c)) in index_map.iter().enumerate() {
596 if l == line {
597 let dist = (c as isize - target_col as isize).unsigned_abs();
598 if dist < best_dist {
599 best_dist = dist;
600 best_idx = i;
601 }
602 }
603 }
604 best_idx
605 }
606
607 fn border_width(&self) -> f32 {
608 if self.multiline {
609 crate::layout::textbox_multiline_border_width()
610 } else {
611 1.0
612 }
613 }
614
615 /// The inset from the box's edge to its text — the caret, selection,
616 /// hit-testing, wrap and scroll range all measure from it. 8px, or the
617 /// well's floor when the relief wall reaches further in: the wall is
618 /// `bevel_width` deep once the box is tall enough (`well`'s 20% cap), so a
619 /// fixed 8px sat a multiline box's text on its wall.
620 fn pad(&self) -> f32 {
621 8.0f32.max(self.wall_inset())
622 }
623
624 /// How far in from the box's edge its relief wall ends (the outline's own
625 /// inset from the carve plus the wall's depth); zero with no relief.
626 fn wall_inset(&self) -> f32 {
627 self.well().map_or(0.0, |f| f.rect.x - self.rect.x + f.depth)
628 }
629
630 /// The value text's clip, `[x1, y1, x2, y2]`, from the content rect (the
631 /// box below its label strip): the well's floor, so scrolled text slides
632 /// under the relief wall rather than over it. Only a multiline box is
633 /// clipped in y: a single line is centred, never scrolls vertically, and a
634 /// short box's floor is shallower than its line.
635 fn text_clip(&self, content: Rect) -> [f32; 4] {
636 let inset = self.wall_inset();
637 let inset_y = if self.multiline { inset } else { 0.0 };
638 [
639 content.x + inset,
640 content.y + inset_y,
641 content.x + content.width - inset,
642 content.y + content.height - inset_y,
643 ]
644 }
645
646 pub fn set_placeholder(&mut self, placeholder: &str) {
647 self.placeholder = Some(placeholder.to_string());
648 }
649
650 pub fn take_change(&mut self) -> bool {
651 if self.update_on_type {
652 let held = self.committed_buffer();
653 if self.text != held {
654 self.text = held;
655 self.just_changed = true;
656 }
657 }
658 let changed = self.just_changed;
659 self.just_changed = false;
660 changed
661 }
662
663 pub fn line_wrap_enabled(&self) -> bool {
664 self.multiline && self.line_wrap_override.unwrap_or_else(crate::layout::textbox_line_wrap)
665 }
666
667 pub fn set_max_width(&mut self, max_w: Option<f32>) {
668 self.max_width = max_w;
669 }
670
671 pub fn set_width(&mut self, w: f32) {
672 self.width = Some(w);
673 }
674
675 pub fn sync_editor_state(&mut self) {
676 self.editor_state = TextEditorState {
677 buffer: self.edit_buffer.clone(),
678 cursor_idx: self.cursor_idx,
679 select_anchor: self.select_anchor,
680 all_selected: self.all_selected,
681 };
682 }
683
684 /// The editing fields as one value — what the history stores.
685 fn snapshot(&self) -> TextEditorState {
686 TextEditorState {
687 buffer: self.edit_buffer.clone(),
688 cursor_idx: self.cursor_idx,
689 select_anchor: self.select_anchor,
690 all_selected: self.all_selected,
691 }
692 }
693
694 fn restore(&mut self, snap: TextEditorState) {
695 self.edit_buffer = snap.buffer;
696 self.cursor_idx = snap.cursor_idx;
697 self.select_anchor = snap.select_anchor;
698 self.all_selected = snap.all_selected;
699 self.sync_editor_state();
700 self.scroll_to_cursor();
701 }
702
703 /// Step the edit buffer back one recorded step. Only while editing —
704 /// a committed value is the app's to undo, not the box's.
705 pub fn undo_edit(&mut self) -> bool {
706 if !self.editing || self.disabled {
707 return false;
708 }
709 let current = self.snapshot();
710 match self.history.undo(current) {
711 Some(prev) => {
712 self.restore(prev);
713 self.just_changed = true;
714 true
715 }
716 None => false,
717 }
718 }
719
720 /// Step forward again — see [`undo_edit`](Self::undo_edit).
721 pub fn redo_edit(&mut self) -> bool {
722 if !self.editing || self.disabled {
723 return false;
724 }
725 let current = self.snapshot();
726 match self.history.redo(current) {
727 Some(next) => {
728 self.restore(next);
729 self.just_changed = true;
730 true
731 }
732 None => false,
733 }
734 }
735
736 /// The selection as it may go to the clipboard: never a password box's.
737 /// Every copy and cut goes through here -- Ctrl+C / Ctrl+X, the context
738 /// menu's rows, [`copy_selection`](Self::copy_selection) and
739 /// [`cut_selection`](Self::cut_selection) -- because each of them used to
740 /// put a password on the clipboard in plain text, readable by any client,
741 /// from the login greeter's and the polkit dialog's boxes alike.
742 fn clipboard_text(&self, state: &TextEditorState) -> Option<String> {
743 if self.is_password {
744 return None;
745 }
746 state.selected_text()
747 }
748
749 pub fn copy_selection(&self) {
750 let state = TextEditorState {
751 buffer: self.edit_buffer.clone(),
752 cursor_idx: self.cursor_idx,
753 select_anchor: self.select_anchor,
754 all_selected: self.all_selected,
755 };
756 if let Some(text) = self.clipboard_text(&state) {
757 clipboard::copy_to_clipboard(&text);
758 }
759 }
760
761 /// Cut the selection to the clipboard. A password box's selection can
762 /// not go there (`clipboard_text`), so it is left in place, not deleted.
763 pub fn cut_selection(&mut self) -> bool {
764 if self.is_password {
765 return false;
766 }
767 let before = self.snapshot();
768 let mut state = TextEditorState {
769 buffer: std::mem::take(&mut self.edit_buffer),
770 cursor_idx: self.cursor_idx,
771 select_anchor: self.select_anchor,
772 all_selected: self.all_selected,
773 };
774 if let Some(text) = state.selected_text() {
775 clipboard::copy_to_clipboard(&text);
776 state.insert_text("");
777 self.history.record(before);
778 self.edit_buffer = state.buffer;
779 self.cursor_idx = state.cursor_idx;
780 self.select_anchor = state.select_anchor;
781 self.all_selected = state.all_selected;
782 self.sync_editor_state();
783 true
784 } else {
785 self.edit_buffer = state.buffer;
786 self.sync_editor_state();
787 false
788 }
789 }
790
791 pub fn paste_from_clipboard(&mut self) -> bool {
792 if let Some(text) = clipboard::read_from_clipboard() {
793 let before = self.snapshot();
794 let mut state = TextEditorState {
795 buffer: std::mem::take(&mut self.edit_buffer),
796 cursor_idx: self.cursor_idx,
797 select_anchor: self.select_anchor,
798 all_selected: self.all_selected,
799 };
800 let mut cleaned = String::new();
801 for ch in text.chars() {
802 if !ch.is_control() && ch != '\n' && ch != '\r' {
803 cleaned.push(ch);
804 }
805 }
806 state.insert_text(&cleaned);
807 self.history.record(before);
808 self.edit_buffer = state.buffer;
809 self.cursor_idx = state.cursor_idx;
810 self.select_anchor = state.select_anchor;
811 self.all_selected = state.all_selected;
812 self.sync_editor_state();
813 true
814 } else {
815 false
816 }
817 }
818
819 pub fn select_all(&mut self) {
820 let mut state = TextEditorState {
821 buffer: std::mem::take(&mut self.edit_buffer),
822 cursor_idx: self.cursor_idx,
823 select_anchor: self.select_anchor,
824 all_selected: self.all_selected,
825 };
826 state.select_all();
827 self.edit_buffer = state.buffer;
828 self.cursor_idx = state.cursor_idx;
829 self.select_anchor = state.select_anchor;
830 self.all_selected = state.all_selected;
831 self.sync_editor_state();
832 }
833
834 pub fn set_value(&mut self, val: &str) -> bool {
835 let val_str = val.to_string();
836 if self.text != val_str {
837 if self.editing {
838 let before = self.snapshot();
839 self.history.record(before);
840 } else {
841 self.history.clear();
842 }
843 self.text = val_str.clone();
844 self.edit_buffer = val_str;
845 self.just_changed = true;
846 let len = self.edit_buffer.chars().count();
847 self.cursor_idx = self.cursor_idx.min(len);
848 if let Some(anchor) = self.select_anchor {
849 self.select_anchor = Some(anchor.min(len));
850 }
851 if self.cursor_idx == 0 && self.select_anchor == Some(0) {
852 self.all_selected = false;
853 }
854 self.sync_editor_state();
855 self.clamp_scroll();
856 true
857 } else {
858 false
859 }
860 }
861
862 /// The widest line's drawn advance — shaped when recorded, else the
863 /// chars × char_width estimate (the pre-shaping formula).
864 fn content_width(&self, lines: &[String]) -> f32 {
865 let shaped = if self.multiline {
866 self.line_glyph_positions
867 .iter()
868 // A line's width is its widest stop: the last in left-to-right text, the
869 // first in right-to-left.
870 .map(|l| l.iter().copied().fold(0.0f32, f32::max))
871 .fold(0.0f32, f32::max)
872 } else {
873 self.total_text_width
874 };
875 if shaped > 0.0 {
876 shaped
877 } else {
878 let max_line_len = lines.iter().map(|l| l.chars().count()).max().unwrap_or(0);
879 max_line_len as f32 * self.char_width()
880 }
881 }
882
883 pub fn clamp_scroll(&mut self) {
884 let pad = self.pad();
885 let line_height = self.line_height();
886 let max_w = self.wrap_width(self.rect.width);
887 let (lines, _) = if self.multiline {
888 self.wrap_text(max_w)
889 } else {
890 let buffer = if self.editing { &self.edit_buffer } else { &self.text };
891 (vec![buffer.clone()], vec![(0, 0); buffer.chars().count() + 1])
892 };
893
894 if self.multiline {
895 let content_h = lines.len() as f32 * line_height;
896 let max_scroll = (content_h - (self.rect.height - 2.0 * pad)).max(0.0);
897 self.scroll_y = self.scroll_y.clamp(0.0, max_scroll);
898 } else {
899 self.scroll_y = 0.0;
900 }
901
902 if !self.line_wrap_enabled() {
903 let content_w = self.content_width(&lines);
904 let max_scroll_x = (content_w - (self.rect.width - 2.0 * pad)).max(0.0);
905 self.scroll_x = self.scroll_x.clamp(0.0, max_scroll_x);
906 } else {
907 self.scroll_x = 0.0;
908 }
909 }
910
911 pub fn scroll_to_cursor(&mut self) {
912 let pad = self.pad();
913 let char_width = self.char_width();
914 let line_height = self.line_height();
915 let max_w = self.wrap_width(self.rect.width);
916 let (_lines, index_map) = if self.multiline {
917 self.wrap_text(max_w)
918 } else {
919 let buffer = if self.editing { &self.edit_buffer } else { &self.text };
920 let mut m = Vec::new();
921 for i in 0..=buffer.chars().count() {
922 m.push((0, i));
923 }
924 (vec![buffer.clone()], m)
925 };
926 if index_map.is_empty() { return; }
927
928 let cursor_idx = self.cursor_idx.min(index_map.len() - 1);
929 let (line_idx, col_idx) = index_map[cursor_idx];
930
931 let top = self.label_top();
932 let viewport_w = self.rect.width - 2.0 * pad;
933 let viewport_h = self.rect.height - top - 2.0 * pad;
934
935 if self.multiline {
936 let line_y = top + pad + (line_idx as f32 * line_height);
937 if line_y < self.scroll_y + 10.0 {
938 self.scroll_y = (line_y - 20.0).max(0.0);
939 } else if line_y + line_height > self.scroll_y + viewport_h - 10.0 {
940 self.scroll_y = (line_y + line_height - viewport_h + 20.0).max(0.0);
941 }
942 }
943
944 if !self.line_wrap_enabled() {
945 let cursor_x = if self.multiline {
946 self.line_col_x(line_idx, col_idx)
947 } else {
948 self.glyph_positions
949 .get(col_idx)
950 .copied()
951 .unwrap_or(col_idx as f32 * char_width)
952 };
953 if cursor_x < self.scroll_x + 10.0 {
954 self.scroll_x = (cursor_x - 20.0).max(0.0);
955 } else if cursor_x + char_width > self.scroll_x + viewport_w - 10.0 {
956 self.scroll_x = (cursor_x + char_width - viewport_w + 20.0).max(0.0);
957 }
958 }
959 self.clamp_scroll();
960 }
961
962 /// `edit_buffer` without an input method's provisional run: what the box
963 /// holds, as opposed to what it shows.
964 pub fn committed_buffer(&self) -> String {
965 match self.composing {
966 Some((start, len)) => self.edit_buffer.chars().enumerate().filter(|(i, _)| *i < start || *i >= start + len).map(|(_, c)| c).collect(),
967 None => self.edit_buffer.clone(),
968 }
969 }
970
971 /// Take the provisional run out of the buffer, the caret back where it
972 /// began. Whether there was one.
973 fn strip_composition(&mut self) -> bool {
974 let Some((start, _)) = self.composing else { return false };
975 self.edit_buffer = self.committed_buffer();
976 self.composing = None;
977 self.cursor_idx = start.min(self.edit_buffer.chars().count());
978 self.select_anchor = None;
979 self.all_selected = false;
980 true
981 }
982
983 /// Drop a composition this box is showing, and have the input method
984 /// cancel it: the box is no longer where it is going.
985 fn abandon_composition(&mut self) {
986 if self.strip_composition() {
987 crate::ime::request_reset();
988 self.sync_editor_state();
989 }
990 self.ime_seen = crate::ime::generation();
991 }
992
993 /// Show the input method's current composition, if it has moved since
994 /// this box last showed one: the old run out, the new one in at the
995 /// caret, the caret where the input method has its cursor. A composition
996 /// begun over a selection replaces it, as typing would.
997 fn sync_preedit(&mut self) {
998 if !self.editing {
999 return;
1000 }
1001 let generation = crate::ime::generation();
1002 if generation == self.ime_seen {
1003 return;
1004 }
1005 self.ime_seen = generation;
1006 self.strip_composition();
1007 if let Some(p) = crate::ime::preedit() {
1008 let before = self.snapshot();
1009 if before.all_selected || before.selected_range().is_some() {
1010 let mut state = before.clone();
1011 state.insert_text("");
1012 self.history.record(before);
1013 self.edit_buffer = state.buffer;
1014 self.cursor_idx = state.cursor_idx;
1015 }
1016 let start = self.cursor_idx.min(self.edit_buffer.chars().count());
1017 let at = self.edit_buffer.char_indices().nth(start).map_or(self.edit_buffer.len(), |(b, _)| b);
1018 self.edit_buffer.insert_str(at, &p.text);
1019 self.composing = Some((start, p.text.chars().count()));
1020 self.cursor_idx = start + p.caret_chars();
1021 self.select_anchor = None;
1022 self.all_selected = false;
1023 }
1024 self.sync_editor_state();
1025 self.scroll_to_cursor();
1026 }
1027
1028 /// The legacy `focus()` body minus the global-focus claim (the caller's, via
1029 /// `EventCtx::request_focus`).
1030 fn begin_editing(&mut self) {
1031 if self.disabled { return; }
1032 // A composition still going is the input method's for wherever the
1033 // caret was; this box starts with none.
1034 self.composing = None;
1035 self.ime_seen = crate::ime::generation();
1036 self.editing = true;
1037 self.edit_buffer = self.text.clone();
1038 let len = self.edit_buffer.chars().count();
1039 self.cursor_idx = len;
1040 self.select_anchor = Some(0);
1041 self.all_selected = len > 0;
1042 self.just_focused = true;
1043 self.history.clear();
1044 self.sync_editor_state();
1045 }
1046
1047 /// The legacy `unfocus()` body: leave edit mode and commit the buffer.
1048 fn commit_editing(&mut self) {
1049 if self.editing {
1050 self.abandon_composition();
1051 self.editing = false;
1052 if self.text != self.edit_buffer {
1053 self.text = self.edit_buffer.clone();
1054 self.just_changed = true;
1055 }
1056 self.select_anchor = None;
1057 self.all_selected = false;
1058 self.sync_editor_state();
1059 }
1060 }
1061
1062 /// Map a press/drag position to a buffer index — the shared body of the legacy
1063 /// `mouse_input` press arm and `drag_update`.
1064 fn position_to_idx(&self, px: f32, py: f32) -> usize {
1065 let pad = self.pad();
1066 let top = self.label_top();
1067 if self.multiline {
1068 let line_height = self.line_height();
1069 let max_w = self.wrap_width(self.rect.width);
1070 let (lines, index_map) = self.wrap_text(max_w);
1071 let click_line = (((py - (self.rect.y + top + pad) + self.scroll_y) / line_height).floor() as isize).max(0) as usize;
1072 let rel_x = px - (self.rect.x + pad) + self.scroll_x;
1073 let click_col = self.line_x_to_col(click_line.min(lines.len() - 1), rel_x);
1074 self.map_2d_to_1d(&index_map, click_line, click_col, lines.len() - 1)
1075 } else {
1076 self.map_x_to_idx(px)
1077 }
1078 }
1079
1080 /// Extend the selection to a drag position — the shared body of the legacy
1081 /// `on_cursor_moved` drag arm and `drag_update` (which used no label inset).
1082 fn extend_selection_to(&mut self, px: f32, py: f32) -> bool {
1083 let drag_idx = self.position_to_idx(px, py);
1084 if self.cursor_idx != drag_idx {
1085 self.cursor_idx = drag_idx;
1086 self.just_focused = false;
1087 let len = self.edit_buffer.chars().count();
1088 let start = self.select_anchor.unwrap_or(0).min(self.cursor_idx);
1089 let end = self.select_anchor.unwrap_or(0).max(self.cursor_idx);
1090 self.all_selected = start == 0 && end == len && len > 0;
1091 true
1092 } else {
1093 false
1094 }
1095 }
1096
1097 /// Port of the legacy `keyboard_input` body.
1098 fn handle_key(&mut self, event: &KeyEvent) -> bool {
1099 if !self.editing || self.disabled { return false; }
1100 if event.state != ElementState::Pressed { return false; }
1101 // While an input method composes, its keys are its own: a shell does
1102 // not deliver them, and one that does is not editing this text.
1103 self.sync_preedit();
1104 if self.composing.is_some() {
1105 return true;
1106 }
1107
1108 let control = event.ctrl;
1109
1110 // The undo/redo chords, for apps that hand keys to widgets without
1111 // exposing a `UiContext` (the runner's routing reaches the box
1112 // through `ContextAction` first when they do). Before the working
1113 // copy below, since a step replaces the whole editing state.
1114 if control {
1115 if match_key_shortcut(event, &crate::input::widget_chord("undo", "", "ctrl+z")) {
1116 return self.undo_edit();
1117 }
1118 if match_key_shortcut(event, &crate::input::widget_chord("redo", "", "ctrl+shift+z")) {
1119 return self.redo_edit();
1120 }
1121 }
1122
1123 let before = self.snapshot();
1124 let mut state = before.clone();
1125
1126 let handled = match &event.logical_key {
1127 Key::Named(NamedKey::Backspace) => {
1128 state.delete_backwards()
1129 }
1130 Key::Named(NamedKey::Delete) => {
1131 state.delete_forwards()
1132 }
1133 Key::Named(NamedKey::ArrowLeft) => {
1134 state.move_cursor_left(event.shift)
1135 }
1136 Key::Named(NamedKey::ArrowRight) => {
1137 state.move_cursor_right(event.shift)
1138 }
1139 Key::Named(NamedKey::ArrowUp) => {
1140 if state.all_selected {
1141 state.clear_selection();
1142 } else if event.shift {
1143 if state.select_anchor.is_none() {
1144 state.select_anchor = Some(state.cursor_idx);
1145 }
1146 } else {
1147 state.clear_selection();
1148 }
1149 if self.multiline {
1150 let max_w = self.wrap_width(self.rect.width);
1151 let (lines, index_map) = self.wrap_text(max_w);
1152 let (cursor_l, cursor_c) = index_map[state.cursor_idx.min(index_map.len() - 1)];
1153 if cursor_l > 0 {
1154 state.cursor_idx = self.map_2d_to_1d(&index_map, cursor_l - 1, cursor_c, lines.len() - 1);
1155 } else {
1156 state.cursor_idx = 0;
1157 }
1158 } else {
1159 state.cursor_idx = 0;
1160 }
1161 true
1162 }
1163 Key::Named(NamedKey::ArrowDown) => {
1164 if state.all_selected {
1165 state.clear_selection();
1166 } else if event.shift {
1167 if state.select_anchor.is_none() {
1168 state.select_anchor = Some(state.cursor_idx);
1169 }
1170 } else {
1171 state.clear_selection();
1172 }
1173 if self.multiline {
1174 let max_w = self.wrap_width(self.rect.width);
1175 let (lines, index_map) = self.wrap_text(max_w);
1176 let (cursor_l, cursor_c) = index_map[state.cursor_idx.min(index_map.len() - 1)];
1177 if cursor_l < lines.len() - 1 {
1178 state.cursor_idx = self.map_2d_to_1d(&index_map, cursor_l + 1, cursor_c, lines.len() - 1);
1179 } else {
1180 state.cursor_idx = state.buffer.chars().count();
1181 }
1182 } else {
1183 state.cursor_idx = state.buffer.chars().count();
1184 }
1185 true
1186 }
1187 Key::Named(NamedKey::Home) => {
1188 state.move_cursor_to_start(event.shift)
1189 }
1190 Key::Named(NamedKey::End) => {
1191 state.move_cursor_to_end(event.shift)
1192 }
1193 Key::Named(NamedKey::Enter) => {
1194 if self.multiline {
1195 state.insert_text("\n");
1196 true
1197 } else {
1198 self.commit_editing();
1199 true
1200 }
1201 }
1202 Key::Named(NamedKey::Escape) => {
1203 self.editing = false;
1204 state.buffer = self.text.clone();
1205 state.clear_selection();
1206 true
1207 }
1208 Key::Character(ref ch_str) if control && (ch_str == "a" || ch_str == "A") => {
1209 state.select_all();
1210 true
1211 }
1212 Key::Character(ref ch_str) if control && (ch_str == "c" || ch_str == "C") => {
1213 if let Some(text) = self.clipboard_text(&state) {
1214 clipboard::copy_to_clipboard(&text);
1215 }
1216 true
1217 }
1218 // A password box's selection stays put: it can not be cut to the
1219 // clipboard, and deleting it alone would not be a cut.
1220 Key::Character(ref ch_str) if control && (ch_str == "x" || ch_str == "X") => {
1221 if let Some(text) = self.clipboard_text(&state) {
1222 clipboard::copy_to_clipboard(&text);
1223 state.insert_text("");
1224 }
1225 true
1226 }
1227 Key::Character(ref ch_str) if control && (ch_str == "v" || ch_str == "V") => {
1228 if let Some(pasted) = clipboard::read_from_clipboard() {
1229 let mut cleaned = String::new();
1230 for ch in pasted.chars() {
1231 if !ch.is_control() && ch != '\n' && ch != '\r' {
1232 cleaned.push(ch);
1233 }
1234 }
1235 state.insert_text(&cleaned);
1236 } else {
1237 state.clear_selection();
1238 }
1239 true
1240 }
1241 _ => {
1242 if let Some(text) = &event.text {
1243 if !control {
1244 state.insert_text(text);
1245 true
1246 } else {
1247 false
1248 }
1249 } else {
1250 false
1251 }
1252 }
1253 };
1254
1255 if self.editing {
1256 if state.buffer != before.buffer {
1257 // One step per typed run, per deleted run; whitespace
1258 // starts a new run so undo walks back a word at a time.
1259 // Replacing a selection is always its own step.
1260 let group = match &event.logical_key {
1261 _ if before.selected_range().is_some() => None,
1262 Key::Named(NamedKey::Backspace) => Some(2),
1263 Key::Named(NamedKey::Delete) => Some(3),
1264 Key::Character(_) if !control => {
1265 let ws = event.text.as_deref().is_some_and(|t| t.chars().all(char::is_whitespace));
1266 Some(if ws { 4 } else { 1 })
1267 }
1268 _ => None,
1269 };
1270 match group {
1271 Some(g) => self.history.record_grouped(before, g),
1272 None => self.history.record(before),
1273 }
1274 } else if handled {
1275 // A cursor or selection move between keystrokes splits the
1276 // run: "abc", move, "def" undoes as two steps.
1277 self.history.break_group();
1278 }
1279 self.edit_buffer = state.buffer;
1280 self.cursor_idx = state.cursor_idx;
1281 self.select_anchor = state.select_anchor;
1282 self.all_selected = state.all_selected;
1283 self.sync_editor_state();
1284 self.scroll_to_cursor();
1285 }
1286
1287 handled
1288 }
1289
1290 /// Port of the legacy `mouse_wheel` body (scroll the multiline/no-wrap viewports).
1291 fn handle_wheel(&mut self, delta: &MouseScrollDelta) -> bool {
1292 let pad = self.pad();
1293 if self.disabled { return false; }
1294 let char_width = self.char_width();
1295 let line_height = self.line_height();
1296
1297 let max_w = self.wrap_width(self.rect.width);
1298
1299 let (lines, _) = if self.multiline {
1300 self.wrap_text(max_w)
1301 } else {
1302 let buffer = if self.editing { &self.edit_buffer } else { &self.text };
1303 (vec![buffer.clone()], vec![(0, 0); buffer.chars().count() + 1])
1304 };
1305
1306 let mut dy_px = 0.0;
1307 let mut max_scroll_y = 0.0;
1308 if self.multiline {
1309 let content_h = lines.len() as f32 * line_height;
1310 max_scroll_y = (content_h - (self.rect.height - 2.0 * pad)).max(0.0);
1311 dy_px = match *delta {
1312 MouseScrollDelta::LineDelta(_, dy) => -dy * line_height * 2.0,
1313 MouseScrollDelta::PixelDelta(pos) => -pos.y as f32,
1314 };
1315 }
1316
1317 let mut dx_px = 0.0;
1318 let mut max_scroll_x = 0.0;
1319 if !self.line_wrap_enabled() {
1320 let content_w = self.content_width(&lines);
1321 max_scroll_x = (content_w - (self.rect.width - 2.0 * pad)).max(0.0);
1322 let natural = crate::layout::touchpad_natural_scroll();
1323 let scroll_amt_x = match *delta {
1324 MouseScrollDelta::LineDelta(dx, dy) => {
1325 if !self.multiline {
1326 let scroll_val = if dy != 0.0 { -dy } else { if natural { -dx } else { dx } };
1327 scroll_val * char_width * 3.0
1328 } else {
1329 let scroll_val = if natural { -dx } else { dx };
1330 scroll_val * char_width * 3.0
1331 }
1332 }
1333 MouseScrollDelta::PixelDelta(pos) => {
1334 if !self.multiline {
1335
1336 if pos.y != 0.0 { -pos.y as f32 } else { if natural { -pos.x as f32 } else { pos.x as f32 } }
1337 } else {
1338 if natural { -pos.x as f32 } else { pos.x as f32 }
1339 }
1340 }
1341 };
1342 dx_px = scroll_amt_x;
1343 }
1344
1345 // Both axes through the shared motion: notches glide, finger tracks
1346 // 1:1, a flick coasts. The pub offsets are the drawn values.
1347 self.scroll_max = (max_scroll_x, max_scroll_y);
1348 self.scroll_motion.reconcile(self.scroll_x, self.scroll_y);
1349 let discrete = matches!(delta, MouseScrollDelta::LineDelta(..));
1350 let changed = self.scroll_motion.apply_px(
1351 dx_px,
1352 dy_px,
1353 discrete,
1354 Bounds::max(max_scroll_x),
1355 Bounds::max(max_scroll_y),
1356 );
1357 self.scroll_x = self.scroll_motion.x.pos();
1358 self.scroll_y = self.scroll_motion.y.pos();
1359 changed
1360 }
1361
1362 /// Selection highlight + caret quads, shared by both render branches. `x`/`w` are the
1363 /// (possibly label-inset) horizontal span the branch draws in — the legacy paths differed
1364 /// (non-rounded and rounded alike use the full base span).
1365 /// The selection highlight, an input method's composition underlined, and
1366 /// the caret, as quads. Returns the caret's rect while editing (unclipped,
1367 /// in the box's coordinates), which the painter reports to the input method.
1368 fn selection_quads(&self, x: f32, w: f32, out: &mut Vec<(f32, f32, f32, f32, [f32; 4])>) -> Option<[f32; 4]> {
1369 let pad = self.pad();
1370 if !(self.editing || self.select_anchor.is_some()) {
1371 return None;
1372 }
1373 let mut caret = None;
1374 let top = self.label_top();
1375 let char_width = self.char_width();
1376 let line_height = self.line_height();
1377
1378 let highlight_color = [0.20, 0.50, 0.85, 0.3];
1379 let cursor_color = if self.draw_bg_border {
1380 [0.80, 0.80, 0.85, 1.0]
1381 } else {
1382 [0.10, 0.10, 0.15, 1.0]
1383 };
1384
1385 let start = self.select_anchor.unwrap_or(self.cursor_idx).min(self.cursor_idx);
1386 let end = self.select_anchor.unwrap_or(self.cursor_idx).max(self.cursor_idx);
1387
1388 if self.multiline {
1389 let max_w = self.wrap_width(w);
1390 let (_lines, index_map) = self.wrap_text(max_w);
1391
1392 let view_top = self.rect.y + top;
1393 let view_bottom = self.rect.y + self.rect.height;
1394
1395 if start != end {
1396 let start_pos = index_map[start.min(index_map.len() - 1)];
1397 let end_pos = index_map[end.min(index_map.len() - 1)];
1398
1399 for line_idx in start_pos.0..=end_pos.0 {
1400 let mut line_start_col = None;
1401 let mut line_end_col = None;
1402 for idx in start..end {
1403 if idx < index_map.len() {
1404 let (l, c) = index_map[idx];
1405 if l == line_idx {
1406 if line_start_col.is_none() || c < line_start_col.unwrap() {
1407 line_start_col = Some(c);
1408 }
1409 if line_end_col.is_none() || c > line_end_col.unwrap() {
1410 line_end_col = Some(c);
1411 }
1412 }
1413 }
1414 }
1415 if let (Some(sc), Some(ec)) = (line_start_col, line_end_col) {
1416 let highlight_y = self.rect.y + top + pad + (line_idx as f32 * line_height) - self.scroll_y;
1417 let clipped_y = highlight_y.max(view_top);
1418 let clipped_bottom = (highlight_y + line_height).min(view_bottom);
1419 let shift = self.line_shift.get(line_idx).copied().unwrap_or(0.0);
1420 // The boxes of the selected clusters: two pieces where the selection
1421 // crosses a change of direction. Without a shaped run, the column span.
1422 let spans = match (self.line_runs.get(line_idx), _lines.get(line_idx)) {
1423 (Some(run), Some(line)) => {
1424 let byte = |col: usize| line.char_indices().nth(col).map_or(line.len(), |(b, _)| b);
1425 run.spans(byte(sc), byte(ec + 1)).into_iter().map(|(a, b)| (a + shift, b + shift)).collect()
1426 }
1427 _ => {
1428 let (a, b) = (self.line_col_x(line_idx, sc), self.line_col_x(line_idx, ec + 1));
1429 vec![(a.min(b), a.max(b))]
1430 }
1431 };
1432 for (a, b) in spans {
1433 let h_left = (x + pad + a - self.scroll_x).max(x + pad);
1434 let h_right = (x + pad + b - self.scroll_x).min(x + w - pad);
1435 if h_left < h_right && clipped_y < clipped_bottom {
1436 out.push((h_left, clipped_y, h_right - h_left, clipped_bottom - clipped_y, highlight_color));
1437 }
1438 }
1439 }
1440 }
1441 }
1442
1443 if let Some((cs, cl)) = self.composing {
1444 // The composition's underline, a line at a time, under the
1445 // glyphs it covers.
1446 let last_line = index_map.get((cs + cl).saturating_sub(1).min(index_map.len() - 1)).map_or(0, |p| p.0);
1447 let first_line = index_map.get(cs.min(index_map.len() - 1)).map_or(0, |p| p.0);
1448 for line_idx in first_line..=last_line {
1449 let cols: Vec<usize> = (cs..cs + cl)
1450 .filter_map(|i| index_map.get(i).filter(|p| p.0 == line_idx).map(|p| p.1))
1451 .collect();
1452 if let (Some(&a), Some(&b)) = (cols.iter().min(), cols.iter().max()) {
1453 let (xa, xb) = (self.line_col_x(line_idx, a), self.line_col_x(line_idx, b + 1));
1454 let ux = x + pad + xa.min(xb) - self.scroll_x;
1455 let uw = (xb - xa).abs();
1456 let uy = self.rect.y + top + pad + ((line_idx + 1) as f32 * line_height) - 2.0 - self.scroll_y;
1457 let left = ux.max(x + pad);
1458 let right = (ux + uw).min(x + w - pad);
1459 if left < right && uy >= view_top && uy + 1.5 <= view_bottom {
1460 out.push((left, uy, right - left, 1.5, cursor_color));
1461 }
1462 }
1463 }
1464 }
1465
1466 if self.editing {
1467 let caret_h = self.font_size * 1.15;
1468 let (cursor_l, cursor_c) = index_map[self.cursor_idx.min(index_map.len() - 1)];
1469 let cursor_x = x + pad + self.line_col_x(cursor_l, cursor_c) - self.scroll_x;
1470 let cursor_y = self.rect.y + top + pad + (cursor_l as f32 * line_height) + (line_height - caret_h) / 2.0 - self.scroll_y;
1471 caret = Some([cursor_x, cursor_y, 1.5, caret_h]);
1472 let clipped_y = cursor_y.max(view_top);
1473 let clipped_bottom = (cursor_y + caret_h).min(view_bottom);
1474 if cursor_x >= x + pad && cursor_x <= x + w - pad
1475 && clipped_y < clipped_bottom {
1476 out.push((cursor_x, clipped_y, 1.5, clipped_bottom - clipped_y, cursor_color));
1477 }
1478 }
1479 } else {
1480 let caret_h = self.font_size * 1.15;
1481 if start != end {
1482 // The boxes of the selected clusters: two pieces where the selection crosses
1483 // a change of direction. Without a shaped run, the column span.
1484 let shown = if self.is_password { "•".repeat(self.edit_buffer.chars().count()) } else { self.edit_buffer.clone() };
1485 let spans: Vec<(f32, f32)> = match &self.glyph_run {
1486 Some(run) => {
1487 let byte = |col: usize| shown.char_indices().nth(col).map_or(shown.len(), |(b, _)| b);
1488 run.spans(byte(start), byte(end)).into_iter().map(|(a, b)| (a + self.glyph_shift, b + self.glyph_shift)).collect()
1489 }
1490 None => {
1491 let at = |i: usize| self.glyph_positions.get(i).copied().unwrap_or(i as f32 * char_width);
1492 vec![(at(start).min(at(end)), at(start).max(at(end)))]
1493 }
1494 };
1495 for (a, b) in spans {
1496 let h_left = (x + pad + a - self.scroll_x).max(x + pad);
1497 let h_right = (x + pad + b - self.scroll_x).min(x + w - pad);
1498 if h_left < h_right {
1499 out.push((
1500 h_left,
1501 crate::layout::align_text_y(self.rect.y, self.rect.height, self.font_size, top),
1502 h_right - h_left,
1503 crate::layout::line_height(self.font_size),
1504 highlight_color,
1505 ));
1506 }
1507 }
1508 }
1509
1510 if let Some((cs, cl)) = self.composing {
1511 let at = |i: usize| self.glyph_positions.get(i).copied().unwrap_or(i as f32 * char_width);
1512 let (ca, cb) = (at(cs).min(at(cs + cl)), at(cs).max(at(cs + cl)));
1513 let left = (x + pad + ca - self.scroll_x).max(x + pad);
1514 let right = (x + pad + cb - self.scroll_x).min(x + w - pad);
1515 if left < right {
1516 let text_y = crate::layout::align_text_y(self.rect.y, self.rect.height, self.font_size, top);
1517 out.push((left, text_y + self.font_size + 1.0, right - left, 1.5, cursor_color));
1518 }
1519 }
1520
1521 if self.editing {
1522 let offset = if self.glyph_positions.is_empty() {
1523 self.cursor_idx as f32 * char_width
1524 } else {
1525 self.cursor_x_offset
1526 };
1527 let cursor_x = x + pad + offset - self.scroll_x;
1528 let text_y = crate::layout::align_text_y(self.rect.y, self.rect.height, self.font_size, top);
1529 let cursor_y = text_y + (self.font_size - caret_h) / 2.0;
1530 caret = Some([cursor_x, cursor_y, 1.5, caret_h]);
1531 if cursor_x >= x + pad && cursor_x <= x + w - pad {
1532 out.push((cursor_x, cursor_y, 1.5, caret_h, cursor_color));
1533 }
1534 }
1535 }
1536 caret
1537 }
1538
1539 /// The value/placeholder text lines — the legacy `text_labels` body minus the control
1540 /// label (the adapter's base-label machinery draws that).
1541 fn value_labels(&self) -> Vec<TextLabel> {
1542 let pad = self.pad();
1543 let mut labels = Vec::new();
1544 let top = self.label_top();
1545 let mut val_text = if self.editing {
1546 self.edit_buffer.clone()
1547 } else {
1548 self.text.clone()
1549 };
1550 if self.is_password {
1551 val_text = "•".repeat(val_text.chars().count());
1552 }
1553
1554 let is_placeholder = val_text.is_empty() && self.placeholder.is_some();
1555 let display_text = if is_placeholder {
1556 self.placeholder.as_ref().unwrap().clone()
1557 } else {
1558 val_text
1559 };
1560
1561 let label_color = if is_placeholder {
1562 crate::colors::textbox_placeholder_text_color()
1563 } else if let Some(custom_color) = self.text_color {
1564 custom_color
1565 } else if self.disabled {
1566 [0x53, 0x53, 0x5a]
1567 } else if self.all_selected {
1568 [0xff, 0xff, 0xff]
1569 } else if self.editing {
1570 [0xee, 0xee, 0xf5]
1571 } else {
1572 [0xcc, 0xcc, 0xd4]
1573 };
1574
1575 let x = self.rect.x;
1576 let w = self.rect.width;
1577
1578 if self.multiline {
1579 let line_height = self.line_height();
1580 let max_w = self.wrap_width(w);
1581 let (lines, _) = self.wrap_text(max_w);
1582 let lines_to_draw = if is_placeholder {
1583 self.wrap_str(self.placeholder.as_deref().unwrap_or(""), max_w).0
1584 } else {
1585 lines
1586 };
1587 for (line_idx, line_text) in lines_to_draw.iter().enumerate() {
1588 let shift = if is_placeholder { 0.0 } else { self.line_shift.get(line_idx).copied().unwrap_or(0.0) };
1589 labels.push(TextLabel {
1590 text: line_text.clone(),
1591 x: x + pad + shift - self.scroll_x,
1592 y: self.rect.y + top + pad + (line_idx as f32 * line_height) + (line_height - self.font_size) / 2.0 - self.scroll_y,
1593 font_size: self.font_size,
1594 color: label_color,
1595 });
1596 }
1597 } else {
1598 labels.push(TextLabel {
1599 text: display_text,
1600 x: x + pad + self.glyph_shift - self.scroll_x,
1601 y: crate::layout::align_text_y(self.rect.y, self.rect.height, self.font_size, top),
1602 font_size: self.font_size,
1603 color: label_color,
1604 });
1605 }
1606 labels
1607 }
1608
1609 /// The well this TextBox carves: a [`crate::scene::paint::Field`] that is
1610 /// all well, lit while editing — its radii top-left, top-right,
1611 /// bottom-right, bottom-left, the right two square when the box is
1612 /// [`Self::joined_right`] (its host draws the field that joins it to a
1613 /// run, `ParametersBg::fields`). `None` when it draws no relief at all
1614 /// (square-cornered legacy geometry, `control_relief` off, or a box that
1615 /// draws no background).
1616 ///
1617 /// The SINGLE source for that geometry: `paint` carves it here, and the
1618 /// flat-path bridge in `layout::render_widget` re-offers the same rect
1619 /// through [`crate::layout::RenderTarget::recess`] for hosts that consume
1620 /// `all_quads` and so never see the carve. A second copy of this math in
1621 /// the bridge is exactly how the two would drift apart.
1622 pub fn well(&self) -> Option<crate::scene::paint::Field> {
1623 let radius = crate::layout::textbox_corner_radius();
1624 if radius <= 0.0 || !self.recessed() || !self.draw_bg_border {
1625 return None;
1626 }
1627 let top = self.label_top();
1628 let well = Rect {
1629 x: self.rect.x,
1630 y: self.rect.y + top,
1631 width: self.rect.width,
1632 height: self.rect.height - top,
1633 };
1634 let depth = crate::layout::bevel_width().min(well.height * 0.2);
1635 let right = if self.joined_right { 0.0 } else { radius };
1636 let (well, radii) = crate::layout::carve_inside(well, (radius, right, right, radius), depth);
1637 let tint = self.editing.then(|| {
1638 let hc = crate::color::highlight_primary_color();
1639 [hc[0], hc[1], hc[2]]
1640 });
1641 Some(crate::scene::paint::Field::well(well, radii, depth).with_tint(tint))
1642 }
1643 }
1644
1645 impl Adapted<TextBox> {
1646 /// Recessed style: see the `recessed` field.
1647 pub fn with_recessed(mut self, recessed: bool) -> Self {
1648 self.recessed = Some(recessed);
1649 self
1650 }
1651
1652 pub fn with_update_on_type(mut self, update: bool) -> Self {
1653 self.update_on_type = update;
1654 self
1655 }
1656
1657 pub fn with_multiline(mut self, multiline: bool) -> Self {
1658 self.multiline = multiline;
1659 self
1660 }
1661
1662 pub fn with_line_wrap(mut self, wrap: bool) -> Self {
1663 self.line_wrap_override = Some(wrap);
1664 self
1665 }
1666
1667 pub fn with_draw_bg_border(mut self, draw: bool) -> Self {
1668 self.draw_bg_border = draw;
1669 self
1670 }
1671
1672 pub fn with_text_color(mut self, color: Option<[u8; 3]>) -> Self {
1673 self.text_color = color;
1674 self
1675 }
1676
1677 pub fn with_font_size(mut self, size: f32) -> Self {
1678 self.font_size = size;
1679 self
1680 }
1681
1682 pub fn with_font_family(mut self, family: String) -> Self {
1683 self.font_family = family;
1684 self
1685 }
1686
1687 pub fn with_password(mut self, is_password: bool) -> Self {
1688 self.is_password = is_password;
1689 self
1690 }
1691
1692 /// A search box: the toolkit's "Search..." placeholder, in the user's language, and a
1693 /// menu with Clear.
1694 pub fn with_search(mut self) -> Self {
1695 self.is_search = true;
1696 self.placeholder = Some(crate::l10n::tr("search-placeholder"));
1697 self
1698 }
1699
1700 pub fn with_placeholder(mut self, placeholder: &str) -> Self {
1701 self.placeholder = Some(placeholder.to_string());
1702 self
1703 }
1704
1705 pub fn with_max_width(mut self, max_w: Option<f32>) -> Self {
1706 self.max_width = max_w;
1707 self
1708 }
1709
1710 pub fn with_width(mut self, w: f32) -> Self {
1711 self.width = Some(w);
1712 self
1713 }
1714 }
1715
1716 impl Layout for TextBox {
1717
1718 /// One row for a single-line box; a multiline box has no natural height of its own —
1719 /// the host sizes it, and a layout strategy leaves its assigned rect alone.
1720 fn intrinsic_size(&self) -> Option<Size> {
1721 if self.multiline {
1722 return None;
1723 }
1724 Some(Size::new(0.0, crate::layout::textbox_height()))
1725 }
1726
1727 fn hit_row_rect(&self) -> bool {
1728 true
1729 }
1730
1731 /// The legacy `set_rect` width clamp: an explicit `width` wins, else cap at `max_width`.
1732 fn adjust_rect(&self, requested: Rect) -> Rect {
1733 let final_w = if let Some(explicit_w) = self.width {
1734 explicit_w
1735 } else if let Some(max_w) = self.max_width {
1736 requested.width.min(max_w)
1737 } else {
1738 requested.width
1739 };
1740 Rect { width: final_w, ..requested }
1741 }
1742
1743 /// The legacy `set_row_rect` applied the same clamp to the row span.
1744 fn adjust_row_rect(&self, x: f32, w: f32) -> (f32, f32) {
1745 let final_w = if let Some(explicit_w) = self.width {
1746 explicit_w
1747 } else if let Some(max_w) = self.max_width {
1748 w.min(max_w)
1749 } else {
1750 w
1751 };
1752 (x, final_w)
1753 }
1754
1755 fn rect_assigned(&mut self, rect: Rect) {
1756 self.rect = rect;
1757 self.clamp_scroll();
1758 }
1759 }
1760
1761 impl Paint for TextBox {
1762 fn color(&self) -> [f32; 4] {
1763 [0.10, 0.10, 0.16, 1.0]
1764 }
1765
1766 fn corner_style(&self, _rect: Rect) -> Option<(f32, (bool, bool, bool, bool))> {
1767 let r = crate::layout::textbox_corner_radius();
1768 if r > 0.0 {
1769 Some((r, (true, true, true, true)))
1770 } else {
1771 Some((r, (false, false, false, false)))
1772 }
1773 }
1774
1775 fn widget_font(&self) -> Option<String> {
1776 Some(crate::layout::control_label_font_detached())
1777 }
1778
1779 /// Content text font for the paint walk: a TextBox whose `font_family`/`font_size` was
1780 /// deliberately customized (cce-text-editor's monospace editor) draws its value text in
1781 /// that family at the label's own size — a bare family name, so the control-font string's
1782 /// size suffix doesn't override `font_size`. Default boxes keep the `widget_font` string
1783 /// verbatim (the legacy convention, size suffix included).
1784 fn text_font(&self) -> Option<String> {
1785 if self.font_family != self.default_font_family || self.font_size != self.default_font_size {
1786 Some(self.font_family.clone())
1787 } else {
1788 self.widget_font()
1789 }
1790 }
1791
1792 fn text_attrs(&self) -> crate::scene::paint::TextAttrs {
1793 self.font_attrs
1794 }
1795
1796 fn sync_label(&mut self, label: &str) {
1797 self.label = Some(label.to_string());
1798 }
1799
1800 fn text_bounds(&self, rect: Rect) -> Option<[f32; 4]> {
1801 Some(self.text_clip(rect))
1802 }
1803
1804 /// The legacy `prepare_text`: sync font family/size with the live config defaults, then
1805 /// shape the display text and record per-glyph advances (`map_x_to_idx` reads them).
1806 fn prepare_text(&mut self, fs: &mut cosmic_text::FontSystem, _rect: Rect) {
1807 // The input method's composition, shown before the buffer is shaped.
1808 self.sync_preedit();
1809 let (style_family, style_size) = crate::layout::control_label_font_detached_parsed();
1810 if self.font_size == self.default_font_size {
1811 self.font_size = style_size;
1812 }
1813 self.default_font_size = style_size;
1814
1815 if self.font_family == self.default_font_family {
1816 self.font_family = style_family.clone();
1817 }
1818 self.default_font_family = style_family;
1819
1820 let text_src = if self.editing { &self.edit_buffer } else { &self.text };
1821 let showing_placeholder = text_src.is_empty() && self.placeholder.is_some();
1822 let display_text = if showing_placeholder {
1823 self.placeholder.as_ref().unwrap().as_str()
1824 } else {
1825 text_src.as_str()
1826 };
1827
1828 // Measured in the family the value text is DRAWN in — see `value_font`.
1829 let font_fam = self.value_font();
1830 let scale = crate::scale::scale_factor().max(1.0);
1831
1832 // One column's advance, from the same shaping path as the labels (buffer-cached,
1833 // so this is a lookup after the first frame per family/size).
1834 let probe = crate::backend::text::shared_text_buffer(
1835 fs,
1836 "MMMMMMMM",
1837 self.font_size,
1838 font_fam.as_deref(),
1839 self.font_attrs,
1840 );
1841 self.shaped_char_advance = probe
1842 .layout_runs()
1843 .next()
1844 .and_then(|run| run.glyphs.last().map(|g| (g.x + g.w) / scale / 8.0))
1845 .unwrap_or(0.0);
1846
1847 // `char_width()` returns this frame's shaped advance from here on, so the
1848 // wrap below matches the one `selection_quads`/`value_labels` compute at
1849 // paint time.
1850 let wrap = self.multiline.then(|| self.wrap_width(self.rect.width).to_bits());
1851
1852 let key = PrepKey {
1853 text: display_text.to_string(),
1854 placeholder: showing_placeholder,
1855 password: self.is_password,
1856 font_size_bits: self.font_size.to_bits(),
1857 font: font_fam.clone(),
1858 attrs: self.font_attrs,
1859 scale_bits: scale.to_bits(),
1860 vertical: crate::backend::text::vertical_text().is_some(),
1861 wrap,
1862 room_bits: (self.rect.width - 2.0 * self.pad()).max(0.0).to_bits(),
1863 };
1864 if self.prep_key.as_ref() != Some(&key) {
1865 self.shape_columns(fs, &key);
1866 self.prep_key = Some(key);
1867 }
1868
1869 let cursor_pos = self.cursor_idx.min(self.glyph_positions.len().saturating_sub(1));
1870 self.cursor_x_offset = self.glyph_positions.get(cursor_pos).copied().unwrap_or(0.0);
1871 }
1872
1873 fn paint(&self, rect: Rect, ctx: &mut PaintCtx) {
1874 let pad = self.pad();
1875 let top = self.label_top();
1876 let base_y = rect.y - top;
1877 let base_h = rect.height + top;
1878 let visual_h = rect.height;
1879 let radius = crate::layout::textbox_corner_radius();
1880 let border_w = self.border_width();
1881
1882 // Open for typing: say so to the compositor this frame (the
1883 // on-screen keyboard follows it). The field stands in for the caret
1884 // until the caret is drawn below, which reports itself.
1885 if self.editing && !self.disabled {
1886 let (ox, oy) = ctx.offset();
1887 crate::text_input::claim(self.rect.x + ox, self.rect.y + top + oy, self.rect.width, visual_h);
1888 }
1889
1890 // Keep the model's cached rect and the paint rect consistent: paint receives the
1891 // content rect derived from the same base the cache holds, so the bodies below read
1892 // `self.rect` (the legacy `self.base`) exactly as legacy did. `rect` is used only to
1893 // localize this frame's geometry.
1894 let _ = (base_y, base_h);
1895
1896 if radius <= 0.0 {
1897 // Legacy `extra_quads`: full base span, disabled
1898 // special-case with early return.
1899 let mut quads: Vec<(f32, f32, f32, f32, [f32; 4])> = Vec::new();
1900 if self.disabled {
1901 if self.draw_bg_border {
1902 quads.push((self.rect.x, self.rect.y + top, self.rect.width, visual_h, [0.12, 0.12, 0.16, 1.0]));
1903 quads.push((self.rect.x + border_w, self.rect.y + top + border_w, self.rect.width - 2.0 * border_w, visual_h - 2.0 * border_w, [0.06, 0.06, 0.08, 1.0]));
1904 }
1905 } else {
1906 if self.draw_bg_border {
1907 // One background regardless of focus — the focus treatment is
1908 // the tinted recess rim (rounded path) / editing border, not a
1909 // surface swap.
1910 let bg_color = crate::colors::textbox_background_color();
1911 let border_color = crate::colors::well_frame_color(self.hovered, self.editing);
1912 quads.push((self.rect.x, self.rect.y + top, self.rect.width, visual_h, border_color));
1913 quads.push((self.rect.x + border_w, self.rect.y + top + border_w, self.rect.width - 2.0 * border_w, visual_h - 2.0 * border_w, bg_color));
1914 }
1915 if let Some([cx, cy, cw, ch]) = self.selection_quads(self.rect.x, self.rect.width, &mut quads) {
1916 let (ox, oy) = ctx.offset();
1917 crate::ime::report_caret(cx + ox, cy + oy, cw, ch);
1918 }
1919 }
1920 for (qx, qy, qw, qh, qc) in quads {
1921 ctx.quad(Rect { x: qx, y: qy, width: qw, height: qh }, qc);
1922 }
1923 } else {
1924 // Legacy `all_rounded_quads`: no disabled special-case.
1925 let x = self.rect.x;
1926 let w = self.rect.width;
1927
1928 // One background regardless of focus (see the flat path above).
1929 let bg_color = crate::colors::textbox_background_color();
1930 let border_color = crate::colors::well_frame_color(self.hovered, self.editing);
1931
1932 if self.draw_bg_border {
1933 let corners = (true, true, true, true);
1934 // Recessed + transparent fill: the carve alone defines the
1935 // well — the plate below is its floor, so the flat border and
1936 // bg rects are skipped entirely. An opaque fill (e.g. the edit
1937 // color while editing) draws as usual and gets carved.
1938 let bare = self.recessed() && bg_color[3] <= 0.001;
1939 if !bare {
1940 ctx.rounded_rect(Rect { x, y: self.rect.y + top, width: w, height: visual_h }, radius, corners, border_color);
1941 ctx.rounded_rect(
1942 Rect { x: x + border_w, y: self.rect.y + top + border_w, width: w - 2.0 * border_w, height: visual_h - 2.0 * border_w },
1943 (radius - border_w).max(0.0),
1944 corners,
1945 bg_color,
1946 );
1947 }
1948 if let Some(field) = self.well() {
1949 // Focus lights the well's rim in the highlight accent (with
1950 // the shader's complementary shadow) — the TreeList treatment.
1951 ctx.field(&field);
1952 }
1953 }
1954
1955 let mut quads: Vec<(f32, f32, f32, f32, [f32; 4])> = Vec::new();
1956 if let Some([cx, cy, cw, ch]) = self.selection_quads(x, w, &mut quads) {
1957 let (ox, oy) = ctx.offset();
1958 crate::ime::report_caret(cx + ox, cy + oy, cw, ch);
1959 }
1960 for (qx, qy, qw, qh, qc) in quads {
1961 ctx.quad(Rect { x: qx, y: qy, width: qw, height: qh }, qc);
1962 }
1963
1964 // Relief scrollbar for overflowing multiline content — the shared
1965 // groove + raised-pill painter (the TreeList treatment), persistent
1966 // rather than activity-faded: an editor keeps its position
1967 // indicator. Content height mirrors clamp_scroll's math (`pad`
1968 // top and bottom), so the thumb tracks the scroll range exactly.
1969 if self.multiline {
1970 let line_height = self.line_height();
1971 let content_h = self.wrap_text(self.wrap_width(self.rect.width)).0.len() as f32 * line_height;
1972 crate::widget::container::scroll_box::paint_relief_scrollbar(
1973 ctx,
1974 Rect { x, y: self.rect.y + top, width: w, height: visual_h },
1975 content_h + 2.0 * pad,
1976 self.scroll_y,
1977 );
1978 }
1979 }
1980
1981 // The content is whatever has been typed, so a line longer than the
1982 // well is routine rather than exceptional; the well scrolls, but
1983 // nothing stopped the glyphs drawing outside it. (The adapter's label
1984 // bridge swaps this for `text_bounds` — the same clip.)
1985 let well = Some(self.text_clip(Rect { x: self.rect.x, y: self.rect.y + top, width: self.rect.width, height: visual_h }));
1986 let font = self.value_font();
1987 for tl in self.value_labels() {
1988 ctx.text_with(tl.text, tl.x, tl.y, tl.font_size, tl.color, font.clone(), well);
1989 }
1990 }
1991 }
1992
1993 impl Input for TextBox {
1994 fn focus_role(&self) -> crate::widget::FocusRole {
1995 crate::widget::FocusRole::Well
1996 }
1997
1998 /// A multi-line box that is editing types Tab, as an editor does; a one-line field lets
1999 /// the Tab walk take it (Tab leaves a field).
2000 fn keeps_tab(&self) -> bool {
2001 self.multiline && self.editing
2002 }
2003 /// Advances the wheel glide / trackpad coast behind the scroll offsets.
2004 /// Cheap when idle (the common case); `wants_tick` is unconditional
2005 /// because it is sampled once at registration.
2006 fn tick(&mut self, dt: f32, _rect: Rect) -> bool {
2007 self.scroll_motion.reconcile(self.scroll_x, self.scroll_y);
2008 if !self.scroll_motion.is_animating() {
2009 return false;
2010 }
2011 let (mx, my) = self.scroll_max;
2012 let moved = self.scroll_motion.tick(dt, Bounds::max(mx), Bounds::max(my));
2013 self.scroll_x = self.scroll_motion.x.pos();
2014 self.scroll_y = self.scroll_motion.y.pos();
2015 moved || self.scroll_motion.is_animating()
2016 }
2017
2018 fn wants_tick(&self) -> bool {
2019 true
2020 }
2021
2022 fn tracks_base_focus(&self) -> bool {
2023 false
2024 }
2025
2026 fn on_event(&mut self, event: &Event, ectx: &mut EventCtx) -> bool {
2027 // A press moves the caret: a composition in progress is left where
2028 // it was, cancelled.
2029 if let Event::MouseButton { state: ElementState::Pressed, .. } = event {
2030 self.abandon_composition();
2031 }
2032 match event {
2033 Event::MouseButton { button: MouseButton::Right, state: ElementState::Pressed, x: px, y: py, .. } => {
2034 if self.disabled { return false; }
2035 // Adapter hit-gates presses; legacy focused an un-editing box before opening
2036 // the menu (work-before-menu, so `opens_context_menu` can't express it).
2037 if !self.editing {
2038 self.begin_editing();
2039 ectx.request_focus();
2040 }
2041 ectx.open_context_menu(*px, *py);
2042 true
2043 }
2044 Event::MouseButton { button: MouseButton::Left, state: ElementState::Pressed, x: px, y: py, .. } => {
2045 if self.disabled { return false; }
2046 // A click aims into the field: it places the caret where it landed,
2047 // whether or not it is the click that focuses. Only KEYBOARD focus
2048 // (`Event::FocusIn` -> `begin_editing`) arms select-all. That split is
2049 // the convention everywhere — tabbing selects a field, clicking points
2050 // into it — and it is what a prefilled box needs: select-all on the
2051 // focusing click meant the first keystroke wiped the whole value, which
2052 // is wrong for the ~26 prefilled single-line boxes across the fleet
2053 // (login username, reply subject, a unit's ExecStart, a config value).
2054 // This used to be carved out for multiline only; both arms now agree.
2055 let focusing = !self.editing;
2056 if focusing {
2057 self.begin_editing();
2058 ectx.request_focus();
2059 }
2060 let idx = self.position_to_idx(*px, *py);
2061 self.cursor_idx = idx;
2062 self.select_anchor = Some(idx);
2063 self.all_selected = false;
2064 if focusing {
2065 self.sync_editor_state();
2066 }
2067 true
2068 }
2069 Event::MouseButton { button: MouseButton::Left, state: ElementState::Released, x: px, y: py, .. } => {
2070 if self.disabled { return false; }
2071 // Legacy gated releases on the hit test; the adapter delivers them ungated, so
2072 // re-check containment (the plain block rect — row spans approximated).
2073 let (bx, by, bw, bh) = (self.rect.x, self.rect.y, self.rect.width, self.rect.height);
2074 if !(*px >= bx && *px <= bx + bw && *py >= by && *py <= by + bh) {
2075 return false;
2076 }
2077 if self.dragging {
2078 self.dragging = false;
2079 }
2080 if self.select_anchor == Some(self.cursor_idx) {
2081 self.select_anchor = None;
2082 }
2083 true
2084 }
2085 Event::PointerMove { x: px, y: py, .. } => {
2086 // The drag-selection half of the legacy `on_cursor_moved`; hover bookkeeping
2087 // is the adapter's (Enter/Leave below).
2088 if self.disabled {
2089 return false;
2090 }
2091 if self.dragging && self.editing {
2092 return self.extend_selection_to(*px, *py);
2093 }
2094 false
2095 }
2096 Event::MouseEnter => {
2097 self.hovered = !self.disabled;
2098 true
2099 }
2100 Event::MouseLeave => {
2101 self.hovered = false;
2102 true
2103 }
2104 Event::MouseWheel { delta, .. } => self.handle_wheel(delta),
2105 Event::KeyInput(key_event) => self.handle_key(key_event),
2106 Event::FocusIn => {
2107 // The legacy `focus()`: enter editing and claim the global slot (unless
2108 // disabled — legacy early-returned before `set_focused`). Skipped when
2109 // already editing: a press-then-set_focused sequence must not re-arm
2110 // select-all over the caret the press just placed.
2111 if !self.disabled && !self.editing {
2112 self.begin_editing();
2113 ectx.request_focus();
2114 }
2115 false
2116 }
2117 Event::FocusOut => {
2118 self.commit_editing();
2119 false
2120 }
2121 _ => false,
2122 }
2123 }
2124
2125 fn take_change(&mut self) -> bool {
2126 self.take_change()
2127 }
2128
2129 fn value_string(&self) -> Option<String> {
2130 Some(self.text.clone())
2131 }
2132
2133 fn set_value_string(&mut self, val: &str) -> bool {
2134 self.set_value(val)
2135 }
2136
2137 fn a11y_text(&self) -> Option<crate::a11y::A11yText> {
2138 // What the box holds, never an input method's provisional run; a password as bullets.
2139 let held = if self.editing { self.committed_buffer() } else { self.text.clone() };
2140 let len = held.chars().count();
2141 let text = if self.is_password { "\u{2022}".repeat(len) } else { held };
2142 // The caret and anchor are indices into what is shown; a composition sits at the
2143 // caret, so past its start they come back to it.
2144 let held_idx = |i: usize| {
2145 let i = match self.composing {
2146 Some((start, n)) if i > start => i.saturating_sub(n).max(start),
2147 _ => i,
2148 };
2149 i.min(len)
2150 };
2151 let selection = self.editing.then(|| {
2152 let focus = held_idx(self.cursor_idx);
2153 (self.select_anchor.map_or(focus, held_idx), focus)
2154 });
2155 Some(crate::a11y::A11yText {
2156 text,
2157 selection,
2158 multiline: self.multiline,
2159 password: self.is_password,
2160 editable: !self.disabled,
2161 placeholder: self.placeholder.clone(),
2162 kind: None,
2163 })
2164 }
2165
2166 fn a11y_set_text(&mut self, text: &str) -> bool {
2167 if self.disabled {
2168 return false;
2169 }
2170 self.abandon_composition();
2171 let held = if self.editing { self.committed_buffer() } else { self.text.clone() };
2172 if held == text {
2173 return false;
2174 }
2175 if self.editing {
2176 let before = self.snapshot();
2177 self.history.record(before);
2178 } else {
2179 self.history.clear();
2180 }
2181 self.text = text.to_string();
2182 self.edit_buffer = text.to_string();
2183 self.just_changed = true;
2184 self.cursor_idx = text.chars().count();
2185 self.select_anchor = None;
2186 self.all_selected = false;
2187 self.sync_editor_state();
2188 self.clamp_scroll();
2189 if self.editing {
2190 self.scroll_to_cursor();
2191 }
2192 true
2193 }
2194
2195 fn context_action(&mut self, action: crate::widget::ContextAction) -> bool {
2196 use crate::widget::ContextAction as CA;
2197 match action {
2198 CA::Cut => {
2199 let res = self.cut_selection();
2200 if res {
2201 self.just_changed = true;
2202 }
2203 res
2204 }
2205 CA::Copy => {
2206 self.copy_selection();
2207 true
2208 }
2209 CA::Paste => {
2210 let res = self.paste_from_clipboard();
2211 if res {
2212 self.just_changed = true;
2213 }
2214 res
2215 }
2216 CA::SelectAll => {
2217 self.select_all();
2218 true
2219 }
2220 CA::ClearText => {
2221 self.set_value("");
2222 true
2223 }
2224 CA::Undo => self.undo_edit(),
2225 CA::Redo => self.redo_edit(),
2226 _ => false,
2227 }
2228 }
2229
2230 fn draggable(&self, _rect: Rect) -> bool {
2231 !self.disabled
2232 }
2233
2234 fn is_dragging(&self) -> bool {
2235 self.dragging
2236 }
2237
2238 fn drag_begin(&mut self, _px: f32, _py: f32, _rect: Rect) {
2239 if self.disabled || !self.editing { return; }
2240 self.dragging = true;
2241 }
2242
2243 fn drag_update(&mut self, px: f32, py: f32, _rect: Rect) -> bool {
2244 if self.disabled || !self.editing { return false; }
2245 self.extend_selection_to(px, py)
2246 }
2247
2248 fn drag_end(&mut self) {
2249 self.dragging = false;
2250 }
2251 }
2252
2253 /// Legacy `Default` (an empty box) — settings' accounts page derives `Default` over fields of
2254 /// this type.
2255 impl Default for Adapted<TextBox> {
2256 fn default() -> Self {
2257 TextBox::new(String::new())
2258 }
2259 }
2260
2261 unsafe impl Send for TextBox {}
2262 unsafe impl Sync for TextBox {}
2263
2264
2265 #[cfg(test)]
2266 mod tests {
2267 use super::*;
2268
2269 /// The multiline caret/click math reads shaped per-line offsets; a caret
2270 /// must land exactly where it is drawn, on every column of every line.
2271 /// A right-to-left word in a one-line box is set against the box's right edge, and
2272 /// edited where it is drawn: the caret before each letter stands at its right edge, so
2273 /// the offsets fall from the right end to the word's left; a click at the right end is
2274 /// the start, at the word's left the end; the drawn text starts where the offsets do.
2275 /// A selection from inside English into Hebrew is two pieces. The bidi levels come from
2276 /// the text, so this holds whatever face draws the letters.
2277 #[test]
2278 fn a_right_to_left_word_is_edited_where_it_is_drawn() {
2279 let mut fs = crate::create_font_system();
2280 let mut tb = TextBox::new("שלום".to_string());
2281 tb.set_rect(10.0, 10.0, 300.0, 30.0);
2282 tb.prepare_text(&mut fs);
2283 let offs = tb.glyph_positions.clone();
2284 assert_eq!(offs.len(), 5, "four letters and the end");
2285 if tb.total_text_width == 0.0 {
2286 return; // nothing shaped (no fonts at all): nothing to check
2287 }
2288 let pad = tb.inner().pad();
2289 let (left, room) = (10.0 + pad, 300.0 - 2.0 * pad);
2290 assert!(offs.windows(2).all(|w| w[1] < w[0]), "carets fall right to left: {offs:?}");
2291 assert!((offs[0] - room).abs() < 0.01, "set against the right edge: {offs:?}");
2292 assert!((offs[4] - (room - tb.total_text_width)).abs() < 0.01, "{offs:?}");
2293 assert_eq!(tb.inner().map_x_to_idx(left + room), 0, "the right end is the start");
2294 assert_eq!(tb.inner().map_x_to_idx(left + offs[4]), 4, "the word's left is the end");
2295 let label = &tb.inner().value_labels()[0];
2296 assert!((label.x - (left + offs[4])).abs() < 0.01, "drawn where the offsets are: {} vs {}", label.x, left + offs[4]);
2297
2298 // English then Hebrew: a left-to-right line, as it was; a selection from the "b" to
2299 // the Hebrew word's first letter is the b and that letter at the word's far right.
2300 let mut tb = TextBox::new("ab שלום".to_string());
2301 tb.set_rect(10.0, 10.0, 300.0, 30.0);
2302 tb.inner_mut().editing = true;
2303 tb.inner_mut().edit_buffer = "ab שלום".to_string();
2304 tb.prepare_text(&mut fs);
2305 assert!(tb.glyph_positions[0].abs() < 0.01, "a left-to-right line starts at the left");
2306 tb.inner_mut().select_anchor = Some(1);
2307 tb.inner_mut().cursor_idx = 4;
2308 let mut quads = Vec::new();
2309 tb.inner().selection_quads(10.0, 300.0, &mut quads);
2310 let sel: Vec<_> = quads.iter().filter(|q| q.2 > 2.0).collect();
2311 assert_eq!(sel.len(), 2, "two pieces: {sel:?}");
2312 }
2313
2314 /// In a multiline box a Hebrew paragraph's lines are set against the right edge and an
2315 /// English paragraph's are not; the drawn lines carry the same shift as the offsets.
2316 #[test]
2317 fn a_right_to_left_paragraph_is_set_against_the_right() {
2318 let mut fs = crate::create_font_system();
2319 let mut tb = TextBox::new("hello there\nשלום עולם".to_string()).with_multiline(true).with_line_wrap(true);
2320 tb.set_rect(10.0, 10.0, 300.0, 200.0);
2321 tb.prepare_text(&mut fs);
2322 let room = 300.0 - 2.0 * tb.inner().pad();
2323 let lines = tb.line_glyph_positions.clone();
2324 assert_eq!(lines.len(), 2);
2325 if lines[1].iter().all(|x| *x == 0.0) {
2326 return; // nothing shaped
2327 }
2328 assert!(lines[0][0].abs() < 0.01, "English starts at the left");
2329 let widest = lines[1].iter().copied().fold(0.0f32, f32::max);
2330 assert!((widest - room).abs() < 0.01, "Hebrew ends at the right: {:?}", lines[1]);
2331 let labels = tb.inner().value_labels();
2332 assert!(labels[1].x > labels[0].x + 50.0, "the Hebrew line is drawn shifted: {} vs {}", labels[1].x, labels[0].x);
2333 }
2334
2335 /// A one-line box shows the START of an overflowing line: the left end of English, the
2336 /// right end of Hebrew, whose characters run from the right. Editing follows the caret.
2337 #[test]
2338 fn an_overflowing_right_to_left_line_shows_its_start() {
2339 let mut fs = crate::create_font_system();
2340 let long = "שלום עולם ".repeat(8);
2341 let mut tb = TextBox::new(long).with_multiline(false);
2342 tb.set_rect(10.0, 10.0, 120.0, 28.0);
2343 tb.prepare_text(&mut fs);
2344 let room = 120.0 - 2.0 * tb.inner().pad();
2345 let total = tb.inner().total_text_width;
2346 if total <= room {
2347 return; // nothing shaped
2348 }
2349 assert!((tb.inner().scroll_x - (total - room)).abs() < 0.01, "scrolled to the right end: {} of {}", tb.inner().scroll_x, total - room);
2350 // The first character's stop lies in view.
2351 let first = tb.inner().glyph_positions[0];
2352 let x = first - tb.inner().scroll_x;
2353 assert!((0.0..=room + 0.5).contains(&x), "the line's first character is shown: {x}");
2354
2355 let mut english = TextBox::new("hello there ".repeat(8)).with_multiline(false);
2356 english.set_rect(10.0, 10.0, 120.0, 28.0);
2357 english.prepare_text(&mut fs);
2358 assert_eq!(english.inner().scroll_x, 0.0, "English shows its left end");
2359 }
2360
2361 /// A multiline box wraps by the shaped width of what it holds, not a count of chars:
2362 /// every line fits the box (a hanging space aside) and none breaks early — the next
2363 /// line's first word would not have fitted on it. Text of mixed widths (narrow, wide,
2364 /// CJK) is where a column count and the drawn width disagree.
2365 #[test]
2366 fn a_multiline_box_wraps_where_its_text_is_wide() {
2367 let text = "iiii WWWW lll MMM 漢字漢字 iii WW 漢字漢字漢字 il WM 漢字".repeat(3);
2368 let mut tb = TextBox::new(text.clone()).with_multiline(true).with_line_wrap(true);
2369 crate::widget::WidgetHost::set_rect(&mut tb, 0.0, 0.0, 160.0, 400.0);
2370 let inner = tb.inner();
2371 let max_w = inner.wrap_width(160.0);
2372 let (lines, map) = inner.wrap_text(max_w);
2373 assert!(lines.len() > 2, "{lines:?}");
2374 let width = |s: &str| inner.char_advances(s).iter().sum::<f32>();
2375 for (i, line) in lines.iter().enumerate() {
2376 let shown = line.trim_end();
2377 assert!(width(shown) <= max_w + 0.5 || shown.chars().count() == 1, "line {i} {shown:?} is {} wide, past {max_w}", width(shown));
2378 if let Some(next) = lines.get(i + 1) {
2379 let word: String = next.chars().take_while(|c| !c.is_whitespace()).collect();
2380 if !word.is_empty() && line.ends_with(' ') {
2381 assert!(width(&format!("{line}{word}")) > max_w + 0.5, "line {i} {line:?} broke before {word:?}, which fitted");
2382 }
2383 }
2384 }
2385 let rejoined: String = lines.concat();
2386 assert_eq!(rejoined, text, "the lines are the text, cut");
2387 assert_eq!(map.len(), text.chars().count() + 1);
2388 }
2389
2390 #[test]
2391 fn multiline_shaped_offsets_round_trip() {
2392 let mut fs = cosmic_text::FontSystem::new();
2393 let mut tb = TextBox::new("wim wim wim\niiii WWWW mm ii".to_string())
2394 .with_multiline(true)
2395 .with_line_wrap(false);
2396 tb.set_rect(10.0, 10.0, 300.0, 100.0);
2397 tb.prepare_text(&mut fs);
2398
2399 assert_eq!(tb.line_glyph_positions.len(), 2, "one offset row per line");
2400 for line in &tb.line_glyph_positions {
2401 for w in line.windows(2) {
2402 assert!(w[1] >= w[0], "offsets must be non-decreasing: {:?}", line);
2403 }
2404 }
2405
2406 // Round-trip caret→pixel→column. Skipped in a font-less environment
2407 // (no glyphs shape, offsets stay zero — nothing to verify).
2408 for (l, line) in tb.line_glyph_positions.clone().iter().enumerate() {
2409 if line.last().copied().unwrap_or(0.0) == 0.0 {
2410 continue;
2411 }
2412 for c in 0..line.len() {
2413 assert_eq!(
2414 tb.line_x_to_col(l, tb.line_col_x(l, c)),
2415 c,
2416 "line {l} col {c} must round-trip"
2417 );
2418 }
2419 }
2420 }
2421
2422 /// Single-line offsets must be non-decreasing for MULTI-WORD text. Guards
2423 /// the `normalized_glyph_starts` workaround for cosmic-text 0.12's
2424 /// `Shaping::Basic` bug (span-relative `LayoutGlyph::start`, resetting at
2425 /// every word): without it, each word's glyphs overwrite the low columns
2426 /// and a mid-text caret lands inside the wrong word.
2427 #[test]
2428 fn single_line_multi_word_offsets_monotonic() {
2429 let mut fs = cosmic_text::FontSystem::new();
2430 let mut tb = TextBox::new("wim wim wim".to_string());
2431 tb.set_rect(10.0, 10.0, 300.0, 30.0);
2432 tb.prepare_text(&mut fs);
2433
2434 for w in tb.glyph_positions.windows(2) {
2435 assert!(w[1] >= w[0], "offsets must be non-decreasing: {:?}", tb.glyph_positions);
2436 }
2437 // In a font-bearing environment the mid-text columns are real advances:
2438 // strictly inside (0, total). Skipped font-less (all zeros).
2439 if tb.glyph_positions.last().copied().unwrap_or(0.0) > 0.0 {
2440 let total = *tb.glyph_positions.last().unwrap();
2441 for (i, &x) in tb.glyph_positions.iter().enumerate().skip(1).take(tb.glyph_positions.len() - 2) {
2442 assert!(x > 0.0 && x < total, "col {i} offset {x} must sit inside the text run");
2443 }
2444 }
2445 }
2446
2447 /// Multi-byte text gets one offset per CHAR (not per byte), and the
2448 /// per-frame memo notices a change of text: the second shape must not
2449 /// serve the first one's offsets.
2450 #[test]
2451 fn offsets_count_chars_and_reshape_on_change() {
2452 let mut fs = cosmic_text::FontSystem::new();
2453 let mut tb = TextBox::new("héllo wörld".to_string());
2454 tb.set_rect(10.0, 10.0, 300.0, 30.0);
2455 tb.prepare_text(&mut fs);
2456 assert_eq!(tb.glyph_positions.len(), "héllo wörld".chars().count() + 1);
2457 for w in tb.glyph_positions.windows(2) {
2458 assert!(w[1] >= w[0], "offsets must be non-decreasing: {:?}", tb.glyph_positions);
2459 }
2460
2461 let first = tb.glyph_positions.clone();
2462 tb.prepare_text(&mut fs);
2463 assert_eq!(tb.glyph_positions, first, "an unchanged box keeps its offsets");
2464
2465 tb.text = "hé".to_string();
2466 tb.prepare_text(&mut fs);
2467 assert_eq!(tb.glyph_positions.len(), 3, "a changed text is reshaped");
2468 }
2469
2470 #[test]
2471 fn test_textbox_selection_highlight() {
2472 let mut dummy = crate::context::UiContext::new();
2473 let mut tb = TextBox::new("Initial Text".to_string());
2474 tb.set_rect(10.0, 10.0, 200.0, 30.0);
2475
2476 // 1. Initial state
2477 assert!(!tb.editing);
2478 assert!(!tb.all_selected);
2479
2480 // 2. Keyboard focus triggers highlighting. (A CLICK deliberately does not
2481 // — it places the caret; see `prefilled_single_line_click_does_not_wipe_the_value`.)
2482 tb.focus();
2483 assert!(tb.editing);
2484 assert!(tb.all_selected);
2485 assert_eq!(tb.edit_buffer, "Initial Text");
2486
2487 // 3. Typing a key replaces all text
2488 let key_ev = KeyEvent {
2489 state: ElementState::Pressed,
2490 logical_key: Key::Character("A".to_string()),
2491 text: Some("A".to_string()),
2492 repeat: false,
2493 ctrl: false,
2494 shift: false,
2495 alt: false,
2496 };
2497 let handled = tb.keyboard_input(&key_ev, &mut dummy);
2498 assert!(handled);
2499 assert!(!tb.all_selected);
2500 assert_eq!(tb.edit_buffer, "A");
2501
2502 // 4. Pressing Enter commits change
2503 let enter_ev = KeyEvent {
2504 state: ElementState::Pressed,
2505 logical_key: Key::Named(NamedKey::Enter),
2506 text: None,
2507 repeat: false,
2508 ctrl: false,
2509 shift: false,
2510 alt: false,
2511 };
2512 let handled_enter = tb.keyboard_input(&enter_ev, &mut dummy);
2513 assert!(handled_enter);
2514 assert!(!tb.editing);
2515 assert_eq!(tb.text, "A");
2516 assert!(tb.take_change());
2517 }
2518
2519 /// The other half of the click change: keyboard focus must still arm
2520 /// select-all, so tabbing into a field and typing replaces it. Losing this
2521 /// would make every form field tedious to retype.
2522 #[test]
2523 fn keyboard_focus_still_selects_all() {
2524 let mut tb = TextBox::new("imap.example.org:993".to_string());
2525 tb.set_rect(10.0, 10.0, 200.0, 30.0);
2526
2527 tb.focus();
2528
2529 assert!(tb.editing);
2530 assert!(tb.all_selected, "tabbing in selects the whole value");
2531 assert_eq!(tb.select_anchor, Some(0));
2532 assert_eq!(tb.cursor_idx, "imap.example.org:993".chars().count());
2533 }
2534
2535 /// A prefilled single-line box is the case that motivated the change: the
2536 /// focusing click must leave the value intact so the first keystroke edits
2537 /// rather than erases. Same guarantee the multiline test below asserts.
2538 #[test]
2539 fn prefilled_single_line_click_does_not_wipe_the_value() {
2540 let mut dummy = crate::context::UiContext::new();
2541 let mut tb = TextBox::new("imap.example.org:993".to_string());
2542 tb.set_rect(10.0, 10.0, 300.0, 30.0);
2543
2544 let click_x = 10.0 + 8.0 + 4.0 * tb.char_width();
2545 assert!(tb.mouse_input(MouseButton::Left, ElementState::Pressed, click_x, 20.0, &mut dummy));
2546 assert!(tb.editing);
2547 assert!(!tb.all_selected);
2548
2549 let key_ev = KeyEvent {
2550 state: ElementState::Pressed,
2551 logical_key: Key::Character("X".to_string()),
2552 text: Some("X".to_string()),
2553 repeat: false,
2554 ctrl: false,
2555 shift: false,
2556 alt: false,
2557 };
2558 assert!(tb.keyboard_input(&key_ev, &mut dummy));
2559 assert_eq!(
2560 tb.edit_buffer.chars().count(),
2561 21,
2562 "typing must insert into the prefilled value, not replace it"
2563 );
2564 assert!(tb.edit_buffer.starts_with("imap"), "the existing value survives the first keystroke");
2565 }
2566
2567 /// An input method's composition is a provisional run in the buffer: shown
2568 /// at the caret with the caret where the input method has it, never held
2569 /// (`committed_buffer`, `take_change`), replaced by the commit, which is
2570 /// typed — and dropped, the input method told, when editing ends under it.
2571 #[test]
2572 fn a_composition_is_shown_in_place_and_the_commit_is_typed() {
2573 use crate::ime::{self, Preedit};
2574 let mut dummy = crate::context::UiContext::new();
2575 let mut tb = TextBox::new("ab".to_string()).with_update_on_type(true);
2576 tb.set_rect(10.0, 10.0, 300.0, 30.0);
2577 tb.focus();
2578 tb.cursor_idx = 1;
2579 tb.select_anchor = None;
2580 tb.all_selected = false;
2581 let _ = ime::take_reset();
2582
2583 // "にほ", the input method's cursor after "に".
2584 ime::set_preedit(Some(Preedit::new("にほ", Some((0, 3)))));
2585 tb.sync_preedit();
2586 assert_eq!(tb.edit_buffer, "aにほb");
2587 assert_eq!(tb.composing, Some((1, 2)));
2588 assert_eq!(tb.cursor_idx, 2);
2589 assert_eq!(tb.committed_buffer(), "ab");
2590 assert!(!tb.take_change(), "a composition is not a change");
2591 // A key the input method lets through mid-composition is not typed.
2592 let typed = |t: &str| KeyEvent {
2593 state: ElementState::Pressed,
2594 logical_key: Key::Character(t.to_string()),
2595 text: Some(t.to_string()),
2596 repeat: false,
2597 ctrl: false,
2598 shift: false,
2599 alt: false,
2600 };
2601 assert!(tb.keyboard_input(&typed("x"), &mut dummy));
2602 assert_eq!(tb.edit_buffer, "aにほb");
2603
2604 // The commit: the composition ends, then its text is typed.
2605 ime::set_preedit(None);
2606 assert!(tb.keyboard_input(&typed("日本"), &mut dummy));
2607 assert_eq!(tb.edit_buffer, "a日本b");
2608 assert_eq!((tb.composing, tb.cursor_idx), (None, 3));
2609 assert!(tb.take_change());
2610 assert_eq!(tb.text, "a日本b");
2611
2612 // Editing ends mid-composition: the run goes, the input method is
2613 // told, and the text is what was held.
2614 ime::set_preedit(Some(Preedit::new("か", None)));
2615 tb.sync_preedit();
2616 assert_eq!(tb.edit_buffer, "a日本かb");
2617 tb.commit_editing();
2618 assert_eq!(tb.text, "a日本b");
2619 assert!(ime::take_reset());
2620 assert_eq!(ime::preedit(), None);
2621 }
2622
2623 /// Undo/redo over an editing session: a typed word is one step, a
2624 /// space starts the next, a cursor move splits a run, Backspace runs
2625 /// coalesce, redo walks forward, a fresh keystroke after an undo forks,
2626 /// and the chord reaches the box both as a `ContextAction` (the runner's
2627 /// route) and as a raw key (the no-context fallback).
2628 #[test]
2629 fn typing_undoes_by_run() {
2630 let mut dummy = crate::context::UiContext::new();
2631 let mut tb = TextBox::new(String::new());
2632 tb.set_rect(10.0, 10.0, 300.0, 30.0);
2633 tb.focus();
2634 assert!(tb.editing);
2635 assert!(!tb.undo_edit(), "a fresh session has nothing to undo");
2636
2637 let key = |k: &str, ctrl: bool, shift: bool| KeyEvent {
2638 state: ElementState::Pressed,
2639 logical_key: Key::Character(k.to_string()),
2640 text: if ctrl { None } else { Some(k.to_string()) },
2641 repeat: false,
2642 ctrl,
2643 shift,
2644 alt: false,
2645 };
2646 let named = |n: NamedKey| KeyEvent {
2647 state: ElementState::Pressed,
2648 logical_key: Key::Named(n),
2649 text: None,
2650 repeat: false,
2651 ctrl: false,
2652 shift: false,
2653 alt: false,
2654 };
2655 let type_str = |tb: &mut Adapted<TextBox>, dummy: &mut crate::context::UiContext, s: &str| {
2656 for ch in s.chars() {
2657 assert!(tb.keyboard_input(&key(&ch.to_string(), false, false), dummy));
2658 }
2659 };
2660
2661 type_str(&mut tb, &mut dummy, "hello world");
2662 assert_eq!(tb.edit_buffer, "hello world");
2663 assert_eq!(tb.history.undo_len(), 3, "'hello', ' ', 'world'");
2664
2665 // A cursor move splits the next run off.
2666 assert!(tb.keyboard_input(&named(NamedKey::ArrowLeft), &mut dummy));
2667 type_str(&mut tb, &mut dummy, "XY");
2668 assert_eq!(tb.edit_buffer, "hello worlXYd");
2669 assert_eq!(tb.history.undo_len(), 4);
2670
2671 // Backspaces coalesce into one step.
2672 assert!(tb.keyboard_input(&named(NamedKey::Backspace), &mut dummy));
2673 assert!(tb.keyboard_input(&named(NamedKey::Backspace), &mut dummy));
2674 assert_eq!(tb.edit_buffer, "hello world");
2675 assert_eq!(tb.history.undo_len(), 5);
2676
2677 // Undo through the ContextAction route, then the raw-chord route.
2678 assert!(crate::widget::WidgetHostExt::context_action(&mut tb, crate::widget::ContextAction::Undo));
2679 assert_eq!(tb.edit_buffer, "hello worlXYd");
2680 assert!(tb.keyboard_input(&key("z", true, false), &mut dummy));
2681 assert_eq!(tb.edit_buffer, "hello world");
2682 assert!(tb.keyboard_input(&key("Z", true, true), &mut dummy), "redo via ctrl+shift+z");
2683 assert_eq!(tb.edit_buffer, "hello worlXYd");
2684 assert!(tb.keyboard_input(&key("z", true, false), &mut dummy));
2685 assert!(tb.keyboard_input(&key("z", true, false), &mut dummy));
2686 assert_eq!(tb.edit_buffer, "hello ");
2687 assert!(tb.keyboard_input(&key("z", true, false), &mut dummy));
2688 assert_eq!(tb.edit_buffer, "hello");
2689 assert!(tb.keyboard_input(&key("z", true, false), &mut dummy));
2690 assert_eq!(tb.edit_buffer, "");
2691 assert!(!tb.undo_edit(), "history exhausted");
2692
2693 // Redo forward one, then a fresh keystroke forks the branch.
2694 assert!(crate::widget::WidgetHostExt::context_action(&mut tb, crate::widget::ContextAction::Redo));
2695 assert_eq!(tb.edit_buffer, "hello");
2696 type_str(&mut tb, &mut dummy, "!");
2697 assert_eq!(tb.edit_buffer, "hello!");
2698 assert!(!tb.redo_edit(), "a new edit after an undo drops the redo branch");
2699
2700 // A committed value is not the box's to undo.
2701 tb.unfocus();
2702 assert!(!tb.editing);
2703 assert!(!crate::widget::WidgetHostExt::context_action(&mut tb, crate::widget::ContextAction::Undo));
2704 }
2705
2706 #[test]
2707 fn empty_box_click_ignores_placeholder_glyphs() {
2708 let mut dummy = crate::context::UiContext::new();
2709 let mut tb = TextBox::new(String::new()).with_placeholder("Search...");
2710 tb.set_rect(10.0, 10.0, 200.0, 30.0);
2711
2712 // `prepare_text` shapes the placeholder when the value is empty; the
2713 // caret math must still treat the box as zero-length. Simulate the
2714 // shaped placeholder ("Search...", 9 cols) directly so the test does
2715 // not depend on a font being present.
2716 tb.glyph_positions = (0..=9).map(|i| i as f32 * 7.0).collect();
2717
2718 // Click deep into the painted placeholder.
2719 let click_x = 10.0 + 8.0 + 8.0 * 7.0;
2720 assert!(tb.mouse_input(MouseButton::Left, ElementState::Pressed, click_x, 20.0, &mut dummy));
2721 assert!(tb.editing);
2722 assert_eq!(tb.cursor_idx, 0, "empty box: the caret lands at the start, not on a placeholder column");
2723 }
2724
2725 #[test]
2726 fn multiline_focus_click_places_caret_instead_of_select_all() {
2727 let mut dummy = crate::context::UiContext::new();
2728 let mut tb = TextBox::new("abcdef".to_string()).with_multiline(true);
2729 tb.set_rect(10.0, 10.0, 200.0, 100.0);
2730
2731 // The focusing click must NOT arm select-all (a first keystroke would wipe
2732 // prefilled content, e.g. a reply quote) — it places the caret like any click.
2733 let clicked = tb.mouse_input(MouseButton::Left, ElementState::Pressed, 12.0, 20.0, &mut dummy);
2734 assert!(clicked);
2735 assert!(tb.editing);
2736 assert!(!tb.all_selected);
2737 let caret_after_click = tb.cursor_idx;
2738 let key_ev = KeyEvent {
2739 state: ElementState::Pressed,
2740 logical_key: Key::Character("X".to_string()),
2741 text: Some("X".to_string()),
2742 repeat: false,
2743 ctrl: false,
2744 shift: false,
2745 alt: false,
2746 };
2747 let handled = tb.keyboard_input(&key_ev, &mut dummy);
2748 assert!(handled);
2749 assert_eq!(tb.edit_buffer.chars().count(), 7, "typing must insert, not replace the buffer");
2750 assert!(tb.edit_buffer.contains("X"));
2751 assert_eq!(tb.cursor_idx, caret_after_click + 1);
2752 }
2753
2754 #[test]
2755 fn test_textbox_drag_and_modifier_selection() {
2756 let mut dummy = crate::context::UiContext::new();
2757 let mut tb = TextBox::new("Hello World".to_string());
2758 tb.set_rect(10.0, 10.0, 200.0, 30.0);
2759
2760 // 1. Initial click focuses and places the caret where it landed — it does
2761 // NOT select all. Selecting all belongs to keyboard focus (see
2762 // `keyboard_focus_still_selects_all`).
2763 let click_x3 = 10.0 + 8.0 + 3.0 * tb.char_width();
2764 let pressed = tb.mouse_input(MouseButton::Left, ElementState::Pressed, click_x3, 20.0, &mut dummy);
2765 assert!(pressed);
2766 let released = tb.mouse_input(MouseButton::Left, ElementState::Released, click_x3, 20.0, &mut dummy);
2767 assert!(released);
2768 assert!(tb.editing);
2769 assert!(!tb.all_selected);
2770 assert_eq!(tb.cursor_idx, 3);
2771 // The release collapses the zero-width selection the press opened.
2772 assert_eq!(tb.select_anchor, None);
2773
2774 // 2. Click inside placed caret at index 5
2775 let click_x5 = 10.0 + 8.0 + 5.0 * tb.char_width();
2776 let pressed_inside = tb.mouse_input(MouseButton::Left, ElementState::Pressed, click_x5, 20.0, &mut dummy);
2777 assert!(pressed_inside);
2778 assert_eq!(tb.cursor_idx, 5);
2779 assert_eq!(tb.select_anchor, Some(5));
2780 assert!(!tb.all_selected);
2781
2782 // 3. Drag to index 11
2783 let drag_x11 = 10.0 + 8.0 + 11.0 * tb.char_width();
2784 tb.drag_begin(click_x5, 20.0);
2785 let updated = tb.drag_update(drag_x11, 20.0);
2786 assert!(updated);
2787 assert_eq!(tb.cursor_idx, 11);
2788 assert_eq!(tb.select_anchor, Some(5));
2789 tb.drag_end();
2790
2791 // 4. Keyboard ArrowLeft with Shift shrinks selection from 11 to 10
2792 let left_shift_ev = KeyEvent {
2793 state: ElementState::Pressed,
2794 logical_key: Key::Named(NamedKey::ArrowLeft),
2795 text: None,
2796 repeat: false,
2797 ctrl: false,
2798 shift: true,
2799 alt: false,
2800 };
2801 let handled = tb.keyboard_input(&left_shift_ev, &mut dummy);
2802 assert!(handled);
2803 assert_eq!(tb.cursor_idx, 10);
2804 assert_eq!(tb.select_anchor, Some(5));
2805
2806 // 5. Keyboard ArrowLeft without Shift collapses selection to start (index 5)
2807 let left_ev = KeyEvent {
2808 state: ElementState::Pressed,
2809 logical_key: Key::Named(NamedKey::ArrowLeft),
2810 text: None,
2811 repeat: false,
2812 ctrl: false,
2813 shift: false,
2814 alt: false,
2815 };
2816 let handled = tb.keyboard_input(&left_ev, &mut dummy);
2817 assert!(handled);
2818 assert_eq!(tb.cursor_idx, 5);
2819 assert_eq!(tb.select_anchor, None);
2820
2821 // 6. Keyboard Shift+Up highlights to beginning (cursor 0, anchor 5)
2822 let up_shift_ev = KeyEvent {
2823 state: ElementState::Pressed,
2824 logical_key: Key::Named(NamedKey::ArrowUp),
2825 text: None,
2826 repeat: false,
2827 ctrl: false,
2828 shift: true,
2829 alt: false,
2830 };
2831 let handled = tb.keyboard_input(&up_shift_ev, &mut dummy);
2832 assert!(handled);
2833 assert_eq!(tb.cursor_idx, 0);
2834 assert_eq!(tb.select_anchor, Some(5));
2835
2836 // 7. Typing a key replaces selected range "Hello" with "Rust"
2837 let rust_ev = KeyEvent {
2838 state: ElementState::Pressed,
2839 logical_key: Key::Character("Rust".to_string()),
2840 text: Some("Rust".to_string()),
2841 repeat: false,
2842 ctrl: false,
2843 shift: false,
2844 alt: false,
2845 };
2846 let handled = tb.keyboard_input(&rust_ev, &mut dummy);
2847 assert!(handled);
2848 assert_eq!(tb.edit_buffer, "Rust World");
2849 assert_eq!(tb.cursor_idx, 4);
2850 assert_eq!(tb.select_anchor, None);
2851 }
2852
2853 #[test]
2854 fn test_textbox_right_click_context_menu() {
2855 let mut dummy = crate::context::UiContext::new();
2856 let mut tb = TextBox::new("Context Menu Text".to_string());
2857 tb.set_rect(10.0, 10.0, 200.0, 30.0);
2858
2859 // Hide context menu initially
2860 dummy.hide_context_menu();
2861 assert!(!dummy.is_context_menu_visible());
2862
2863 // Right click on textbox
2864 let clicked = tb.mouse_input(MouseButton::Right, ElementState::Pressed, 50.0, 20.0, &mut dummy);
2865 assert!(clicked);
2866 assert!(dummy.is_context_menu_visible());
2867 assert!(tb.editing);
2868 }
2869
2870 #[test]
2871 fn test_textbox_multiline_selection_highlight() {
2872 let mut tb = TextBox::new("Line 1\nLine 2\nLine 3".to_string()).with_multiline(true);
2873 tb.set_rect(10.0, 10.0, 200.0, 100.0);
2874 tb.select_anchor = Some(7); // starts at "Line 2"
2875 tb.cursor_idx = 13; // ends at end of "Line 2"
2876
2877 use crate::scene::paint::Prim;
2878 let highlight = [0.20, 0.50, 0.85, 0.3];
2879 let has_highlight = crate::widget::shown_prims(&tb).iter().any(|p| match p {
2880 Prim::Quad { color, .. } | Prim::RoundedRect { color, .. } => *color == highlight,
2881 _ => false,
2882 });
2883 assert!(has_highlight, "Should have a highlight quad!");
2884 }
2885
2886 #[test]
2887 fn test_textbox_line_wrap_disabled_horizontal_scrolling() {
2888 let _dummy = crate::context::UiContext::new();
2889
2890 // Wrap is stated on the widget (`line_wrap_override`), not on the
2891 // process-global `textbox_line_wrap` — this box is single-line, which
2892 // is already unwrapped, and the override says so whatever the config
2893 // does. The global would be visible to every other test in parallel.
2894 let mut tb = TextBox::new("Very long text that should not wrap and instead scroll horizontally".to_string())
2895 .with_line_wrap(false);
2896 tb.set_rect(10.0, 10.0, 100.0, 30.0);
2897 assert!(!tb.line_wrap_enabled());
2898
2899 assert_eq!(tb.scroll_x, 0.0);
2900
2901 tb.focus();
2902 tb.cursor_idx = tb.edit_buffer.chars().count();
2903 tb.scroll_to_cursor();
2904
2905 assert!(tb.scroll_x > 0.0, "scroll_x should be scrolled horizontally to keep the cursor visible");
2906 }
2907
2908 #[test]
2909 fn test_search_textbox_clear_option() {
2910 let mut dummy = crate::context::UiContext::new();
2911 let mut tb = TextBox::new("Some Search query".to_string()).with_placeholder("Search...");
2912 tb.set_rect(10.0, 10.0, 200.0, 30.0);
2913
2914 // Right click on textbox
2915 let clicked = tb.mouse_input(MouseButton::Right, ElementState::Pressed, 50.0, 20.0, &mut dummy);
2916 assert!(clicked);
2917 assert!(dummy.is_context_menu_visible());
2918
2919 // Verify "Clear" option is in options
2920 let opts = crate::widget::context_menu::options();
2921 assert!(opts.contains(&"Clear".to_string()));
2922
2923 // Simulate choosing the "Clear" option
2924 crate::widget::WidgetHostExt::context_action(&mut tb, crate::widget::ContextAction::ClearText);
2925 assert_eq!(tb.text, "");
2926 assert_eq!(tb.edit_buffer, "");
2927 }
2928
2929 /// A single-line box's border is always the hairline; a multiline box's
2930 /// is whatever `textbox_multiline_border_width` resolves to. Read, never
2931 /// written: that width is a process global and the suite runs in
2932 /// parallel, so setting it here would widen every other test's borders.
2933 #[test]
2934 fn test_multiline_textbox_border_width() {
2935 let _dummy = crate::context::UiContext::new();
2936 let tb_single = TextBox::new("Singleline".to_string()).with_multiline(false);
2937 let tb_multi = TextBox::new("Multiline".to_string()).with_multiline(true);
2938
2939 let configured = crate::layout::textbox_multiline_border_width();
2940 assert_eq!(tb_single.border_width(), 1.0);
2941 assert_eq!(tb_multi.border_width(), configured);
2942 }
2943 /// A tall recessed box's wall is the full `bevel_width`, deeper than the
2944 /// old fixed 8px inset: its text starts on the well's floor, past the
2945 /// wall, not on it (cce-fonts' preview box drew its sample over its relief).
2946 #[test]
2947 fn tall_box_text_starts_past_its_relief_wall() {
2948 let _dummy = crate::context::UiContext::new();
2949 let mut tb = TextBox::new("The quick brown fox".to_string())
2950 .with_multiline(true)
2951 .with_recessed(true);
2952 tb.set_rect(10.0, 10.0, 400.0, 180.0);
2953 let field = tb.well().expect("a recessed box with rounded corners carves a well");
2954 let floor_x = field.rect.x + field.depth;
2955 let floor_y = field.rect.y + field.depth;
2956 let first = &tb.value_labels()[0];
2957 assert!(first.x >= floor_x, "text x {} is on the wall (floor at {})", first.x, floor_x);
2958 assert!(first.y >= floor_y, "text y {} is on the wall (floor at {})", first.y, floor_y);
2959 }
2960 /// A password box never hands its text to the clipboard -- not from a
2961 /// selection, not by Ctrl+X, not through the menu -- and its menu offers
2962 /// no Cut or Copy. Its Ctrl+X leaves the text in place.
2963 #[test]
2964 fn a_password_box_never_copies_or_cuts() {
2965 let mut dummy = crate::context::UiContext::new();
2966 let mut tb = TextBox::new(String::new()).with_password(true);
2967 tb.set_rect(10.0, 10.0, 200.0, 30.0);
2968 tb.focus();
2969 tb.edit_buffer = "hunter2".to_string();
2970 tb.cursor_idx = 7;
2971 tb.select_all();
2972 let state = TextEditorState {
2973 buffer: tb.edit_buffer.clone(),
2974 cursor_idx: tb.cursor_idx,
2975 select_anchor: tb.select_anchor,
2976 all_selected: tb.all_selected,
2977 };
2978 assert_eq!(state.selected_text().as_deref(), Some("hunter2"), "the selection is there");
2979 assert_eq!(tb.clipboard_text(&state), None, "but never for the clipboard");
2980
2981 let ctrl_x = KeyEvent {
2982 state: ElementState::Pressed,
2983 logical_key: Key::Character("x".to_string()),
2984 text: None,
2985 repeat: false,
2986 ctrl: true,
2987 shift: false,
2988 alt: false,
2989 };
2990 tb.keyboard_input(&ctrl_x, &mut dummy);
2991 assert_eq!(tb.edit_buffer, "hunter2", "Ctrl+X cuts nothing");
2992 assert!(!crate::widget::WidgetHostExt::context_action(&mut tb, crate::widget::ContextAction::Cut));
2993 assert_eq!(tb.edit_buffer, "hunter2", "nor does the menu's Cut");
2994
2995 dummy.hide_context_menu();
2996 assert!(tb.mouse_input(MouseButton::Right, ElementState::Pressed, 50.0, 20.0, &mut dummy));
2997 let opts = crate::widget::context_menu::options();
2998 assert!(!opts.iter().any(|o| o == "Cut" || o == "Copy"), "no Cut / Copy rows: {opts:?}");
2999 assert!(opts.iter().any(|o| o == "Paste"), "Paste stays: {opts:?}");
3000 dummy.hide_context_menu();
3001 }
3002
3003 /// The gate is the password flag alone: an ordinary box's selection goes.
3004 #[test]
3005 fn an_ordinary_box_selection_is_clipboard_text() {
3006 let tb = TextBox::new("hello".to_string());
3007 let state = TextEditorState {
3008 buffer: "hello".to_string(),
3009 cursor_idx: 5,
3010 select_anchor: Some(0),
3011 all_selected: false,
3012 };
3013 assert_eq!(tb.clipboard_text(&state).as_deref(), Some("hello"));
3014 }
3015 }