Skip to main content

tui_breadcrumb/
truncate.rs

1// ==============================================================================
2// Truncation & Layout Resolution Engine
3// ==============================================================================
4
5//! Internal layout resolution and segment fitting engine.
6//!
7//! Responsible for taking a sequence of [`BreadcrumbItem`]s, a [`BreadcrumbSeparator`](crate::BreadcrumbSeparator),
8//! an available terminal width, and a [`TruncateStrategy`], and producing an optimal
9//! visual layout of elements ([`RenderElement`]) that strictly fits within the bounds.
10
11use unicode_width::UnicodeWidthStr;
12
13use crate::item::BreadcrumbItem;
14use crate::strategy::TruncateStrategy;
15
16/// An individual visual token in the resolved breadcrumb layout.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub enum RenderElement {
19    /// A regular or shortened breadcrumb item segment.
20    Item {
21        /// Original index in the input items array.
22        index: usize,
23        /// Optional shortened/abbreviated label string override (e.g. for `ShortenNames`).
24        abbreviated_label: Option<String>,
25    },
26    /// A separator placed between adjacent items or ellipses.
27    Separator,
28    /// An ellipsis indicating collapsed intermediate or ancestor items.
29    Ellipsis {
30        /// The ellipsis text to display (e.g. `"..."` or `"…"`).
31        text: String,
32    },
33}
34
35/// Helper function to safely shorten a string by unicode character count.
36#[must_use]
37pub fn shorten_str(s: &str, max_chars: usize) -> String {
38    if s.is_empty() || max_chars == 0 {
39        return String::new();
40    }
41    let mut end_byte = 0;
42    for (count, (idx, ch)) in s.char_indices().enumerate() {
43        if count >= max_chars {
44            break;
45        }
46        end_byte = idx + ch.len_utf8();
47    }
48    if end_byte == 0 {
49        return s
50            .chars()
51            .next()
52            .map_or_else(String::new, |ch| s[..ch.len_utf8()].to_string());
53    }
54    s[..end_byte].to_string()
55}
56
57/// Computes the visual terminal width of an item, accounting for potential shortened labels.
58fn compute_item_width(
59    item: &BreadcrumbItem<'_>,
60    abbrev_label: Option<&str>,
61    default_dropdown_sym: &str,
62) -> usize {
63    let label_w = match abbrev_label {
64        Some(abbrev) => UnicodeWidthStr::width(abbrev),
65        None => item.label_width(),
66    };
67    if item.has_dropdown {
68        let sym = item
69            .dropdown_symbol
70            .as_deref()
71            .unwrap_or(default_dropdown_sym);
72        label_w + 1 + UnicodeWidthStr::width(sym)
73    } else {
74        label_w
75    }
76}
77
78/// Resolves the layout elements that fit within `available_width`.
79#[must_use]
80pub fn resolve_layout(
81    items: &[BreadcrumbItem<'_>],
82    sep_width: usize,
83    available_width: usize,
84    strategy: &TruncateStrategy,
85    default_dropdown_sym: &str,
86) -> Vec<RenderElement> {
87    if items.is_empty() || available_width == 0 {
88        return Vec::new();
89    }
90
91    let n = items.len();
92    let original_widths: Vec<usize> = items
93        .iter()
94        .map(|item| compute_item_width(item, None, default_dropdown_sym))
95        .collect();
96
97    // Check if everything fits completely without truncation
98    let total_untruncated_width: usize =
99        original_widths.iter().sum::<usize>() + (n.saturating_sub(1) * sep_width);
100
101    if total_untruncated_width <= available_width {
102        return build_full_layout(n);
103    }
104
105    match strategy {
106        TruncateStrategy::None => {
107            build_clipped_layout(&original_widths, sep_width, available_width)
108        }
109        TruncateStrategy::Start {
110            min_tail_items,
111            ellipsis,
112        } => resolve_start_layout(
113            items,
114            &original_widths,
115            sep_width,
116            available_width,
117            *min_tail_items,
118            ellipsis,
119            default_dropdown_sym,
120        ),
121        TruncateStrategy::Middle {
122            min_head_items,
123            min_tail_items,
124            ellipsis,
125        } => resolve_middle_layout(
126            items,
127            &original_widths,
128            sep_width,
129            available_width,
130            *min_head_items,
131            *min_tail_items,
132            ellipsis,
133            default_dropdown_sym,
134        ),
135        TruncateStrategy::End {
136            min_head_items,
137            ellipsis,
138        } => resolve_end_layout(
139            &original_widths,
140            sep_width,
141            available_width,
142            *min_head_items,
143            ellipsis,
144        ),
145        TruncateStrategy::ShortenNames {
146            max_abbrev_len,
147            preserve_tail_items,
148            ellipsis,
149        } => resolve_shorten_names_layout(
150            items,
151            sep_width,
152            available_width,
153            *max_abbrev_len,
154            *preserve_tail_items,
155            ellipsis,
156            default_dropdown_sym,
157        ),
158    }
159}
160
161/// Builds an untruncated layout with all items and separators.
162fn build_full_layout(n: usize) -> Vec<RenderElement> {
163    let mut elements = Vec::with_capacity(n * 2);
164    for i in 0..n {
165        if i > 0 {
166            elements.push(RenderElement::Separator);
167        }
168        elements.push(RenderElement::Item {
169            index: i,
170            abbreviated_label: None,
171        });
172    }
173    elements
174}
175
176/// Builds a clipped layout (left to right) without ellipsis.
177fn build_clipped_layout(
178    widths: &[usize],
179    sep_width: usize,
180    available_width: usize,
181) -> Vec<RenderElement> {
182    let mut elements = Vec::new();
183    let mut current_width = 0;
184
185    for (i, &w) in widths.iter().enumerate() {
186        let needed = if i > 0 { sep_width + w } else { w };
187        if current_width + needed > available_width {
188            break;
189        }
190        if i > 0 {
191            elements.push(RenderElement::Separator);
192        }
193        elements.push(RenderElement::Item {
194            index: i,
195            abbreviated_label: None,
196        });
197        current_width += needed;
198    }
199    elements
200}
201
202/// Resolves `TruncateStrategy::Start`: `... ❯ item_{N-k} ❯ ... ❯ item_{N-1}`
203fn resolve_start_layout(
204    _items: &[BreadcrumbItem<'_>],
205    widths: &[usize],
206    sep_width: usize,
207    available_width: usize,
208    _min_tail_items: usize,
209    ellipsis: &str,
210    _default_dropdown_sym: &str,
211) -> Vec<RenderElement> {
212    let n = widths.len();
213    let ellipsis_w = UnicodeWidthStr::width(ellipsis);
214
215    // If available width is extremely small, try just the last item or just the ellipsis
216    if available_width < ellipsis_w {
217        return vec![RenderElement::Ellipsis {
218            text: ellipsis.to_string(),
219        }];
220    }
221
222    // Always try to include at least the last item (tail)
223    let last_idx = n.saturating_sub(1);
224    let last_w = widths[last_idx];
225
226    // Base cost: ellipsis + sep + last_item
227    let base_cost = ellipsis_w + sep_width + last_w;
228    if base_cost > available_width {
229        // If even ellipsis + last item doesn't fit, check if last item alone fits
230        if last_w <= available_width {
231            return vec![RenderElement::Item {
232                index: last_idx,
233                abbreviated_label: None,
234            }];
235        }
236        return vec![RenderElement::Ellipsis {
237            text: ellipsis.to_string(),
238        }];
239    }
240
241    // Accumulate tail items from right to left
242    let mut included_tail_count = 1;
243    let mut current_cost = base_cost;
244
245    for idx in (0..last_idx).rev() {
246        let item_cost = sep_width + widths[idx];
247        if current_cost + item_cost <= available_width {
248            current_cost += item_cost;
249            included_tail_count += 1;
250        } else {
251            break;
252        }
253    }
254
255    // If all items fit, we don't need ellipsis
256    if included_tail_count == n {
257        return build_full_layout(n);
258    }
259
260    let start_tail_idx = n - included_tail_count;
261    let mut elements = Vec::with_capacity(included_tail_count * 2 + 1);
262    elements.push(RenderElement::Ellipsis {
263        text: ellipsis.to_string(),
264    });
265    for idx in start_tail_idx..n {
266        elements.push(RenderElement::Separator);
267        elements.push(RenderElement::Item {
268            index: idx,
269            abbreviated_label: None,
270        });
271    }
272
273    elements
274}
275
276/// Resolves `TruncateStrategy::Middle`: `item_0 ❯ ... ❯ item_{N-k} ❯ item_{N-1}`
277#[allow(clippy::too_many_arguments)]
278fn resolve_middle_layout(
279    items: &[BreadcrumbItem<'_>],
280    widths: &[usize],
281    sep_width: usize,
282    available_width: usize,
283    min_head_items: usize,
284    min_tail_items: usize,
285    ellipsis: &str,
286    default_dropdown_sym: &str,
287) -> Vec<RenderElement> {
288    let n = widths.len();
289    if n <= 2 {
290        // Fallback to start layout for very short lists
291        return resolve_start_layout(
292            items,
293            widths,
294            sep_width,
295            available_width,
296            min_tail_items,
297            ellipsis,
298            default_dropdown_sym,
299        );
300    }
301
302    let ellipsis_w = UnicodeWidthStr::width(ellipsis);
303    let head_count_req = min_head_items.max(1);
304    let tail_count_req = min_tail_items.max(1);
305
306    // Initial check: Head 0 and Tail N-1 with ellipsis between them:
307    // item_0 + sep + ellipsis + sep + item_{N-1}
308    let minimal_middle_cost = widths[0] + sep_width + ellipsis_w + sep_width + widths[n - 1];
309    if minimal_middle_cost > available_width {
310        // Fallback to Start layout
311        return resolve_start_layout(
312            items,
313            widths,
314            sep_width,
315            available_width,
316            min_tail_items,
317            ellipsis,
318            default_dropdown_sym,
319        );
320    }
321
322    let mut head_end = 1; // exclusive index: items [0..head_end]
323    let mut tail_start = n - 1; // inclusive index: items [tail_start..n]
324    let mut current_cost = minimal_middle_cost;
325
326    // Try to satisfy required head and tail counts first if space permits
327    while head_end < head_count_req && head_end < tail_start {
328        let add_cost = sep_width + widths[head_end];
329        if current_cost + add_cost <= available_width {
330            current_cost += add_cost;
331            head_end += 1;
332        } else {
333            break;
334        }
335    }
336
337    while (n - tail_start) < tail_count_req && tail_start > head_end {
338        let add_cost = sep_width + widths[tail_start - 1];
339        if current_cost + add_cost <= available_width {
340            current_cost += add_cost;
341            tail_start -= 1;
342        } else {
343            break;
344        }
345    }
346
347    // Greedily expand tail, then head, until space exhausted or they meet
348    loop {
349        let mut expanded = false;
350
351        // Try adding another tail item first (deeper context)
352        if tail_start > head_end {
353            let add_cost = sep_width + widths[tail_start - 1];
354            if current_cost + add_cost <= available_width {
355                current_cost += add_cost;
356                tail_start -= 1;
357                expanded = true;
358            }
359        }
360
361        // Try adding another head item
362        if head_end < tail_start {
363            let add_cost = sep_width + widths[head_end];
364            if current_cost + add_cost <= available_width {
365                current_cost += add_cost;
366                head_end += 1;
367                expanded = true;
368            }
369        }
370
371        if !expanded {
372            break;
373        }
374    }
375
376    // If head meets tail, everything fits without ellipsis
377    if head_end >= tail_start {
378        return build_full_layout(n);
379    }
380
381    // Assemble layout: [0..head_end] + [Ellipsis] + [tail_start..n]
382    let mut elements = Vec::new();
383    for i in 0..head_end {
384        if i > 0 {
385            elements.push(RenderElement::Separator);
386        }
387        elements.push(RenderElement::Item {
388            index: i,
389            abbreviated_label: None,
390        });
391    }
392
393    elements.push(RenderElement::Separator);
394    elements.push(RenderElement::Ellipsis {
395        text: ellipsis.to_string(),
396    });
397    elements.push(RenderElement::Separator);
398
399    for i in tail_start..n {
400        elements.push(RenderElement::Item {
401            index: i,
402            abbreviated_label: None,
403        });
404        if i + 1 < n {
405            elements.push(RenderElement::Separator);
406        }
407    }
408
409    elements
410}
411
412/// Resolves `TruncateStrategy::End`: `item_0 ❯ item_1 ❯ ...`
413fn resolve_end_layout(
414    widths: &[usize],
415    sep_width: usize,
416    available_width: usize,
417    min_head_items: usize,
418    ellipsis: &str,
419) -> Vec<RenderElement> {
420    let n = widths.len();
421    let ellipsis_w = UnicodeWidthStr::width(ellipsis);
422
423    if available_width < ellipsis_w {
424        return vec![RenderElement::Ellipsis {
425            text: ellipsis.to_string(),
426        }];
427    }
428
429    let mut elements = Vec::new();
430    let mut current_cost = 0;
431    let mut included_count = 0;
432
433    for (i, &w) in widths.iter().enumerate() {
434        let is_first = i == 0;
435        let item_cost = if is_first { w } else { sep_width + w };
436        let cost_with_ellipsis = current_cost + item_cost + sep_width + ellipsis_w;
437
438        if (cost_with_ellipsis <= available_width)
439            || (is_first && item_cost <= available_width && min_head_items >= 1)
440        {
441            if !is_first {
442                elements.push(RenderElement::Separator);
443            }
444            elements.push(RenderElement::Item {
445                index: i,
446                abbreviated_label: None,
447            });
448            current_cost += item_cost;
449            included_count += 1;
450        } else {
451            break;
452        }
453    }
454
455    if included_count == n {
456        return build_full_layout(n);
457    }
458
459    if !elements.is_empty() {
460        elements.push(RenderElement::Separator);
461    }
462    elements.push(RenderElement::Ellipsis {
463        text: ellipsis.to_string(),
464    });
465
466    elements
467}
468
469/// Resolves `TruncateStrategy::ShortenNames`: `H ❯ P ❯ ratatui ❯ src ❯ sparkline.rs`
470fn resolve_shorten_names_layout(
471    items: &[BreadcrumbItem<'_>],
472    sep_width: usize,
473    available_width: usize,
474    max_abbrev_len: usize,
475    preserve_tail_items: usize,
476    ellipsis: &str,
477    default_dropdown_sym: &str,
478) -> Vec<RenderElement> {
479    let n = items.len();
480    let tail_preserve_start = n.saturating_sub(preserve_tail_items.max(1));
481
482    // Generate shortened labels for candidates (indices 0..tail_preserve_start)
483    let mut shortened_labels: Vec<Option<String>> = Vec::with_capacity(n);
484    let mut new_widths: Vec<usize> = Vec::with_capacity(n);
485
486    for (i, item) in items.iter().enumerate() {
487        if i < tail_preserve_start {
488            // Extract raw string representation from line to abbreviate
489            let raw_text = item
490                .label
491                .spans
492                .iter()
493                .map(|s| s.content.as_ref())
494                .collect::<String>();
495            let abbrev = shorten_str(&raw_text, max_abbrev_len);
496            let w = compute_item_width(item, Some(&abbrev), default_dropdown_sym);
497            shortened_labels.push(Some(abbrev));
498            new_widths.push(w);
499        } else {
500            let w = compute_item_width(item, None, default_dropdown_sym);
501            shortened_labels.push(None);
502            new_widths.push(w);
503        }
504    }
505
506    let total_shortened_width: usize =
507        new_widths.iter().sum::<usize>() + (n.saturating_sub(1) * sep_width);
508
509    // If shortened fits, return layout with shortened tokens
510    if total_shortened_width <= available_width {
511        let mut elements = Vec::with_capacity(n * 2);
512        for (i, label) in shortened_labels.into_iter().enumerate() {
513            if i > 0 {
514                elements.push(RenderElement::Separator);
515            }
516            elements.push(RenderElement::Item {
517                index: i,
518                abbreviated_label: label,
519            });
520        }
521        return elements;
522    }
523
524    // Fallback: apply Middle or Start truncation over the shortened layout
525    resolve_middle_layout(
526        items,
527        &new_widths,
528        sep_width,
529        available_width,
530        1,
531        preserve_tail_items,
532        ellipsis,
533        default_dropdown_sym,
534    )
535}
536
537#[cfg(test)]
538mod tests {
539    use super::*;
540    use crate::item::BreadcrumbItem;
541
542    #[test]
543    fn test_shorten_str() {
544        assert_eq!(shorten_str("Home", 1), "H");
545        assert_eq!(shorten_str("Projects", 2), "Pr");
546        assert_eq!(shorten_str("🦀 Rust", 1), "🦀");
547        assert_eq!(shorten_str("", 1), "");
548    }
549
550    #[test]
551    fn test_resolve_layout_full_fit() {
552        let items = vec![
553            BreadcrumbItem::new("Home"),
554            BreadcrumbItem::new("Projects"),
555            BreadcrumbItem::new("ratatui"),
556        ];
557        // "Home" (4) + sep(3) + "Projects" (8) + sep(3) + "ratatui" (7) = 25
558        let layout = resolve_layout(&items, 3, 30, &TruncateStrategy::middle(), "▾");
559        assert_eq!(
560            layout,
561            vec![
562                RenderElement::Item {
563                    index: 0,
564                    abbreviated_label: None
565                },
566                RenderElement::Separator,
567                RenderElement::Item {
568                    index: 1,
569                    abbreviated_label: None
570                },
571                RenderElement::Separator,
572                RenderElement::Item {
573                    index: 2,
574                    abbreviated_label: None
575                },
576            ]
577        );
578    }
579
580    #[test]
581    fn test_resolve_layout_start() {
582        let items = vec![
583            BreadcrumbItem::new("Home"),
584            BreadcrumbItem::new("Projects"),
585            BreadcrumbItem::new("ratatui"),
586            BreadcrumbItem::new("src"),
587            BreadcrumbItem::new("lib.rs"),
588        ];
589        let layout = resolve_layout(&items, 3, 20, &TruncateStrategy::start(), "▾");
590        assert_eq!(
591            layout,
592            vec![
593                RenderElement::Ellipsis { text: "...".into() },
594                RenderElement::Separator,
595                RenderElement::Item {
596                    index: 3,
597                    abbreviated_label: None
598                },
599                RenderElement::Separator,
600                RenderElement::Item {
601                    index: 4,
602                    abbreviated_label: None
603                },
604            ]
605        );
606    }
607
608    #[test]
609    fn test_resolve_layout_middle() {
610        let items = vec![
611            BreadcrumbItem::new("Home"),
612            BreadcrumbItem::new("Projects"),
613            BreadcrumbItem::new("ratatui"),
614            BreadcrumbItem::new("src"),
615            BreadcrumbItem::new("lib.rs"),
616        ];
617        let layout = resolve_layout(&items, 3, 26, &TruncateStrategy::middle(), "▾");
618        assert_eq!(
619            layout,
620            vec![
621                RenderElement::Item {
622                    index: 0,
623                    abbreviated_label: None
624                },
625                RenderElement::Separator,
626                RenderElement::Ellipsis { text: "...".into() },
627                RenderElement::Separator,
628                RenderElement::Item {
629                    index: 3,
630                    abbreviated_label: None
631                },
632                RenderElement::Separator,
633                RenderElement::Item {
634                    index: 4,
635                    abbreviated_label: None
636                },
637            ]
638        );
639    }
640
641    #[test]
642    fn test_resolve_layout_shorten_names() {
643        let items = vec![
644            BreadcrumbItem::new("Home"),
645            BreadcrumbItem::new("Projects"),
646            BreadcrumbItem::new("ratatui"),
647            BreadcrumbItem::new("src"),
648            BreadcrumbItem::new("lib.rs"),
649        ];
650        let strategy = TruncateStrategy::shorten_names_with(1, 2, "...");
651        let layout = resolve_layout(&items, 3, 24, &strategy, "▾");
652        assert_eq!(
653            layout,
654            vec![
655                RenderElement::Item {
656                    index: 0,
657                    abbreviated_label: Some("H".into())
658                },
659                RenderElement::Separator,
660                RenderElement::Item {
661                    index: 1,
662                    abbreviated_label: Some("P".into())
663                },
664                RenderElement::Separator,
665                RenderElement::Item {
666                    index: 2,
667                    abbreviated_label: Some("r".into())
668                },
669                RenderElement::Separator,
670                RenderElement::Item {
671                    index: 3,
672                    abbreviated_label: None
673                },
674                RenderElement::Separator,
675                RenderElement::Item {
676                    index: 4,
677                    abbreviated_label: None
678                },
679            ]
680        );
681    }
682}