Skip to main content

slt/context/
helpers.rs

1use super::*;
2
3/// Byte offset of the `char_index`-th Unicode scalar boundary (clamped to
4/// `value.len()`).
5///
6/// Prefer [`byte_index_for_grapheme`] at cursor / wrap sites: a scalar index
7/// can fall inside a grapheme cluster (e.g. between the two regional indicators
8/// of a flag emoji, or between a base char and its combining mark), so slicing
9/// at a scalar boundary can cut a user-perceived character in half. This scalar
10/// form is retained only for the few remaining callers whose state column is
11/// still defined in scalar terms.
12#[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/// Number of extended grapheme clusters (user-perceived characters) in `s`.
24///
25/// This is the cluster-aware replacement for `s.chars().count()` at cursor /
26/// column sites. A ZWJ flag (`πŸ‡°πŸ‡·`), family emoji (`πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦`), Devanagari
27/// syllable (`ΰ€•ΰ₯ΰ€·ΰ€Ώ`), or Thai cluster (`กำ`) each counts as one.
28#[inline]
29pub(crate) fn grapheme_count(s: &str) -> usize {
30    s.graphemes(true).count()
31}
32
33/// Byte offset of the `cluster_index`-th extended-grapheme-cluster boundary
34/// (clamped to `s.len()`).
35///
36/// Replaces the scalar-based [`byte_index_for_char`] at cursor sites so that a
37/// slice / insert / delete never falls inside a cluster.
38#[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/// Display width (in terminal columns) of a single grapheme cluster string.
49///
50/// Measured on the whole cluster via [`UnicodeWidthStr::width`], which is
51/// correct for ZWJ emoji β€” a cluster's column count is the width of its visible
52/// glyph, not the per-scalar sum.
53#[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
86/// Pad or truncate `cell` so its display width is exactly `width` cells.
87///
88/// Shorter content is right-padded with spaces (current behavior); longer
89/// content is truncated with a `…` ellipsis. With an `Auto` column the
90/// resolved width already equals the content width, so this is a pure pad β€”
91/// preserving the pre-v0.21 string-grid output byte-for-byte.
92pub(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(' ').take(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    // Pad in case the last char was wide and left a one-cell gap before `…`.
120    let out_width = UnicodeWidthStr::width(out.as_str());
121    out.extend(std::iter::repeat(' ').take(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(' ').take(left));
183    centered.push_str(text);
184    centered.extend(std::iter::repeat(' ').take(right));
185    centered
186}
187
188pub(crate) struct TextareaVLine {
189    pub(crate) logical_row: usize,
190    /// Cluster index (extended grapheme cluster) of this visual segment's
191    /// start within its logical row.
192    pub(crate) char_start: usize,
193    /// Number of grapheme clusters this visual segment spans.
194    pub(crate) char_count: usize,
195}
196
197/// Build the visual (soft-wrapped) line layout for a textarea.
198///
199/// `char_start` / `char_count` are **grapheme-cluster** indices, not scalar
200/// indices, so a soft-wrap break never lands inside a cluster (a ZWJ emoji or
201/// combining sequence stays whole on one visual line).
202pub(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                .map_or(true, |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
278/// Intrinsic-size measurement API (v0.21.1).
279///
280/// These read-only queries expose the layout engine's text-wrapping math and
281/// the previous frame's named-container geometry without changing any rendering
282/// path. They let app code reserve space, decide pagination, or position
283/// floating UI relative to a widget that was laid out last frame.
284impl Context {
285    /// The intrinsic `(width, height_in_rows)` `text` would occupy, in cells.
286    ///
287    /// Reuses the exact word-wrap kernel the layout engine runs
288    /// ([`wrap_lines`](crate::layout::wrap_lines) via this crate's `tree`
289    /// module), so the answer always matches what a `ui.text(text).wrap()`
290    /// would actually render β€” width logic is never duplicated here.
291    ///
292    /// * When `max_width` is `None`, the text is measured unwrapped: width is
293    ///   the widest hard-break line, height is the number of `'\n'`-separated
294    ///   lines (at least 1).
295    /// * When `max_width` is `Some(w)` with `w > 0`, the text is wrapped to
296    ///   `w` columns; the returned width is the widest wrapped line (`<= w`)
297    ///   and the height is the wrapped line count.
298    /// * `Some(0)` is treated like `None` (no width budget β€” honor hard breaks
299    ///   only), mirroring the layout kernel's zero-width contract.
300    ///
301    /// Width is the terminal display width (wide CJK glyphs count as 2,
302    /// zero-width combining marks as 0). The result is clamped to `u16`; a
303    /// pathological line wider than `u16::MAX` cells saturates rather than
304    /// wrapping.
305    ///
306    /// # Examples
307    ///
308    /// ```no_run
309    /// # slt::run(|ui: &mut slt::Context| {
310    /// // Unwrapped: width is the longest line, height the line count.
311    /// let (w, h) = ui.measure_text("hello\nworld!", None);
312    /// assert_eq!((w, h), (6, 2));
313    ///
314    /// // Wrapped to 5 columns: the long word breaks across rows.
315    /// let (w, h) = ui.measure_text("alpha beta gamma", Some(5));
316    /// assert!(w <= 5 && h >= 1);
317    /// # });
318    /// ```
319    pub fn measure_text(&self, text: &str, max_width: Option<u16>) -> (u16, u16) {
320        // `Some(0)` collapses to the "no budget" path so we never feed a
321        // zero-width wrap (which the kernel treats as hard-break-only anyway).
322        let budget = match max_width {
323            Some(w) if w > 0 => w as u32,
324            // `u32::MAX` is the layout engine's "unbounded width" sentinel
325            // (see `textarea_build_visual_lines`); `wrap_lines` honors only
326            // hard breaks at that width, giving the unwrapped measurement.
327            _ => 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    /// The [`Rect`] a named widget/container occupied on the **last completed
342    /// frame**, or `None` if no group with that `name` was rendered.
343    ///
344    /// Reads the same `name β†’ rect` bookkeeping that powers group hover/focus
345    /// styling (`prev_group_rects`), captured at the end of the previous
346    /// frame's collect pass. Register a name with
347    /// [`Context::group`](crate::Context::group):
348    ///
349    /// ```ignore
350    /// ui.group("sidebar").border(slt::Border::Rounded).col(|ui| { /* … */ });
351    /// // …next frame:
352    /// if let Some(r) = ui.measured_rect("sidebar") {
353    ///     ui.text(format!("sidebar is {}x{}", r.width, r.height));
354    /// }
355    /// ```
356    ///
357    /// Returns `None` on the first frame (nothing measured yet) and for any
358    /// name that was not rendered as a `group(...)` last frame. If the same
359    /// name is used for multiple groups, the first match in render order is
360    /// returned.
361    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/// Saturating `usize -> u16` for intrinsic-size results.
370///
371/// A measured extent wider/taller than `u16::MAX` cells is pathological (no
372/// real terminal is that large); saturating keeps the public return type a
373/// compact `u16` without an overflow panic.
374#[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        // Two hard-break lines: width = widest line, height = line count.
409        let (w, h) = ui.measure_text("hello\nworld!", None);
410        assert_eq!((w, h), (6, 2));
411
412        // Single line, no breaks β†’ height 1.
413        assert_eq!(ui.measure_text("abc", None), (3, 1));
414
415        // Empty string is one blank line of zero width.
416        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        // "alpha beta gamma" wrapped to 5 columns: every word is <= 5 wide so
425        // it lands one word per line β†’ 3 rows, widest line "gamma" = 5.
426        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        // A word longer than the budget is hard-split across rows; height
432        // grows but width still stays within the budget.
433        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        // Edge case: `Some(0)` must not feed a zero-width wrap. It honors hard
441        // breaks only, identical to `None`.
442        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        // Two double-width CJK glyphs measure as 4 cells, one row.
455        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        // Nothing has been rendered yet β†’ no prior geometry.
463        assert!(ui.measured_rect("panel").is_none());
464    }
465
466    #[test]
467    fn measured_rect_returns_group_geometry_after_a_render() {
468        // Render a named group on frame 1; on frame 2 the previous frame's
469        // collected `prev_group_rects` makes the rect queryable.
470        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            // A name that was never rendered stays `None` β€” edge case guard.
482            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        // The group fits inside the 40x10 backend area.
491        assert!(rect.x + rect.width <= 40);
492        assert!(rect.y + rect.height <= 10);
493    }
494}