1use unicode_width::UnicodeWidthStr;
12
13use crate::item::BreadcrumbItem;
14use crate::strategy::TruncateStrategy;
15
16#[derive(Debug, Clone, PartialEq, Eq)]
18pub enum RenderElement {
19 Item {
21 index: usize,
23 abbreviated_label: Option<String>,
25 },
26 Separator,
28 Ellipsis {
30 text: String,
32 },
33}
34
35#[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
57fn 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#[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 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
161fn 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
176fn 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
202fn 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 < ellipsis_w {
217 return vec![RenderElement::Ellipsis {
218 text: ellipsis.to_string(),
219 }];
220 }
221
222 let last_idx = n.saturating_sub(1);
224 let last_w = widths[last_idx];
225
226 let base_cost = ellipsis_w + sep_width + last_w;
228 if base_cost > available_width {
229 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 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 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#[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 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 let minimal_middle_cost = widths[0] + sep_width + ellipsis_w + sep_width + widths[n - 1];
309 if minimal_middle_cost > available_width {
310 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; let mut tail_start = n - 1; let mut current_cost = minimal_middle_cost;
325
326 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 loop {
349 let mut expanded = false;
350
351 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 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_end >= tail_start {
378 return build_full_layout(n);
379 }
380
381 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
412fn 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
469fn 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 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 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 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 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 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}