1use repose_core::*;
41use std::ops::Range;
42use web_time::Duration;
43use web_time::Instant;
44
45use unicode_segmentation::UnicodeSegmentation;
46
47pub const TF_FONT_DP: f32 = 16.0;
49pub const TF_PADDING_X_DP: f32 = 8.0;
51
52pub struct TextMetrics {
53 pub positions: Vec<f32>, pub byte_offsets: Vec<usize>,
57}
58
59pub fn measure_text(text: &str, font_px: f32, font_family: Option<&'static str>) -> TextMetrics {
63 let m = repose_text::metrics_for_textfield(text, font_px, font_family);
64 TextMetrics {
65 positions: m.positions,
66 byte_offsets: m.byte_offsets,
67 }
68}
69
70pub fn byte_to_char_index(m: &TextMetrics, byte: usize) -> usize {
71 match m.byte_offsets.binary_search(&byte) {
72 Ok(i) | Err(i) => i,
73 }
74}
75
76pub fn index_for_x_bytes(text: &str, font_px: f32, x_px: f32) -> usize {
78 let m = measure_text(text, font_px, None);
79
80 let mut best_i = 0usize;
81 let mut best_d = f32::INFINITY;
82 for i in 0..m.positions.len() {
83 let d = (m.positions[i] - x_px).abs();
84 if d < best_d {
85 best_d = d;
86 best_i = i;
87 }
88 }
89 m.byte_offsets[best_i]
90}
91
92fn prev_grapheme_boundary(text: &str, byte: usize) -> usize {
94 let mut last = 0usize;
95 for (i, _) in text.grapheme_indices(true) {
96 if i >= byte {
97 break;
98 }
99 last = i;
100 }
101 last
102}
103
104fn next_grapheme_boundary(text: &str, byte: usize) -> usize {
105 for (i, _) in text.grapheme_indices(true) {
106 if i > byte {
107 return i;
108 }
109 }
110 text.len()
111}
112
113#[derive(Clone, Debug)]
114pub struct TextFieldState {
115 pub text: String,
116 pub selection: Range<usize>,
117 pub composition: Option<Range<usize>>, pub scroll_offset: f32, pub scroll_offset_y: f32, pub drag_anchor: Option<usize>, pub blink_start: Instant, pub inner_width: f32, pub inner_height: f32, pub preferred_x_px: Option<f32>, }
126
127impl Default for TextFieldState {
128 fn default() -> Self {
129 Self::new()
130 }
131}
132
133impl TextFieldState {
134 pub fn new() -> Self {
135 Self {
136 text: String::new(),
137 selection: 0..0,
138 composition: None,
139 scroll_offset: 0.0,
140 scroll_offset_y: 0.0,
141 drag_anchor: None,
142 blink_start: Instant::now(),
143 inner_width: 0.0,
144 inner_height: 0.0,
145 preferred_x_px: None,
146 }
147 }
148
149 pub fn insert_text(&mut self, text: &str) {
150 let start = self.selection.start.min(self.text.len());
151 let end = self.selection.end.min(self.text.len());
152
153 self.text.replace_range(start..end, text);
154 let new_pos = start + text.len();
155 self.selection = new_pos..new_pos;
156 self.preferred_x_px = None;
157 self.reset_caret_blink();
158 }
159
160 pub fn delete_backward(&mut self) {
161 if self.selection.start == self.selection.end {
162 let pos = self.selection.start.min(self.text.len());
163 if pos > 0 {
164 let prev = prev_grapheme_boundary(&self.text, pos);
165 self.text.replace_range(prev..pos, "");
166 self.selection = prev..prev;
167 }
168 } else {
169 self.insert_text("");
170 }
171 self.preferred_x_px = None;
172 self.reset_caret_blink();
173 }
174
175 pub fn delete_forward(&mut self) {
176 if self.selection.start == self.selection.end {
177 let pos = self.selection.start.min(self.text.len());
178 if pos < self.text.len() {
179 let next = next_grapheme_boundary(&self.text, pos);
180 self.text.replace_range(pos..next, "");
181 }
182 } else {
183 self.insert_text("");
184 }
185 self.preferred_x_px = None;
186 self.reset_caret_blink();
187 }
188
189 pub fn move_cursor(&mut self, delta: isize, extend_selection: bool) {
190 let mut pos = self.selection.end.min(self.text.len());
191 if delta < 0 {
192 for _ in 0..delta.unsigned_abs() {
193 pos = prev_grapheme_boundary(&self.text, pos);
194 }
195 } else if delta > 0 {
196 for _ in 0..(delta as usize) {
197 pos = next_grapheme_boundary(&self.text, pos);
198 }
199 }
200 if extend_selection {
201 self.selection.end = pos;
202 } else {
203 self.selection = pos..pos;
204 }
205 self.preferred_x_px = None;
206 self.reset_caret_blink();
207 }
208
209 pub fn selected_text(&self) -> String {
210 if self.selection.start == self.selection.end {
211 String::new()
212 } else {
213 self.text[self.selection.clone()].to_string()
214 }
215 }
216
217 pub fn set_composition(&mut self, text: String, cursor: Option<(usize, usize)>) {
218 if text.is_empty() {
219 if let Some(range) = self.composition.take() {
220 let s = clamp_to_char_boundary(&self.text, range.start.min(self.text.len()));
221 let e = clamp_to_char_boundary(&self.text, range.end.min(self.text.len()));
222 if s <= e {
223 self.text.replace_range(s..e, "");
224 self.selection = s..s;
225 }
226 }
227 self.preferred_x_px = None;
228 self.reset_caret_blink();
229 return;
230 }
231
232 let anchor_start;
233 if let Some(r) = self.composition.take() {
234 let mut s = clamp_to_char_boundary(&self.text, r.start.min(self.text.len()));
235 let mut e = clamp_to_char_boundary(&self.text, r.end.min(self.text.len()));
236 if e < s {
237 std::mem::swap(&mut s, &mut e);
238 }
239 self.text.replace_range(s..e, &text);
240 anchor_start = s;
241 } else {
242 let pos = clamp_to_char_boundary(&self.text, self.selection.start.min(self.text.len()));
243 self.text.insert_str(pos, &text);
244 anchor_start = pos;
245 }
246
247 self.composition = Some(anchor_start..(anchor_start + text.len()));
248
249 if let Some((c0, c1)) = cursor {
250 let b0 = char_to_byte(&text, c0);
251 let b1 = char_to_byte(&text, c1);
252 self.selection = (anchor_start + b0)..(anchor_start + b1);
253 } else {
254 let end = anchor_start + text.len();
255 self.selection = end..end;
256 }
257
258 self.preferred_x_px = None;
259 self.reset_caret_blink();
260 }
261
262 pub fn commit_composition(&mut self, text: String) {
263 if let Some(r) = self.composition.take() {
264 let s = clamp_to_char_boundary(&self.text, r.start.min(self.text.len()));
265 let e = clamp_to_char_boundary(&self.text, r.end.min(self.text.len()));
266 self.text.replace_range(s..e, &text);
267 let new_pos = s + text.len();
268 self.selection = new_pos..new_pos;
269 } else {
270 let pos = clamp_to_char_boundary(&self.text, self.selection.end.min(self.text.len()));
271 self.text.insert_str(pos, &text);
272 let new_pos = pos + text.len();
273 self.selection = new_pos..new_pos;
274 }
275 self.preferred_x_px = None;
276 self.reset_caret_blink();
277 }
278
279 pub fn cancel_composition(&mut self) {
280 if let Some(r) = self.composition.take() {
281 let s = clamp_to_char_boundary(&self.text, r.start.min(self.text.len()));
282 let e = clamp_to_char_boundary(&self.text, r.end.min(self.text.len()));
283 if s <= e {
284 self.text.replace_range(s..e, "");
285 self.selection = s..s;
286 }
287 }
288 self.preferred_x_px = None;
289 self.reset_caret_blink();
290 }
291
292 pub fn delete_surrounding(&mut self, before_bytes: usize, after_bytes: usize) {
293 if self.selection.start != self.selection.end {
294 let start = self.selection.start.min(self.text.len());
295 let end = self.selection.end.min(self.text.len());
296 self.text.replace_range(start..end, "");
297 self.selection = start..start;
298 self.preferred_x_px = None;
299 self.reset_caret_blink();
300 return;
301 }
302
303 let caret = self.selection.end.min(self.text.len());
304 let start_raw = caret.saturating_sub(before_bytes);
305 let end_raw = (caret + after_bytes).min(self.text.len());
306
307 let start = prev_grapheme_boundary(&self.text, start_raw);
308 let end = next_grapheme_boundary(&self.text, end_raw);
309 if start < end {
310 self.text.replace_range(start..end, "");
311 self.selection = start..start;
312 }
313 self.preferred_x_px = None;
314 self.reset_caret_blink();
315 }
316
317 pub fn begin_drag(&mut self, idx_byte: usize, extend: bool) {
318 let idx = idx_byte.min(self.text.len());
319 if extend {
320 let anchor = self.selection.start;
321 self.selection = anchor.min(idx)..anchor.max(idx);
322 self.drag_anchor = Some(anchor);
323 } else {
324 self.selection = idx..idx;
325 self.drag_anchor = Some(idx);
326 }
327 self.preferred_x_px = None;
328 self.reset_caret_blink();
329 }
330
331 pub fn drag_to(&mut self, idx_byte: usize) {
332 if let Some(anchor) = self.drag_anchor {
333 let i = idx_byte.min(self.text.len());
334 self.selection = anchor.min(i)..anchor.max(i);
335 }
336 self.preferred_x_px = None;
337 self.reset_caret_blink();
338 }
339 pub fn end_drag(&mut self) {
340 self.drag_anchor = None;
341 }
342
343 pub fn caret_index(&self) -> usize {
344 self.selection.end
345 }
346
347 pub fn ensure_caret_visible(&mut self, caret_x_px: f32, inner_width_px: f32, inset_px: f32) {
350 self.ensure_caret_visible_xy(caret_x_px, 0.0, inner_width_px, 1.0, inset_px);
351 }
352
353 pub fn ensure_caret_visible_xy(
355 &mut self,
356 caret_x_px: f32,
357 caret_y_px: f32,
358 inner_w_px: f32,
359 inner_h_px: f32,
360 inset_px: f32,
361 ) {
362 let inset_px = inset_px.max(0.0);
363
364 let left_px = self.scroll_offset + inset_px;
366 let right_px = self.scroll_offset + inner_w_px - inset_px;
367 if caret_x_px < left_px {
368 self.scroll_offset = (caret_x_px - inset_px).max(0.0);
369 } else if caret_x_px > right_px {
370 self.scroll_offset = (caret_x_px - inner_w_px + inset_px).max(0.0);
371 }
372
373 let top_px = self.scroll_offset_y + inset_px;
375 let bot_px = self.scroll_offset_y + inner_h_px - inset_px;
376 if caret_y_px < top_px {
377 self.scroll_offset_y = (caret_y_px - inset_px).max(0.0);
378 } else if caret_y_px > bot_px {
379 self.scroll_offset_y = (caret_y_px - inner_h_px + inset_px).max(0.0);
380 }
381 }
382
383 pub fn clamp_scroll(&mut self, content_h_px: f32) {
384 let max_y = (content_h_px - self.inner_height).max(0.0);
385 self.scroll_offset_y = self.scroll_offset_y.clamp(0.0, max_y);
386 if self.scroll_offset_y.is_nan() {
387 self.scroll_offset_y = 0.0;
388 }
389 }
390
391 pub fn reset_caret_blink(&mut self) {
392 self.blink_start = Instant::now();
393 }
394 pub fn caret_visible(&self) -> bool {
395 const PERIOD: Duration = Duration::from_millis(500);
396 ((Instant::now() - self.blink_start).as_millis() / PERIOD.as_millis()).is_multiple_of(2)
397 }
398
399 pub fn set_inner_width(&mut self, w_px: f32) {
400 self.inner_width = w_px.max(0.0);
401 if self.scroll_offset.is_nan() {
402 self.scroll_offset = 0.0;
403 }
404 }
405 pub fn set_inner_height(&mut self, h_px: f32) {
406 self.inner_height = h_px.max(0.0);
407 if self.scroll_offset_y.is_nan() {
408 self.scroll_offset_y = 0.0;
409 }
410 }
411}
412
413pub fn TextField(
415 hint: impl Into<String>,
416 value: String,
417 modifier: repose_core::Modifier,
418 on_change: Option<impl Fn(String) + 'static>,
419 on_submit: Option<impl Fn(String) + 'static>,
420) -> repose_core::View {
421 repose_core::View::new(
422 0,
423 repose_core::ViewKind::TextField {
424 state_key: 0,
425 hint: hint.into(),
426 on_change: on_change.map(|f| std::rc::Rc::new(f) as _),
427 on_submit: on_submit.map(|f| std::rc::Rc::new(f) as _),
428 multiline: false,
429 focus_tracker: None,
430 value,
431 },
432 )
433 .modifier(modifier)
434 .semantics(repose_core::Semantics {
435 role: repose_core::Role::TextField,
436 label: None,
437 focused: false,
438 enabled: true,
439 })
440}
441
442pub fn TextArea(
446 hint: impl Into<String>,
447 value: String,
448 modifier: repose_core::Modifier,
449 on_change: Option<impl Fn(String) + 'static>,
450 on_submit: Option<impl Fn(String) + 'static>,
451) -> repose_core::View {
452 repose_core::View::new(
453 0,
454 repose_core::ViewKind::TextField {
455 state_key: 0,
456 hint: hint.into(),
457 multiline: true,
458 on_change: on_change.map(|f| std::rc::Rc::new(f) as _),
459 on_submit: on_submit.map(|f| std::rc::Rc::new(f) as _),
460 focus_tracker: None,
461 value,
462 },
463 )
464 .modifier(modifier)
465 .semantics(repose_core::Semantics {
466 role: repose_core::Role::TextField,
467 label: None,
468 focused: false,
469 enabled: true,
470 })
471}
472
473#[derive(Clone, Debug)]
474pub struct TextAreaLayout {
475 pub ranges: Vec<(usize, usize)>,
476 pub line_h_px: f32,
477}
478
479pub fn layout_text_area(text: &str, font_px: f32, wrap_w_px: f32) -> TextAreaLayout {
480 let line_h = font_px * 1.3;
481 let (ranges, _) = repose_text::wrap_line_ranges(text, font_px, wrap_w_px.max(1.0), None, true);
482 TextAreaLayout {
483 ranges,
484 line_h_px: line_h,
485 }
486}
487
488fn locate_byte_in_ranges(ranges: &[(usize, usize)], b: usize) -> (usize, usize, usize) {
490 if ranges.is_empty() {
491 return (0, 0, b);
492 }
493 for (i, (s, e)) in ranges.iter().enumerate() {
494 if b < *s {
495 if i == 0 {
496 return (0, 0, b);
497 }
498 let (ps, pe) = ranges[i - 1];
499 let local = pe.saturating_sub(ps);
500 return (i - 1, local, ps + local);
501 }
502 if b < *e {
503 let local = b.saturating_sub(*s).min(e.saturating_sub(*s));
504 return (i, local, *s + local);
505 }
506 if b == *e {
507 if let Some((ns, _ne)) = ranges.get(i + 1) {
508 if *ns == b {
509 return (i + 1, 0, b);
510 }
511 }
512 let local = e.saturating_sub(*s);
513 return (i, local, *s + local);
514 }
515 }
516 let (ls, le) = ranges[ranges.len() - 1];
517 let local = le.saturating_sub(ls);
518 (ranges.len() - 1, local, ls + local)
519}
520
521pub fn caret_xy_for_byte(
523 text: &str,
524 font_px: f32,
525 wrap_w_px: f32,
526 byte: usize,
527) -> (f32, f32, usize) {
528 let layout = layout_text_area(text, font_px, wrap_w_px);
529 let (ranges, line_h) = (&layout.ranges, layout.line_h_px);
530 let (li, local, _) = locate_byte_in_ranges(ranges, byte);
531 let (s, e) = ranges.get(li).copied().unwrap_or((0, 0));
532 let line = &text[s..e];
533 let m = measure_text(line, font_px, None);
534 let ci = byte_to_char_index(&m, local);
535 let x = m.positions.get(ci).copied().unwrap_or(0.0);
536 let y = (li as f32) * line_h;
537 (x, y, li)
538}
539
540pub fn index_for_xy_bytes(text: &str, font_px: f32, wrap_w_px: f32, x_px: f32, y_px: f32) -> usize {
542 let layout = layout_text_area(text, font_px, wrap_w_px);
543 let li = ((y_px / layout.line_h_px).floor() as isize).max(0) as usize;
544 let li = li.min(layout.ranges.len().saturating_sub(1));
545 let (s, e) = layout.ranges.get(li).copied().unwrap_or((0, 0));
546 let line = &text[s..e];
547 let local = index_for_x_bytes(line, font_px, x_px.max(0.0));
548 (s + local).min(text.len())
549}
550
551pub fn move_caret_vertical(
553 text: &str,
554 font_px: f32,
555 wrap_w_px: f32,
556 cur_byte: usize,
557 dir: i32, preferred_x: Option<f32>,
559) -> (usize, f32) {
560 let layout = layout_text_area(text, font_px, wrap_w_px);
561 if layout.ranges.is_empty() {
562 return (cur_byte, preferred_x.unwrap_or(0.0));
563 }
564 let (x, _y, li) = caret_xy_for_byte(text, font_px, wrap_w_px, cur_byte);
565 let px = preferred_x.unwrap_or(x);
566 let mut nli = li as i32 + dir;
567 nli = nli.clamp(0, (layout.ranges.len().saturating_sub(1)) as i32);
568 let nli = nli as usize;
569 let (s, e) = layout.ranges[nli];
570 let line = &text[s..e];
571 let local = index_for_x_bytes(line, font_px, px.max(0.0));
572 ((s + local).min(text.len()), px)
573}
574
575pub fn line_home_end(
577 text: &str,
578 font_px: f32,
579 wrap_w_px: f32,
580 cur_byte: usize,
581 to_end: bool,
582) -> usize {
583 let layout = layout_text_area(text, font_px, wrap_w_px);
584 let (li, _local, _) = locate_byte_in_ranges(&layout.ranges, cur_byte);
585 let (s, e) = layout.ranges.get(li).copied().unwrap_or((0, 0));
586 if to_end { e } else { s }
587}
588
589fn clamp_to_char_boundary(s: &str, i: usize) -> usize {
590 if i >= s.len() {
591 return s.len();
592 }
593 if s.is_char_boundary(i) {
594 return i;
595 }
596 let mut j = i;
597 while j > 0 && !s.is_char_boundary(j) {
598 j -= 1;
599 }
600 j
601}
602
603fn char_to_byte(s: &str, ci: usize) -> usize {
604 if ci == 0 {
605 0
606 } else {
607 s.char_indices().nth(ci).map(|(i, _)| i).unwrap_or(s.len())
608 }
609}
610
611#[cfg(test)]
612mod tests {
613 use super::*;
614
615 #[test]
616 fn test_index_for_x_bytes_grapheme() {
617 let t = "A👍🏽B";
618 let font_px = 16.0; let m = measure_text(t, font_px, None);
620 for i in 0..m.byte_offsets.len() - 1 {
621 let b = m.byte_offsets[i];
622 let _ = &t[..b];
623 }
624 }
625}