1use super::*;
2
3#[inline]
13pub(crate) fn byte_index_for_char(value: &str, char_index: usize) -> usize {
14 if char_index == 0 {
15 return 0;
16 }
17 value
18 .char_indices()
19 .nth(char_index)
20 .map_or(value.len(), |(idx, _)| idx)
21}
22
23#[inline]
29pub(crate) fn grapheme_count(s: &str) -> usize {
30 s.graphemes(true).count()
31}
32
33#[inline]
39pub(crate) fn byte_index_for_grapheme(s: &str, cluster_index: usize) -> usize {
40 if cluster_index == 0 {
41 return 0;
42 }
43 s.grapheme_indices(true)
44 .nth(cluster_index)
45 .map_or(s.len(), |(idx, _)| idx)
46}
47
48#[inline]
54pub(crate) fn cluster_width(cluster: &str) -> u32 {
55 UnicodeWidthStr::width(cluster) as u32
56}
57
58pub(crate) fn format_token_count(count: usize) -> String {
59 if count >= 1_000_000 {
60 format!("{:.1}M", count as f64 / 1_000_000.0)
61 } else if count >= 1_000 {
62 format!("{:.1}k", count as f64 / 1_000.0)
63 } else {
64 count.to_string()
65 }
66}
67
68pub(crate) fn format_table_row(cells: &[String], widths: &[u32], separator: &str) -> String {
69 let sep_width = UnicodeWidthStr::width(separator);
70 let total_cells_width: usize = widths.iter().map(|w| *w as usize).sum();
71 let mut row = String::with_capacity(
72 total_cells_width + sep_width.saturating_mul(widths.len().saturating_sub(1)),
73 );
74 for (i, width) in widths.iter().enumerate() {
75 if i > 0 {
76 row.push_str(separator);
77 }
78 row.push_str(&clamp_table_cell(
79 cells.get(i).map(String::as_str).unwrap_or(""),
80 *width,
81 ));
82 }
83 row
84}
85
86pub(crate) fn clamp_table_cell(cell: &str, width: u32) -> String {
93 let width = width as usize;
94 let cell_width = UnicodeWidthStr::width(cell);
95 if cell_width <= width {
96 let mut out = String::with_capacity(width);
97 out.push_str(cell);
98 out.extend(std::iter::repeat_n(' ', width - cell_width));
99 return out;
100 }
101 if width == 0 {
102 return String::new();
103 }
104 if width == 1 {
105 return "\u{2026}".to_string();
106 }
107 let target = width - 1;
108 let mut out = String::with_capacity(width);
109 let mut acc = 0usize;
110 for ch in cell.chars() {
111 let ch_width = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
112 if acc + ch_width > target {
113 break;
114 }
115 out.push(ch);
116 acc += ch_width;
117 }
118 out.push('\u{2026}');
119 let out_width = UnicodeWidthStr::width(out.as_str());
121 out.extend(std::iter::repeat_n(' ', width.saturating_sub(out_width)));
122 out
123}
124
125pub(crate) fn table_visible_len(state: &TableState) -> usize {
126 let visible = state.visible_indices();
127 if state.page_size == 0 {
128 return visible.len();
129 }
130
131 let start = state
132 .page
133 .saturating_mul(state.page_size)
134 .min(visible.len());
135 let end = (start + state.page_size).min(visible.len());
136 end.saturating_sub(start)
137}
138
139pub(crate) fn handle_vertical_nav(
140 selected: &mut usize,
141 max_index: usize,
142 key_code: KeyCode,
143) -> bool {
144 match key_code {
145 KeyCode::Up | KeyCode::Char('k') if *selected > 0 => {
146 *selected -= 1;
147 true
148 }
149 KeyCode::Down | KeyCode::Char('j') if *selected < max_index => {
150 *selected += 1;
151 true
152 }
153 _ => false,
154 }
155}
156
157pub(crate) fn format_compact_number(value: f64) -> String {
158 if value.fract().abs() < f64::EPSILON {
159 return format!("{value:.0}");
160 }
161
162 let mut s = format!("{value:.2}");
163 while s.contains('.') && s.ends_with('0') {
164 s.pop();
165 }
166 if s.ends_with('.') {
167 s.pop();
168 }
169 s
170}
171
172pub(crate) fn center_text(text: &str, width: usize) -> String {
173 let text_width = UnicodeWidthStr::width(text);
174 if text_width >= width {
175 return text.to_string();
176 }
177
178 let total = width - text_width;
179 let left = total / 2;
180 let right = total - left;
181 let mut centered = String::with_capacity(width);
182 centered.extend(std::iter::repeat_n(' ', left));
183 centered.push_str(text);
184 centered.extend(std::iter::repeat_n(' ', right));
185 centered
186}
187
188pub(crate) struct TextareaVLine {
189 pub(crate) logical_row: usize,
190 pub(crate) char_start: usize,
193 pub(crate) char_count: usize,
195}
196
197pub(crate) fn textarea_build_visual_lines(lines: &[String], wrap_width: u32) -> Vec<TextareaVLine> {
203 let mut out = Vec::new();
204 for (row, line) in lines.iter().enumerate() {
205 if line.is_empty() || wrap_width == u32::MAX {
206 out.push(TextareaVLine {
207 logical_row: row,
208 char_start: 0,
209 char_count: grapheme_count(line),
210 });
211 continue;
212 }
213 let mut seg_start = 0usize;
214 let mut seg_chars = 0usize;
215 let mut seg_width = 0u32;
216 for (idx, g) in line.graphemes(true).enumerate() {
217 let cw = cluster_width(g);
218 if seg_width + cw > wrap_width && seg_chars > 0 {
219 out.push(TextareaVLine {
220 logical_row: row,
221 char_start: seg_start,
222 char_count: seg_chars,
223 });
224 seg_start = idx;
225 seg_chars = 0;
226 seg_width = 0;
227 }
228 seg_chars += 1;
229 seg_width += cw;
230 }
231 out.push(TextareaVLine {
232 logical_row: row,
233 char_start: seg_start,
234 char_count: seg_chars,
235 });
236 }
237 out
238}
239
240pub(crate) fn textarea_logical_to_visual(
241 vlines: &[TextareaVLine],
242 logical_row: usize,
243 logical_col: usize,
244) -> (usize, usize) {
245 for (i, vl) in vlines.iter().enumerate() {
246 if vl.logical_row != logical_row {
247 continue;
248 }
249 let seg_end = vl.char_start + vl.char_count;
250 if logical_col >= vl.char_start && logical_col < seg_end {
251 return (i, logical_col - vl.char_start);
252 }
253 if logical_col == seg_end {
254 let is_last_seg = vlines
255 .get(i + 1)
256 .is_none_or(|next| next.logical_row != logical_row);
257 if is_last_seg {
258 return (i, logical_col - vl.char_start);
259 }
260 }
261 }
262 (vlines.len().saturating_sub(1), 0)
263}
264
265pub(crate) fn textarea_visual_to_logical(
266 vlines: &[TextareaVLine],
267 visual_row: usize,
268 visual_col: usize,
269) -> (usize, usize) {
270 if let Some(vl) = vlines.get(visual_row) {
271 let logical_col = vl.char_start + visual_col.min(vl.char_count);
272 (vl.logical_row, logical_col)
273 } else {
274 (0, 0)
275 }
276}
277
278impl Context {
285 pub fn measure_text(&self, text: &str, max_width: Option<u16>) -> (u16, u16) {
320 let budget = match max_width {
323 Some(w) if w > 0 => w as u32,
324 _ => u32::MAX,
328 };
329
330 let lines = crate::layout::wrap_lines(text, budget);
331 let height = lines.len().max(1);
332 let width = lines
333 .iter()
334 .map(|line| UnicodeWidthStr::width(line.as_str()))
335 .max()
336 .unwrap_or(0);
337
338 (clamp_u16(width), clamp_u16(height))
339 }
340
341 pub fn measured_rect(&self, name: &str) -> Option<Rect> {
362 self.prev_group_rects
363 .iter()
364 .find(|(group_name, _)| group_name.as_ref() == name)
365 .map(|(_, rect)| *rect)
366 }
367}
368
369#[inline]
375fn clamp_u16(value: usize) -> u16 {
376 value.min(u16::MAX as usize) as u16
377}
378
379#[allow(unused_variables)]
380pub(crate) fn open_url(url: &str) -> std::io::Result<()> {
381 #[cfg(target_os = "macos")]
382 {
383 std::process::Command::new("open").arg(url).spawn()?;
384 }
385 #[cfg(target_os = "linux")]
386 {
387 std::process::Command::new("xdg-open").arg(url).spawn()?;
388 }
389 #[cfg(target_os = "windows")]
390 {
391 std::process::Command::new("cmd")
392 .args(["/c", "start", "", url])
393 .spawn()?;
394 }
395 Ok(())
396}
397
398#[cfg(test)]
399mod measure_tests {
400 use crate::test_utils::TestBackend;
401 use crate::{Border, Context, FrameState, Theme};
402
403 #[test]
404 fn measure_text_unwrapped_reports_widest_line_and_line_count() {
405 let mut state = FrameState::default();
406 let ui = Context::new(Vec::new(), 40, 10, &mut state, Theme::dark());
407
408 let (w, h) = ui.measure_text("hello\nworld!", None);
410 assert_eq!((w, h), (6, 2));
411
412 assert_eq!(ui.measure_text("abc", None), (3, 1));
414
415 assert_eq!(ui.measure_text("", None), (0, 1));
417 }
418
419 #[test]
420 fn measure_text_wraps_to_budget_and_never_exceeds_it() {
421 let mut state = FrameState::default();
422 let ui = Context::new(Vec::new(), 40, 10, &mut state, Theme::dark());
423
424 let (w, h) = ui.measure_text("alpha beta gamma", Some(5));
427 assert!(w <= 5, "wrapped width {w} must not exceed the budget");
428 assert_eq!(h, 3, "three 5-wide words wrap onto three rows");
429 assert_eq!(w, 5);
430
431 let (w, h) = ui.measure_text("abcdefghij", Some(4));
434 assert!(w <= 4);
435 assert!(h >= 3, "10 chars at width 4 need at least 3 rows, got {h}");
436 }
437
438 #[test]
439 fn measure_text_some_zero_is_treated_as_unbounded() {
440 let mut state = FrameState::default();
443 let ui = Context::new(Vec::new(), 40, 10, &mut state, Theme::dark());
444 assert_eq!(
445 ui.measure_text("a b c\nlonger line", Some(0)),
446 ui.measure_text("a b c\nlonger line", None),
447 );
448 }
449
450 #[test]
451 fn measure_text_counts_wide_cjk_glyphs_as_two_cells() {
452 let mut state = FrameState::default();
453 let ui = Context::new(Vec::new(), 40, 10, &mut state, Theme::dark());
454 assert_eq!(ui.measure_text("νκΈ", None), (4, 1));
456 }
457
458 #[test]
459 fn measured_rect_is_none_on_first_frame() {
460 let mut state = FrameState::default();
461 let ui = Context::new(Vec::new(), 40, 10, &mut state, Theme::dark());
462 assert!(ui.measured_rect("panel").is_none());
464 }
465
466 #[test]
467 fn measured_rect_returns_group_geometry_after_a_render() {
468 let mut backend = TestBackend::new(40, 10);
471
472 backend.render(|ui| {
473 let _ = ui.group("panel").border(Border::Rounded).col(|ui| {
474 ui.text("hi");
475 });
476 });
477
478 let mut seen: Option<crate::Rect> = None;
479 backend.render(|ui| {
480 seen = ui.measured_rect("panel");
481 assert!(ui.measured_rect("does-not-exist").is_none());
483 });
484
485 let rect = seen.expect("named group must have a measured rect after render");
486 assert!(
487 rect.width > 0 && rect.height > 0,
488 "measured rect must be non-empty, got {rect:?}"
489 );
490 assert!(rect.x + rect.width <= 40);
492 assert!(rect.y + rect.height <= 10);
493 }
494}