Skip to main content

slt/context/
helpers.rs

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/// Invoke one deferred raw-draw callback with balanced cell and Kitty clips.
57///
58/// The callback panic is returned to the frame kernel instead of crossing the
59/// cleanup boundary. The caller decides whether to render an error-boundary
60/// fallback or write persistent frame state back and resume unwinding.
61#[allow(dead_code)] // Called by the #340 frame-kernel integration in src/lib.rs.
62pub(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/// Byte offset of the `char_index`-th Unicode scalar boundary (clamped to
87/// `value.len()`).
88///
89/// Prefer [`byte_index_for_grapheme`] at cursor / wrap sites: a scalar index
90/// can fall inside a grapheme cluster (e.g. between the two regional indicators
91/// of a flag emoji, or between a base char and its combining mark), so slicing
92/// at a scalar boundary can cut a user-perceived character in half. This scalar
93/// form is retained only for the few remaining callers whose state column is
94/// still defined in scalar terms.
95#[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/// Number of extended grapheme clusters (user-perceived characters) in `s`.
107///
108/// This is the cluster-aware replacement for `s.chars().count()` at cursor /
109/// column sites. A ZWJ flag (`πŸ‡°πŸ‡·`), family emoji (`πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦`), Devanagari
110/// syllable (`ΰ€•ΰ₯ΰ€·ΰ€Ώ`), or Thai cluster (`กำ`) each counts as one.
111#[inline]
112pub(crate) fn grapheme_count(s: &str) -> usize {
113    s.graphemes(true).count()
114}
115
116/// Byte offset of the `cluster_index`-th extended-grapheme-cluster boundary
117/// (clamped to `s.len()`).
118///
119/// Replaces the scalar-based [`byte_index_for_char`] at cursor sites so that a
120/// slice / insert / delete never falls inside a cluster.
121#[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/// Display width (in terminal columns) of a single grapheme cluster string.
132///
133/// Measured on the whole cluster via [`UnicodeWidthStr::width`], which is
134/// correct for ZWJ emoji β€” a cluster's column count is the width of its visible
135/// glyph, not the per-scalar sum.
136#[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
169/// Pad or truncate `cell` so its display width is exactly `width` cells.
170///
171/// Shorter content is right-padded with spaces (current behavior); longer
172/// content is truncated with a `…` ellipsis. With an `Auto` column the
173/// resolved width already equals the content width, so this is a pure pad β€”
174/// preserving the pre-v0.21 string-grid output byte-for-byte.
175pub(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    // Pad in case the last char was wide and left a one-cell gap before `…`.
203    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    /// Cluster index (extended grapheme cluster) of this visual segment's
274    /// start within its logical row.
275    pub(crate) char_start: usize,
276    /// Number of grapheme clusters this visual segment spans.
277    pub(crate) char_count: usize,
278}
279
280/// Build the visual (soft-wrapped) line layout for a textarea.
281///
282/// `char_start` / `char_count` are **grapheme-cluster** indices, not scalar
283/// indices, so a soft-wrap break never lands inside a cluster (a ZWJ emoji or
284/// combining sequence stays whole on one visual line).
285pub(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
361/// Intrinsic-size measurement API (v0.21.1).
362///
363/// These read-only queries expose the layout engine's text-wrapping math and
364/// the previous frame's named-container geometry without changing any rendering
365/// path. They let app code reserve space, decide pagination, or position
366/// floating UI relative to a widget that was laid out last frame.
367impl Context {
368    /// The intrinsic `(width, height_in_rows)` `text` would occupy, in cells.
369    ///
370    /// Reuses the exact word-wrap kernel the layout engine runs
371    /// (`wrap_lines` via this crate's `tree`
372    /// module), so the answer always matches what a `ui.text(text).wrap()`
373    /// would actually render β€” width logic is never duplicated here.
374    ///
375    /// * When `max_width` is `None`, the text is measured unwrapped: width is
376    ///   the widest hard-break line, height is the number of `'\n'`-separated
377    ///   lines (at least 1).
378    /// * When `max_width` is `Some(w)` with `w > 0`, the text is wrapped to
379    ///   `w` columns; the returned width is the widest wrapped line (`<= w`)
380    ///   and the height is the wrapped line count.
381    /// * `Some(0)` is treated like `None` (no width budget β€” honor hard breaks
382    ///   only), mirroring the layout kernel's zero-width contract.
383    ///
384    /// Width is the terminal display width (wide CJK glyphs count as 2,
385    /// zero-width combining marks as 0). The result is clamped to `u16`; a
386    /// pathological line wider than `u16::MAX` cells saturates rather than
387    /// wrapping.
388    ///
389    /// # Examples
390    ///
391    /// ```no_run
392    /// # slt::run(|ui: &mut slt::Context| {
393    /// // Unwrapped: width is the longest line, height the line count.
394    /// let (w, h) = ui.measure_text("hello\nworld!", None);
395    /// assert_eq!((w, h), (6, 2));
396    ///
397    /// // Wrapped to 5 columns: the long word breaks across rows.
398    /// let (w, h) = ui.measure_text("alpha beta gamma", Some(5));
399    /// assert!(w <= 5 && h >= 1);
400    /// # });
401    /// ```
402    pub fn measure_text(&self, text: &str, max_width: Option<u16>) -> (u16, u16) {
403        // `Some(0)` collapses to the "no budget" path so we never feed a
404        // zero-width wrap (which the kernel treats as hard-break-only anyway).
405        let budget = match max_width {
406            Some(w) if w > 0 => w as u32,
407            // `u32::MAX` is the layout engine's "unbounded width" sentinel
408            // (see `textarea_build_visual_lines`); `wrap_lines` honors only
409            // hard breaks at that width, giving the unwrapped measurement.
410            _ => 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    /// The [`Rect`] a named widget/container occupied on the **last completed
425    /// frame**, or `None` if no group with that `name` was rendered.
426    ///
427    /// Reads the same `name β†’ rect` bookkeeping that powers group hover/focus
428    /// styling (`prev_group_rects`), captured at the end of the previous
429    /// frame's collect pass. Register a name with
430    /// [`Context::group`](crate::Context::group):
431    ///
432    /// ```ignore
433    /// ui.group("sidebar").border(slt::Border::Rounded).col(|ui| { /* … */ });
434    /// // …next frame:
435    /// if let Some(r) = ui.measured_rect("sidebar") {
436    ///     ui.text(format!("sidebar is {}x{}", r.width, r.height));
437    /// }
438    /// ```
439    ///
440    /// Returns `None` on the first frame (nothing measured yet) and for any
441    /// name that was not rendered as a `group(...)` last frame. If the same
442    /// name is used for multiple groups, the first match in render order is
443    /// returned.
444    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/// Saturating `usize -> u16` for intrinsic-size results.
453///
454/// A measured extent wider/taller than `u16::MAX` cells is pathological (no
455/// real terminal is that large); saturating keeps the public return type a
456/// compact `u16` without an overflow panic.
457#[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        // Two hard-break lines: width = widest line, height = line count.
492        let (w, h) = ui.measure_text("hello\nworld!", None);
493        assert_eq!((w, h), (6, 2));
494
495        // Single line, no breaks β†’ height 1.
496        assert_eq!(ui.measure_text("abc", None), (3, 1));
497
498        // Empty string is one blank line of zero width.
499        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        // "alpha beta gamma" wrapped to 5 columns: every word is <= 5 wide so
508        // it lands one word per line β†’ 3 rows, widest line "gamma" = 5.
509        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        // A word longer than the budget is hard-split across rows; height
515        // grows but width still stays within the budget.
516        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        // Edge case: `Some(0)` must not feed a zero-width wrap. It honors hard
524        // breaks only, identical to `None`.
525        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        // Two double-width CJK glyphs measure as 4 cells, one row.
538        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        // Nothing has been rendered yet β†’ no prior geometry.
546        assert!(ui.measured_rect("panel").is_none());
547    }
548
549    #[test]
550    fn measured_rect_returns_group_geometry_after_a_render() {
551        // Render a named group on frame 1; on frame 2 the previous frame's
552        // collected `prev_group_rects` makes the rect queryable.
553        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            // A name that was never rendered stays `None` β€” edge case guard.
565            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        // The group fits inside the 40x10 backend area.
574        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}