1use super::*;
2
3struct DeferredDrawClipGuard<'a> {
4 buffer: &'a mut crate::buffer::Buffer,
5 clip_depth: usize,
6 kitty_clip_depth: usize,
7 kitty_horizontal_clip_depth: usize,
8}
9
10impl<'a> DeferredDrawClipGuard<'a> {
11 fn new(
12 buffer: &'a mut crate::buffer::Buffer,
13 rect: Rect,
14 left_clip_cols: u32,
15 top_clip_rows: u32,
16 original_width: u32,
17 original_height: u32,
18 ) -> Self {
19 let clip_depth = buffer.clip_stack.len();
20 let kitty_clip_depth = buffer.kitty_clip_info_stack.len();
21 let kitty_horizontal_clip_depth = buffer.kitty_horizontal_clip_stack.len();
22 buffer.push_clip(rect);
23 buffer.push_kitty_clip(crate::buffer::KittyClipInfo {
24 top_clip_rows,
25 original_height,
26 });
27 buffer.push_kitty_horizontal_clip(crate::buffer::KittyHorizontalClipInfo {
28 left_clip_cols,
29 original_width,
30 });
31 Self {
32 buffer,
33 clip_depth,
34 kitty_clip_depth,
35 kitty_horizontal_clip_depth,
36 }
37 }
38
39 fn buffer(&mut self) -> &mut crate::buffer::Buffer {
40 self.buffer
41 }
42}
43
44impl Drop for DeferredDrawClipGuard<'_> {
45 fn drop(&mut self) {
46 self.buffer.clip_stack.truncate(self.clip_depth);
47 self.buffer
48 .kitty_clip_info_stack
49 .truncate(self.kitty_clip_depth);
50 self.buffer
51 .kitty_horizontal_clip_stack
52 .truncate(self.kitty_horizontal_clip_depth);
53 }
54}
55
56#[allow(dead_code)] pub(crate) fn invoke_deferred_draw(
63 buffer: &mut crate::buffer::Buffer,
64 rect: Rect,
65 left_clip_cols: u32,
66 top_clip_rows: u32,
67 original_width: u32,
68 original_height: u32,
69 draw: impl FnOnce(&mut crate::buffer::Buffer, Rect),
70) -> Result<(), Box<dyn std::any::Any + Send>> {
71 let mut clips = DeferredDrawClipGuard::new(
72 buffer,
73 rect,
74 left_clip_cols,
75 top_clip_rows,
76 original_width,
77 original_height,
78 );
79 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
80 draw(clips.buffer(), rect);
81 }));
82 drop(clips);
83 result
84}
85
86#[inline]
96pub(crate) fn byte_index_for_char(value: &str, char_index: usize) -> usize {
97 if char_index == 0 {
98 return 0;
99 }
100 value
101 .char_indices()
102 .nth(char_index)
103 .map_or(value.len(), |(idx, _)| idx)
104}
105
106#[inline]
112pub(crate) fn grapheme_count(s: &str) -> usize {
113 s.graphemes(true).count()
114}
115
116#[inline]
122pub(crate) fn byte_index_for_grapheme(s: &str, cluster_index: usize) -> usize {
123 if cluster_index == 0 {
124 return 0;
125 }
126 s.grapheme_indices(true)
127 .nth(cluster_index)
128 .map_or(s.len(), |(idx, _)| idx)
129}
130
131#[inline]
137pub(crate) fn cluster_width(cluster: &str) -> u32 {
138 UnicodeWidthStr::width(cluster) as u32
139}
140
141pub(crate) fn format_token_count(count: usize) -> String {
142 if count >= 1_000_000 {
143 format!("{:.1}M", count as f64 / 1_000_000.0)
144 } else if count >= 1_000 {
145 format!("{:.1}k", count as f64 / 1_000.0)
146 } else {
147 count.to_string()
148 }
149}
150
151pub(crate) fn format_table_row(cells: &[String], widths: &[u32], separator: &str) -> String {
152 let sep_width = UnicodeWidthStr::width(separator);
153 let total_cells_width: usize = widths.iter().map(|w| *w as usize).sum();
154 let mut row = String::with_capacity(
155 total_cells_width + sep_width.saturating_mul(widths.len().saturating_sub(1)),
156 );
157 for (i, width) in widths.iter().enumerate() {
158 if i > 0 {
159 row.push_str(separator);
160 }
161 row.push_str(&clamp_table_cell(
162 cells.get(i).map(String::as_str).unwrap_or(""),
163 *width,
164 ));
165 }
166 row
167}
168
169pub(crate) fn clamp_table_cell(cell: &str, width: u32) -> String {
176 let width = width as usize;
177 let cell_width = UnicodeWidthStr::width(cell);
178 if cell_width <= width {
179 let mut out = String::with_capacity(width);
180 out.push_str(cell);
181 out.extend(std::iter::repeat_n(' ', width - cell_width));
182 return out;
183 }
184 if width == 0 {
185 return String::new();
186 }
187 if width == 1 {
188 return "\u{2026}".to_string();
189 }
190 let target = width - 1;
191 let mut out = String::with_capacity(width);
192 let mut acc = 0usize;
193 for ch in cell.chars() {
194 let ch_width = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
195 if acc + ch_width > target {
196 break;
197 }
198 out.push(ch);
199 acc += ch_width;
200 }
201 out.push('\u{2026}');
202 let out_width = UnicodeWidthStr::width(out.as_str());
204 out.extend(std::iter::repeat_n(' ', width.saturating_sub(out_width)));
205 out
206}
207
208pub(crate) fn table_visible_len(state: &TableState) -> usize {
209 let visible = state.visible_indices();
210 if state.page_size == 0 {
211 return visible.len();
212 }
213
214 let start = state
215 .page
216 .saturating_mul(state.page_size)
217 .min(visible.len());
218 let end = (start + state.page_size).min(visible.len());
219 end.saturating_sub(start)
220}
221
222pub(crate) fn handle_vertical_nav(
223 selected: &mut usize,
224 max_index: usize,
225 key_code: KeyCode,
226) -> bool {
227 match key_code {
228 KeyCode::Up | KeyCode::Char('k') if *selected > 0 => {
229 *selected -= 1;
230 true
231 }
232 KeyCode::Down | KeyCode::Char('j') if *selected < max_index => {
233 *selected += 1;
234 true
235 }
236 _ => false,
237 }
238}
239
240pub(crate) fn format_compact_number(value: f64) -> String {
241 if value.fract().abs() < f64::EPSILON {
242 return format!("{value:.0}");
243 }
244
245 let mut s = format!("{value:.2}");
246 while s.contains('.') && s.ends_with('0') {
247 s.pop();
248 }
249 if s.ends_with('.') {
250 s.pop();
251 }
252 s
253}
254
255pub(crate) fn center_text(text: &str, width: usize) -> String {
256 let text_width = UnicodeWidthStr::width(text);
257 if text_width >= width {
258 return text.to_string();
259 }
260
261 let total = width - text_width;
262 let left = total / 2;
263 let right = total - left;
264 let mut centered = String::with_capacity(width);
265 centered.extend(std::iter::repeat_n(' ', left));
266 centered.push_str(text);
267 centered.extend(std::iter::repeat_n(' ', right));
268 centered
269}
270
271pub(crate) struct TextareaVLine {
272 pub(crate) logical_row: usize,
273 pub(crate) char_start: usize,
276 pub(crate) char_count: usize,
278}
279
280pub(crate) fn textarea_build_visual_lines(lines: &[String], wrap_width: u32) -> Vec<TextareaVLine> {
286 let mut out = Vec::new();
287 for (row, line) in lines.iter().enumerate() {
288 if line.is_empty() || wrap_width == u32::MAX {
289 out.push(TextareaVLine {
290 logical_row: row,
291 char_start: 0,
292 char_count: grapheme_count(line),
293 });
294 continue;
295 }
296 let mut seg_start = 0usize;
297 let mut seg_chars = 0usize;
298 let mut seg_width = 0u32;
299 for (idx, g) in line.graphemes(true).enumerate() {
300 let cw = cluster_width(g);
301 if seg_width + cw > wrap_width && seg_chars > 0 {
302 out.push(TextareaVLine {
303 logical_row: row,
304 char_start: seg_start,
305 char_count: seg_chars,
306 });
307 seg_start = idx;
308 seg_chars = 0;
309 seg_width = 0;
310 }
311 seg_chars += 1;
312 seg_width += cw;
313 }
314 out.push(TextareaVLine {
315 logical_row: row,
316 char_start: seg_start,
317 char_count: seg_chars,
318 });
319 }
320 out
321}
322
323pub(crate) fn textarea_logical_to_visual(
324 vlines: &[TextareaVLine],
325 logical_row: usize,
326 logical_col: usize,
327) -> (usize, usize) {
328 for (i, vl) in vlines.iter().enumerate() {
329 if vl.logical_row != logical_row {
330 continue;
331 }
332 let seg_end = vl.char_start + vl.char_count;
333 if logical_col >= vl.char_start && logical_col < seg_end {
334 return (i, logical_col - vl.char_start);
335 }
336 if logical_col == seg_end {
337 let is_last_seg = vlines
338 .get(i + 1)
339 .is_none_or(|next| next.logical_row != logical_row);
340 if is_last_seg {
341 return (i, logical_col - vl.char_start);
342 }
343 }
344 }
345 (vlines.len().saturating_sub(1), 0)
346}
347
348pub(crate) fn textarea_visual_to_logical(
349 vlines: &[TextareaVLine],
350 visual_row: usize,
351 visual_col: usize,
352) -> (usize, usize) {
353 if let Some(vl) = vlines.get(visual_row) {
354 let logical_col = vl.char_start + visual_col.min(vl.char_count);
355 (vl.logical_row, logical_col)
356 } else {
357 (0, 0)
358 }
359}
360
361impl Context {
368 pub fn measure_text(&self, text: &str, max_width: Option<u16>) -> (u16, u16) {
403 let budget = match max_width {
406 Some(w) if w > 0 => w as u32,
407 _ => u32::MAX,
411 };
412
413 let lines = crate::layout::wrap_lines(text, budget);
414 let height = lines.len().max(1);
415 let width = lines
416 .iter()
417 .map(|line| UnicodeWidthStr::width(line.as_str()))
418 .max()
419 .unwrap_or(0);
420
421 (clamp_u16(width), clamp_u16(height))
422 }
423
424 pub fn measured_rect(&self, name: &str) -> Option<Rect> {
445 self.prev_group_rects
446 .iter()
447 .find(|(group_name, _)| group_name.as_ref() == name)
448 .map(|(_, rect)| *rect)
449 }
450}
451
452#[inline]
458fn clamp_u16(value: usize) -> u16 {
459 value.min(u16::MAX as usize) as u16
460}
461
462#[allow(unused_variables)]
463pub(crate) fn open_url(url: &str) -> std::io::Result<()> {
464 #[cfg(target_os = "macos")]
465 {
466 std::process::Command::new("open").arg(url).spawn()?;
467 }
468 #[cfg(target_os = "linux")]
469 {
470 std::process::Command::new("xdg-open").arg(url).spawn()?;
471 }
472 #[cfg(target_os = "windows")]
473 {
474 std::process::Command::new("cmd")
475 .args(["/c", "start", "", url])
476 .spawn()?;
477 }
478 Ok(())
479}
480
481#[cfg(test)]
482mod measure_tests {
483 use crate::test_utils::TestBackend;
484 use crate::{Border, Context, FrameState, Theme};
485
486 #[test]
487 fn measure_text_unwrapped_reports_widest_line_and_line_count() {
488 let mut state = FrameState::default();
489 let ui = Context::new(Vec::new(), 40, 10, &mut state, Theme::dark());
490
491 let (w, h) = ui.measure_text("hello\nworld!", None);
493 assert_eq!((w, h), (6, 2));
494
495 assert_eq!(ui.measure_text("abc", None), (3, 1));
497
498 assert_eq!(ui.measure_text("", None), (0, 1));
500 }
501
502 #[test]
503 fn measure_text_wraps_to_budget_and_never_exceeds_it() {
504 let mut state = FrameState::default();
505 let ui = Context::new(Vec::new(), 40, 10, &mut state, Theme::dark());
506
507 let (w, h) = ui.measure_text("alpha beta gamma", Some(5));
510 assert!(w <= 5, "wrapped width {w} must not exceed the budget");
511 assert_eq!(h, 3, "three 5-wide words wrap onto three rows");
512 assert_eq!(w, 5);
513
514 let (w, h) = ui.measure_text("abcdefghij", Some(4));
517 assert!(w <= 4);
518 assert!(h >= 3, "10 chars at width 4 need at least 3 rows, got {h}");
519 }
520
521 #[test]
522 fn measure_text_some_zero_is_treated_as_unbounded() {
523 let mut state = FrameState::default();
526 let ui = Context::new(Vec::new(), 40, 10, &mut state, Theme::dark());
527 assert_eq!(
528 ui.measure_text("a b c\nlonger line", Some(0)),
529 ui.measure_text("a b c\nlonger line", None),
530 );
531 }
532
533 #[test]
534 fn measure_text_counts_wide_cjk_glyphs_as_two_cells() {
535 let mut state = FrameState::default();
536 let ui = Context::new(Vec::new(), 40, 10, &mut state, Theme::dark());
537 assert_eq!(ui.measure_text("νκΈ", None), (4, 1));
539 }
540
541 #[test]
542 fn measured_rect_is_none_on_first_frame() {
543 let mut state = FrameState::default();
544 let ui = Context::new(Vec::new(), 40, 10, &mut state, Theme::dark());
545 assert!(ui.measured_rect("panel").is_none());
547 }
548
549 #[test]
550 fn measured_rect_returns_group_geometry_after_a_render() {
551 let mut backend = TestBackend::new(40, 10);
554
555 backend.render(|ui| {
556 let _ = ui.group("panel").border(Border::Rounded).col(|ui| {
557 ui.text("hi");
558 });
559 });
560
561 let mut seen: Option<crate::Rect> = None;
562 backend.render(|ui| {
563 seen = ui.measured_rect("panel");
564 assert!(ui.measured_rect("does-not-exist").is_none());
566 });
567
568 let rect = seen.expect("named group must have a measured rect after render");
569 assert!(
570 rect.width > 0 && rect.height > 0,
571 "measured rect must be non-empty, got {rect:?}"
572 );
573 assert!(rect.x + rect.width <= 40);
575 assert!(rect.y + rect.height <= 10);
576 }
577}
578
579#[cfg(test)]
580mod deferred_draw_tests {
581 use super::invoke_deferred_draw;
582 use crate::buffer::{Buffer, KittyClipInfo};
583 use crate::{Rect, Style};
584
585 #[test]
586 fn nested_draw_panic_restores_both_clip_stacks() {
587 let mut buffer = Buffer::empty(Rect::new(0, 0, 20, 10));
588 let outer_clip = Rect::new(1, 1, 18, 8);
589 let outer_kitty = KittyClipInfo {
590 top_clip_rows: 1,
591 original_height: 12,
592 };
593 let outer_horizontal = crate::buffer::KittyHorizontalClipInfo {
594 left_clip_cols: 1,
595 original_width: 20,
596 };
597 buffer.push_clip(outer_clip);
598 buffer.push_kitty_clip(outer_kitty);
599 buffer.push_kitty_horizontal_clip(outer_horizontal);
600
601 let result = invoke_deferred_draw(
602 &mut buffer,
603 Rect::new(2, 2, 10, 4),
604 0,
605 2,
606 10,
607 8,
608 |buf, _| {
609 let inner =
610 invoke_deferred_draw(buf, Rect::new(3, 3, 4, 2), 0, 0, 4, 2, |buf, rect| {
611 buf.set_string(rect.x, rect.y, "partial", Style::new());
612 panic!("nested raw draw failed");
613 });
614 std::panic::resume_unwind(inner.expect_err("inner draw should panic"));
615 },
616 );
617
618 assert!(result.is_err());
619 assert_eq!(buffer.clip_stack, vec![outer_clip]);
620 assert_eq!(buffer.kitty_clip_info_stack, vec![outer_kitty]);
621 assert_eq!(buffer.kitty_horizontal_clip_stack, vec![outer_horizontal]);
622 }
623
624 #[test]
625 fn multiple_regions_leave_no_clip_state_after_success() {
626 let mut buffer = Buffer::empty(Rect::new(0, 0, 20, 10));
627 for x in [0, 10] {
628 invoke_deferred_draw(
629 &mut buffer,
630 Rect::new(x, 0, 10, 5),
631 0,
632 0,
633 10,
634 5,
635 |buf, rect| {
636 buf.set_string(rect.x, rect.y, "ok", Style::new());
637 },
638 )
639 .expect("draw should succeed");
640 }
641
642 assert!(buffer.clip_stack.is_empty());
643 assert!(buffer.kitty_clip_info_stack.is_empty());
644 }
645}