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