1use crate::data_context::DataContext;
11use crate::segments::{
12 text_width, RenderContext, RenderedSegment, Segment, SegmentDefaults, Separator, WidthBounds,
13};
14use crate::theme::{self, Capability, Style, StyledRun, Theme};
15use unicode_segmentation::UnicodeSegmentation;
16
17#[must_use]
25pub fn render(segments: &[Box<dyn Segment>], ctx: &DataContext, terminal_width: u16) -> String {
26 let mut warn = |msg: &str| crate::lsm_error!("{msg}");
27 render_with_warn(
28 segments,
29 ctx,
30 terminal_width,
31 &mut warn,
32 theme::default_theme(),
33 Capability::None,
34 false,
35 )
36}
37
38#[must_use]
54pub fn render_with_warn(
55 segments: &[Box<dyn Segment>],
56 ctx: &DataContext,
57 terminal_width: u16,
58 warn: &mut dyn FnMut(&str),
59 theme: &Theme,
60 capability: Capability,
61 hyperlinks: bool,
62) -> String {
63 let runs = render_to_runs(segments, ctx, terminal_width, warn);
64 runs_to_ansi(&runs, theme, capability, hyperlinks)
65}
66
67#[must_use]
83pub fn render_to_runs(
84 segments: &[Box<dyn Segment>],
85 ctx: &DataContext,
86 terminal_width: u16,
87 warn: &mut dyn FnMut(&str),
88) -> Vec<StyledRun> {
89 let rc = RenderContext::new(terminal_width);
90 let items = collect_items_with(segments, ctx, &rc, warn);
91 let laid_out = apply_layout(items, ctx, &rc, terminal_width);
92 items_to_runs(&laid_out)
93}
94
95#[must_use]
106pub fn runs_to_ansi(
107 runs: &[StyledRun],
108 theme: &Theme,
109 capability: Capability,
110 hyperlinks: bool,
111) -> String {
112 let mut out = String::new();
113 for run in runs {
114 let link = run.style.hyperlink.as_deref().filter(|_| hyperlinks);
115 if let Some(url) = link {
116 push_osc8_open(&mut out, url);
117 }
118 let open = theme::sgr_open(&run.style, theme, capability);
119 if open.is_empty() {
120 out.push_str(&run.text);
121 } else {
122 out.push_str(&open);
123 out.push_str(&run.text);
124 out.push_str(theme::sgr_reset());
125 }
126 if link.is_some() {
127 push_osc8_close(&mut out);
128 }
129 }
130 out
131}
132
133fn push_osc8_open(out: &mut String, url: &str) {
144 out.push_str("\x1b]8;;");
145 for c in url.chars() {
146 if !c.is_control() {
147 out.push(c);
148 }
149 }
150 out.push_str("\x1b\\");
151}
152
153fn push_osc8_close(out: &mut String) {
155 out.push_str("\x1b]8;;\x1b\\");
156}
157
158struct Item<'a> {
163 rendered: RenderedSegment,
164 defaults: SegmentDefaults,
165 segment: &'a dyn Segment,
166}
167
168fn collect_items_with<'a>(
169 segments: &'a [Box<dyn Segment>],
170 ctx: &DataContext,
171 rc: &RenderContext,
172 warn: &mut dyn FnMut(&str),
173) -> Vec<Item<'a>> {
174 segments
175 .iter()
176 .filter_map(|seg| {
177 let defaults = seg.defaults();
178 let rendered = match seg.render(ctx, rc) {
179 Ok(Some(r)) => r,
180 Ok(None) => return None,
181 Err(err) => {
182 warn(&format!("segment error: {err}"));
183 return None;
184 }
185 };
186 apply_width_bounds(rendered, defaults.width).map(|r| Item {
187 rendered: r,
188 defaults,
189 segment: seg.as_ref(),
190 })
191 })
192 .collect()
193}
194
195fn apply_layout<'a>(
199 mut items: Vec<Item<'a>>,
200 ctx: &DataContext,
201 rc: &RenderContext,
202 terminal_width: u16,
203) -> Vec<Item<'a>> {
204 let budget = u32::from(terminal_width);
205 loop {
206 let total = total_width(&items);
207 if total <= budget {
208 break;
209 }
210 let Some(drop_idx) = items
211 .iter()
212 .enumerate()
213 .filter(|(_, item)| item.defaults.priority > 0)
214 .max_by_key(|(_, item)| item.defaults.priority)
215 .map(|(i, _)| i)
216 else {
217 break;
218 };
219 let overflow = total - budget;
220 if let Some(shrunk) = try_shrink(&items[drop_idx], ctx, rc, overflow) {
225 items[drop_idx].rendered = shrunk;
226 continue;
227 }
228 if items[drop_idx].defaults.truncatable {
229 if let Some(reflowed) = try_reflow(&items[drop_idx], overflow) {
230 items[drop_idx] = reflowed;
231 continue;
232 }
233 }
234 items.remove(drop_idx);
235 }
236 items
237}
238
239#[cfg(test)]
243fn render_items(
244 items: Vec<Item<'_>>,
245 ctx: &DataContext,
246 rc: &RenderContext,
247 terminal_width: u16,
248 theme: &Theme,
249 capability: Capability,
250) -> String {
251 let laid_out = apply_layout(items, ctx, rc, terminal_width);
252 let runs = items_to_runs(&laid_out);
253 runs_to_ansi(&runs, theme, capability, false)
254}
255
256fn items_to_runs(items: &[Item<'_>]) -> Vec<StyledRun> {
261 let mut runs = Vec::with_capacity(items.len().saturating_mul(2));
262 for (i, item) in items.iter().enumerate() {
263 runs.push(StyledRun {
264 text: item.rendered.text.clone(),
265 style: item.rendered.style.clone(),
266 });
267 if i + 1 < items.len() {
268 let sep = effective_separator(item);
269 let sep_text = sep.text();
270 if !sep_text.is_empty() {
271 runs.push(StyledRun {
272 text: sep_text.to_string(),
273 style: separator_style(sep),
274 });
275 }
276 }
277 }
278 runs
279}
280
281fn separator_style(sep: &Separator) -> Style {
287 match sep {
288 Separator::Powerline { .. } => Style::role(theme::Role::Muted),
289 _ => Style::default(),
290 }
291}
292
293fn total_width(items: &[Item<'_>]) -> u32 {
297 if items.is_empty() {
298 return 0;
299 }
300 let seg_sum: u32 = items.iter().map(|i| u32::from(i.rendered.width)).sum();
301 let sep_sum: u32 = items
302 .iter()
303 .take(items.len() - 1)
304 .map(|item| u32::from(effective_separator(item).width()))
305 .sum();
306 seg_sum + sep_sum
307}
308
309fn effective_separator<'i>(item: &'i Item<'_>) -> &'i Separator {
310 item.rendered
311 .right_separator
312 .as_ref()
313 .unwrap_or(&item.defaults.default_separator)
314}
315
316fn apply_width_bounds(
320 rendered: RenderedSegment,
321 bounds: Option<WidthBounds>,
322) -> Option<RenderedSegment> {
323 let Some(bounds) = bounds else {
324 return Some(rendered);
325 };
326 if rendered.width < bounds.min() {
327 return None;
328 }
329 if rendered.width > bounds.max() {
330 return Some(truncate_to(rendered, bounds.max()));
331 }
332 Some(rendered)
333}
334
335fn try_reflow<'a>(item: &Item<'a>, overflow: u32) -> Option<Item<'a>> {
345 let floor = item.defaults.width.map_or(2, |b| b.min().max(2));
346 let cur = item.rendered.width;
347 let target = u32::from(cur).checked_sub(overflow)?;
348 let target_u16 = u16::try_from(target).ok()?;
349 if target_u16 < floor {
350 return None;
351 }
352 let truncated = truncate_to(item.rendered.clone(), target_u16);
353 if truncated.width < floor {
354 return None;
355 }
356 Some(Item {
357 rendered: truncated,
358 defaults: item.defaults.clone(),
359 segment: item.segment,
360 })
361}
362
363fn try_shrink(
373 item: &Item<'_>,
374 ctx: &DataContext,
375 rc: &RenderContext,
376 overflow: u32,
377) -> Option<RenderedSegment> {
378 let cur = item.rendered.width;
379 let target = u16::try_from(u32::from(cur).checked_sub(overflow)?).ok()?;
384 let min_floor = item.defaults.width.map_or(0, |b| b.min());
391 if target < min_floor {
392 return None;
393 }
394 let shrunk = item.segment.shrink_to_fit(ctx, rc, target)?;
395 if shrunk.width > target {
396 crate::lsm_warn!(
397 "segment shrink_to_fit returned width {} > target {}; rejecting",
398 shrunk.width,
399 target,
400 );
401 return None;
402 }
403 if shrunk.width < min_floor {
404 return None;
405 }
406 Some(shrunk)
407}
408
409pub(crate) fn truncate_to(rendered: RenderedSegment, max_cells: u16) -> RenderedSegment {
413 if max_cells == 0 {
414 return RenderedSegment::from_parts(
415 String::new(),
416 0,
417 rendered.right_separator,
418 rendered.style,
419 );
420 }
421 let budget = max_cells.saturating_sub(1);
423 let mut out = String::new();
424 let mut used: u16 = 0;
425 for cluster in rendered.text.graphemes(true) {
426 let w = text_width(cluster);
427 if used.saturating_add(w) > budget {
428 break;
429 }
430 out.push_str(cluster);
431 used = used.saturating_add(w);
432 }
433 out.push('…');
434 RenderedSegment::from_parts(
435 out,
436 used.saturating_add(1),
437 rendered.right_separator,
438 rendered.style,
439 )
440}
441
442#[cfg(test)]
443mod tests {
444 use super::*;
445 use crate::input::{ModelInfo, StatusContext, Tool, WorkspaceInfo};
446 use crate::theme;
447 use std::borrow::Cow;
448 use std::path::PathBuf;
449 use std::sync::Arc;
450
451 struct NoopSegment;
457 impl Segment for NoopSegment {
458 fn render(&self, _ctx: &DataContext, _rc: &RenderContext) -> RenderResult {
459 Ok(None)
460 }
461 }
462 static NOOP: NoopSegment = NoopSegment;
463 fn noop_segment() -> &'static dyn Segment {
464 &NOOP
465 }
466
467 fn empty_ctx() -> DataContext {
468 DataContext::new(StatusContext {
469 tool: Tool::ClaudeCode,
470 model: Some(ModelInfo {
471 display_name: "X".into(),
472 }),
473 workspace: Some(WorkspaceInfo {
474 project_dir: PathBuf::from("/"),
475 git_worktree: None,
476 }),
477 context_window: None,
478 cost: None,
479 effort: None,
480 vim: None,
481 output_style: None,
482 agent_name: None,
483 version: None,
484 raw: Arc::new(serde_json::Value::Null),
485 })
486 }
487
488 fn empty_rc() -> RenderContext {
489 RenderContext::new(80)
490 }
491
492 fn item(text: &str, priority: u8) -> Item<'static> {
493 Item {
494 rendered: RenderedSegment::new(text),
495 defaults: SegmentDefaults::with_priority(priority),
496 segment: noop_segment(),
497 }
498 }
499
500 fn render_plain(items: Vec<Item<'_>>, terminal_width: u16) -> String {
505 render_items(
506 items,
507 &empty_ctx(),
508 &empty_rc(),
509 terminal_width,
510 theme::default_theme(),
511 theme::Capability::None,
512 )
513 }
514
515 #[test]
516 fn render_items_wraps_each_styled_segment_under_palette16() {
517 use crate::theme::Role;
522 let items = vec![
523 Item {
524 rendered: RenderedSegment::new("a"),
525 defaults: SegmentDefaults::with_priority(10),
526 segment: noop_segment(),
527 },
528 Item {
529 rendered: RenderedSegment::new("b").with_role(Role::Warning),
530 defaults: SegmentDefaults::with_priority(10),
531 segment: noop_segment(),
532 },
533 Item {
534 rendered: RenderedSegment::new("c"),
535 defaults: SegmentDefaults::with_priority(10),
536 segment: noop_segment(),
537 },
538 ];
539 let out = render_items(
540 items,
541 &empty_ctx(),
542 &empty_rc(),
543 100,
544 theme::default_theme(),
545 theme::Capability::Palette16,
546 );
547 assert_eq!(out, "a \x1b[93mb\x1b[0m c");
549 }
550
551 #[test]
552 fn total_width_counts_inter_segment_separators_only() {
553 let items = vec![item("ab", 10), item("cd", 10), item("ef", 10)];
554 assert_eq!(total_width(&items), 8);
556 }
557
558 #[test]
559 fn total_width_zero_for_empty() {
560 assert_eq!(total_width(&[]), 0);
561 }
562
563 #[test]
564 fn total_width_single_segment_has_no_separator() {
565 let items = vec![item("abcde", 10)];
566 assert_eq!(total_width(&items), 5);
567 }
568
569 #[test]
570 fn no_width_pressure_renders_all_with_separators() {
571 let items = vec![item("one", 10), item("two", 20), item("three", 30)];
572 assert_eq!(render_plain(items, 100), "one two three");
573 }
574
575 #[test]
576 fn drops_highest_priority_under_pressure() {
577 let items = vec![
578 item("aaaa", 10),
579 item("bbbb", 200), item("cccc", 50),
581 ];
582 let out = render_plain(items, 10);
584 assert!(!out.contains("bbbb"));
585 assert!(out.contains("aaaa"));
586 assert!(out.contains("cccc"));
587 }
588
589 #[test]
590 fn drops_in_descending_priority_order() {
591 let items = vec![
592 item("one", 10),
593 item("two", 200), item("three", 20),
595 item("four", 150), item("five", 30),
597 ];
598 assert_eq!(render_plain(items, 15), "one three five");
600 }
601
602 #[test]
603 fn priority_zero_never_drops_even_over_budget() {
604 let items = vec![item("aaaa", 0), item("bbbb", 0)];
605 let out = render_plain(items, 3);
606 assert_eq!(out, "aaaa bbbb");
607 }
608
609 #[test]
610 fn priority_drop_recomputes_budget_with_powerline_separators() {
611 let item_pl = |text: &'static str, priority: u8| Item {
619 rendered: RenderedSegment::new(text),
620 defaults: SegmentDefaults::with_priority(priority)
621 .with_default_separator(Separator::powerline()),
622 segment: noop_segment(),
623 };
624 let items = vec![item_pl("aaaa", 0), item_pl("bbbb", 200), item_pl("cccc", 0)];
625 let out = render_plain(items, 14);
627 assert!(out.contains("aaaa"));
628 assert!(!out.contains("bbbb"));
629 assert!(out.contains("cccc"));
630 assert!(
631 out.contains('\u{E0B0}'),
632 "chevron survives the drop: {out:?}"
633 );
634 }
635
636 #[test]
637 fn mix_drops_positives_keeps_zeros() {
638 let items = vec![
639 item("keep-me", 0),
640 item("droppable", 200),
641 item("sticky", 0),
642 ];
643 let out = render_plain(items, 20);
645 assert_eq!(out, "keep-me sticky");
646 }
647
648 #[test]
649 fn no_trailing_separator() {
650 let items = vec![item("a", 10), item("b", 10)];
651 assert_eq!(render_plain(items, 100), "a b");
652 }
653
654 #[test]
655 fn empty_input_renders_empty_string() {
656 assert_eq!(render_plain(vec![], 100), "");
657 }
658
659 #[test]
660 fn respects_custom_separator_from_defaults() {
661 let items = vec![
662 Item {
663 rendered: RenderedSegment::new("a"),
664 defaults: SegmentDefaults {
665 priority: 10,
666 width: None,
667 default_separator: Separator::Literal(Cow::Borrowed(" | ")),
668 truncatable: false,
669 },
670 segment: noop_segment(),
671 },
672 Item {
673 rendered: RenderedSegment::new("b"),
674 defaults: SegmentDefaults::with_priority(10),
675 segment: noop_segment(),
676 },
677 ];
678 assert_eq!(render_plain(items, 100), "a | b");
679 }
680
681 #[test]
682 fn render_override_separator_beats_default() {
683 let items = vec![
684 Item {
685 rendered: RenderedSegment::with_separator("a", Separator::None),
686 defaults: SegmentDefaults::with_priority(10),
687 segment: noop_segment(),
688 },
689 Item {
690 rendered: RenderedSegment::new("b"),
691 defaults: SegmentDefaults::with_priority(10),
692 segment: noop_segment(),
693 },
694 ];
695 assert_eq!(render_plain(items, 100), "ab");
696 }
697
698 #[test]
701 fn apply_width_bounds_drops_below_min() {
702 let bounds = WidthBounds::new(5, 10);
703 let rendered = RenderedSegment::new("abc"); assert!(apply_width_bounds(rendered, bounds).is_none());
705 }
706
707 #[test]
708 fn apply_width_bounds_truncates_above_max() {
709 let bounds = WidthBounds::new(0, 5);
710 let rendered = RenderedSegment::new("abcdefghij"); let truncated = apply_width_bounds(rendered, bounds).expect("truncated");
712 assert_eq!(truncated.width, 5);
713 assert!(truncated.text.ends_with('…'));
714 assert_eq!(truncated.text, "abcd…");
715 }
716
717 #[test]
718 fn apply_width_bounds_passthrough_within_range() {
719 let bounds = WidthBounds::new(2, 10);
720 let original = RenderedSegment::new("hello");
721 let result = apply_width_bounds(original.clone(), bounds).expect("kept");
722 assert_eq!(result, original);
723 }
724
725 #[test]
726 fn apply_width_bounds_none_is_passthrough() {
727 let original = RenderedSegment::new("anything");
728 let result = apply_width_bounds(original.clone(), None).expect("kept");
729 assert_eq!(result, original);
730 }
731
732 #[test]
733 fn truncate_to_zero_yields_empty() {
734 let out = truncate_to(RenderedSegment::new("abc"), 0);
735 assert_eq!(out.text, "");
736 assert_eq!(out.width, 0);
737 }
738
739 #[test]
740 fn truncate_handles_wide_grapheme_without_splitting() {
741 let bounds = WidthBounds::new(0, 6);
744 let truncated =
745 apply_width_bounds(RenderedSegment::new("42% · 200k"), bounds).expect("truncated");
746 assert_eq!(truncated.text, "42% ·…");
747 assert_eq!(truncated.width, 6);
748 }
749
750 #[test]
751 fn truncate_preserves_combining_mark_with_base() {
752 let r = RenderedSegment::new("ab\u{65}\u{301}de");
755 assert_eq!(r.width, 5);
756 let out = truncate_to(r, 4);
757 assert_eq!(out.text, "ab\u{65}\u{301}…");
758 assert_eq!(out.width, 4);
759 }
760
761 #[test]
762 fn truncate_does_not_split_zwj_emoji_sequence() {
763 let text = "a\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F466}b";
768 let r = RenderedSegment::new(text);
769 let out = truncate_to(r, 3);
770 assert_eq!(out.text, "a…");
771 assert_eq!(out.width, 2);
772 }
773
774 #[test]
775 fn truncate_to_max_cells_one_emits_only_ellipsis() {
776 let r = RenderedSegment::new("anything");
777 let out = truncate_to(r, 1);
778 assert_eq!(out.text, "…");
779 assert_eq!(out.width, 1);
780 }
781
782 #[test]
783 fn priority_ties_drop_rightmost_first() {
784 let items = vec![item("left", 200), item("mid", 50), item("right", 200)];
785 assert_eq!(render_plain(items, 10), "left mid");
788 }
789
790 #[test]
791 fn separator_none_not_charged_to_budget() {
792 let items = vec![
797 Item {
798 rendered: RenderedSegment::new("a"),
799 defaults: SegmentDefaults::with_priority(200),
800 segment: noop_segment(),
801 },
802 Item {
803 rendered: RenderedSegment::with_separator("b", Separator::None),
804 defaults: SegmentDefaults::with_priority(200),
805 segment: noop_segment(),
806 },
807 Item {
808 rendered: RenderedSegment::new("c"),
809 defaults: SegmentDefaults::with_priority(200),
810 segment: noop_segment(),
811 },
812 ];
813 assert_eq!(render_plain(items, 4), "a bc");
814 }
815
816 #[test]
817 fn total_width_returns_u32_beyond_u16_range() {
818 let items = vec![
821 Item {
822 rendered: RenderedSegment::new("x".repeat(u16::MAX as usize)),
823 defaults: SegmentDefaults::with_priority(10),
824 segment: noop_segment(),
825 },
826 Item {
827 rendered: RenderedSegment::new("x".repeat(u16::MAX as usize)),
828 defaults: SegmentDefaults::with_priority(10),
829 segment: noop_segment(),
830 },
831 Item {
832 rendered: RenderedSegment::new("x".repeat(u16::MAX as usize)),
833 defaults: SegmentDefaults::with_priority(10),
834 segment: noop_segment(),
835 },
836 ];
837 assert_eq!(total_width(&items), 3 * u32::from(u16::MAX) + 2);
838 }
839
840 #[test]
841 fn all_priority_zero_keeps_every_segment_even_when_overfull() {
842 let items = vec![item("aaa", 0), item("bbb", 0), item("ccc", 0)];
843 assert_eq!(render_plain(items, 4), "aaa bbb ccc");
845 }
846
847 use crate::segments::{RenderResult, SegmentError};
850
851 struct StubSegment(RenderResult);
852
853 impl Segment for StubSegment {
854 fn render(&self, _ctx: &DataContext, _rc: &RenderContext) -> RenderResult {
855 match &self.0 {
856 Ok(Some(r)) => Ok(Some(r.clone())),
857 Ok(None) => Ok(None),
858 Err(e) => Err(SegmentError::new(e.message.clone())),
859 }
860 }
861 }
862
863 #[test]
864 fn segment_error_is_logged_and_hides_segment() {
865 let segments: Vec<Box<dyn Segment>> = vec![
866 Box::new(StubSegment(Ok(Some(RenderedSegment::new("ok-before"))))),
867 Box::new(StubSegment(Err(SegmentError::new("boom")))),
868 Box::new(StubSegment(Ok(Some(RenderedSegment::new("ok-after"))))),
869 ];
870 let mut warnings = Vec::new();
871 let items = collect_items_with(&segments, &empty_ctx(), &empty_rc(), &mut |msg| {
872 warnings.push(msg.to_string());
873 });
874 assert_eq!(items.len(), 2);
876 assert_eq!(items[0].rendered.text, "ok-before");
877 assert_eq!(items[1].rendered.text, "ok-after");
878 assert_eq!(warnings.len(), 1);
880 assert!(warnings[0].contains("segment error"));
881 assert!(warnings[0].contains("boom"));
882 }
883
884 #[test]
885 fn ok_none_is_silently_hidden() {
886 let segments: Vec<Box<dyn Segment>> = vec![
887 Box::new(StubSegment(Ok(Some(RenderedSegment::new("visible"))))),
888 Box::new(StubSegment(Ok(None))),
889 ];
890 let mut warnings = Vec::new();
891 let items = collect_items_with(&segments, &empty_ctx(), &empty_rc(), &mut |msg| {
892 warnings.push(msg.to_string());
893 });
894 assert_eq!(items.len(), 1);
895 assert!(warnings.is_empty());
896 }
897
898 struct WidthEcho;
901 impl Segment for WidthEcho {
902 fn render(&self, _ctx: &DataContext, rc: &RenderContext) -> RenderResult {
903 Ok(Some(RenderedSegment::new(rc.terminal_width.to_string())))
904 }
905 }
906
907 #[test]
908 fn render_context_threads_terminal_width_into_segments() {
909 let segments: Vec<Box<dyn Segment>> = vec![Box::new(WidthEcho)];
914 let mut warnings = Vec::new();
915 let rc = RenderContext::new(42);
916 let items = collect_items_with(&segments, &empty_ctx(), &rc, &mut |msg| {
917 warnings.push(msg.to_string());
918 });
919 assert_eq!(items.len(), 1);
920 assert_eq!(items[0].rendered.text, "42");
921 }
922
923 #[test]
924 fn render_with_warn_constructs_render_context_from_terminal_width_arg() {
925 let segments: Vec<Box<dyn Segment>> = vec![Box::new(WidthEcho)];
931 let mut warnings = Vec::new();
932 let line = render_with_warn(
933 &segments,
934 &empty_ctx(),
935 137,
936 &mut |msg| warnings.push(msg.to_string()),
937 theme::default_theme(),
938 theme::Capability::None,
939 false,
940 );
941 assert!(line.contains("137"), "got {line:?}");
942 }
943
944 fn truncatable_item(text: &str, priority: u8) -> Item<'static> {
947 Item {
948 rendered: RenderedSegment::new(text),
949 defaults: SegmentDefaults::with_priority(priority).with_truncatable(true),
950 segment: noop_segment(),
951 }
952 }
953
954 #[test]
955 fn reflow_truncates_highest_priority_before_dropping() {
956 let items = vec![
960 truncatable_item("linesmith/very-long-feature-branch-name", 200),
961 item("Sonnet", 0),
962 ];
963 let out = render_plain(items, 30);
966 assert!(out.starts_with("linesmith/very-long-fe"), "got {out:?}");
967 assert!(out.ends_with("… Sonnet"), "got {out:?}");
968 assert_eq!(text_width(&out), 30);
969 }
970
971 #[test]
972 fn reflow_drops_when_truncation_would_fall_below_floor() {
973 let items = vec![truncatable_item("workspace-name", 200), item("KEEP", 0)];
976 let out = render_plain(items, 4);
979 assert_eq!(out, "KEEP");
980 }
981
982 #[test]
983 fn reflow_respects_explicit_width_min_floor() {
984 let bounds = WidthBounds::new(8, u16::MAX).expect("valid");
987 let mut wide = truncatable_item("abcdefghijklmnop", 200); wide.defaults.width = Some(bounds);
989 let items = vec![wide, item("X", 0)];
990 let out = render_plain(items, 10);
993 assert!(out.contains('…'), "got {out:?}");
994 assert!(out.ends_with(" X"), "got {out:?}");
995
996 let bounds = WidthBounds::new(8, u16::MAX).expect("valid");
998 let mut wide = truncatable_item("abcdefghijklmnop", 200);
999 wide.defaults.width = Some(bounds);
1000 let items = vec![wide, item("X", 0)];
1001 let out = render_plain(items, 9);
1002 assert_eq!(out, "X");
1003 }
1004
1005 #[test]
1006 fn non_truncatable_drops_unchanged_under_pressure() {
1007 let items = vec![item("45% · 200k", 200), item("Sonnet", 0)];
1011 let out = render_plain(items, 10);
1013 assert_eq!(out, "Sonnet");
1014 }
1015
1016 #[test]
1017 fn reflow_iterates_when_first_truncation_insufficient() {
1018 let items = vec![
1022 truncatable_item("aaaaaaaaaa", 100),
1023 truncatable_item("bbbbbbbbbb", 100),
1024 item("KEEP", 0),
1025 ];
1026 let out = render_plain(items, 12);
1032 assert_eq!(out, "aaaaaa… KEEP");
1033 assert_eq!(text_width(&out), 12);
1034 }
1035
1036 #[test]
1037 fn reflow_does_not_touch_priority_zero_even_when_truncatable() {
1038 let items = vec![
1041 Item {
1042 rendered: RenderedSegment::new("untouchable-long-name"),
1043 defaults: SegmentDefaults::with_priority(0).with_truncatable(true),
1044 segment: noop_segment(),
1045 },
1046 item("Sonnet", 0),
1047 ];
1048 let out = render_plain(items, 5);
1049 assert_eq!(out, "untouchable-long-name Sonnet");
1050 }
1051
1052 struct ShrinkableSegment {
1059 full: &'static str,
1060 compact: &'static str,
1061 }
1062 impl Segment for ShrinkableSegment {
1063 fn render(&self, _ctx: &DataContext, _rc: &RenderContext) -> RenderResult {
1064 Ok(Some(RenderedSegment::new(self.full)))
1065 }
1066 fn shrink_to_fit(
1067 &self,
1068 _ctx: &DataContext,
1069 _rc: &RenderContext,
1070 target: u16,
1071 ) -> Option<RenderedSegment> {
1072 let r = RenderedSegment::new(self.compact);
1073 (r.width <= target).then_some(r)
1074 }
1075 fn defaults(&self) -> SegmentDefaults {
1076 SegmentDefaults::with_priority(200)
1077 }
1078 }
1079
1080 struct AnchorSegment(&'static str);
1084 impl Segment for AnchorSegment {
1085 fn render(&self, _ctx: &DataContext, _rc: &RenderContext) -> RenderResult {
1086 Ok(Some(RenderedSegment::new(self.0)))
1087 }
1088 fn defaults(&self) -> SegmentDefaults {
1089 SegmentDefaults::with_priority(0)
1090 }
1091 }
1092
1093 #[test]
1094 fn shrink_to_fit_replaces_full_render_when_compact_form_fits() {
1095 let segments: Vec<Box<dyn Segment>> = vec![
1101 Box::new(ShrinkableSegment {
1102 full: "longbranch * ↑2 ↓1",
1103 compact: "longbranch",
1104 }),
1105 Box::new(AnchorSegment("KEEP")),
1106 ];
1107 let mut warnings = Vec::new();
1108 let line = render_with_warn(
1109 &segments,
1110 &empty_ctx(),
1111 17,
1112 &mut |m| warnings.push(m.to_string()),
1113 theme::default_theme(),
1114 theme::Capability::None,
1115 false,
1116 );
1117 assert_eq!(line, "longbranch KEEP");
1121 }
1122
1123 #[test]
1124 fn shrink_to_fit_falls_back_to_drop_when_compact_form_too_wide() {
1125 let segments: Vec<Box<dyn Segment>> = vec![
1128 Box::new(ShrinkableSegment {
1129 full: "longbranch",
1130 compact: "stilltoolongtruly",
1131 }),
1132 Box::new(AnchorSegment("X")),
1133 ];
1134 let mut warnings = Vec::new();
1135 let line = render_with_warn(
1136 &segments,
1137 &empty_ctx(),
1138 5,
1139 &mut |m| warnings.push(m.to_string()),
1140 theme::default_theme(),
1141 theme::Capability::None,
1142 false,
1143 );
1144 assert_eq!(line, "X");
1147 }
1148
1149 #[test]
1150 fn shrink_to_fit_honors_configured_width_min_floor() {
1151 struct LowFloorShrink;
1158 impl Segment for LowFloorShrink {
1159 fn render(&self, _: &DataContext, _: &RenderContext) -> RenderResult {
1160 Ok(Some(RenderedSegment::new("longerprefix")))
1161 }
1162 fn shrink_to_fit(
1163 &self,
1164 _: &DataContext,
1165 _: &RenderContext,
1166 _target: u16,
1167 ) -> Option<RenderedSegment> {
1168 Some(RenderedSegment::new("five5"))
1169 }
1170 fn defaults(&self) -> SegmentDefaults {
1171 SegmentDefaults::with_priority(200)
1172 .with_width(WidthBounds::new(8, u16::MAX).expect("valid"))
1173 }
1174 }
1175 let segments: Vec<Box<dyn Segment>> =
1176 vec![Box::new(LowFloorShrink), Box::new(AnchorSegment("X"))];
1177 let line = render_with_warn(
1178 &segments,
1179 &empty_ctx(),
1180 7,
1181 &mut |_| {},
1182 theme::default_theme(),
1183 theme::Capability::None,
1184 false,
1185 );
1186 assert_eq!(line, "X");
1189 }
1190
1191 #[test]
1192 fn shrink_to_fit_rejects_too_wide_response_and_drops() {
1193 struct MisbehavingSegment;
1203 impl Segment for MisbehavingSegment {
1204 fn render(&self, _: &DataContext, _: &RenderContext) -> RenderResult {
1205 Ok(Some(RenderedSegment::new("longbranch")))
1206 }
1207 fn shrink_to_fit(
1208 &self,
1209 _: &DataContext,
1210 _: &RenderContext,
1211 _target: u16,
1212 ) -> Option<RenderedSegment> {
1213 Some(RenderedSegment::new("stilltoolongtruly"))
1214 }
1215 fn defaults(&self) -> SegmentDefaults {
1216 SegmentDefaults::with_priority(200)
1217 }
1218 }
1219 let segments: Vec<Box<dyn Segment>> =
1220 vec![Box::new(MisbehavingSegment), Box::new(AnchorSegment("X"))];
1221 let line = render_with_warn(
1222 &segments,
1223 &empty_ctx(),
1224 5,
1225 &mut |_| {},
1226 theme::default_theme(),
1227 theme::Capability::None,
1228 false,
1229 );
1230 assert_eq!(line, "X");
1231 }
1232
1233 #[test]
1234 fn shrink_to_fit_runs_before_truncatable_end_ellipsis() {
1235 struct DualSegment;
1239 impl Segment for DualSegment {
1240 fn render(&self, _ctx: &DataContext, _rc: &RenderContext) -> RenderResult {
1241 Ok(Some(RenderedSegment::new("longprefix-with-tail")))
1242 }
1243 fn shrink_to_fit(
1244 &self,
1245 _ctx: &DataContext,
1246 _rc: &RenderContext,
1247 target: u16,
1248 ) -> Option<RenderedSegment> {
1249 let r = RenderedSegment::new("longprefix");
1250 (r.width <= target).then_some(r)
1251 }
1252 fn defaults(&self) -> SegmentDefaults {
1253 SegmentDefaults::with_priority(200).with_truncatable(true)
1254 }
1255 }
1256 let segments: Vec<Box<dyn Segment>> = vec![
1257 Box::new(DualSegment),
1258 Box::new(StubSegment(Ok(Some(RenderedSegment::new("X"))))),
1259 ];
1260 let mut warnings = Vec::new();
1261 let line = render_with_warn(
1262 &segments,
1263 &empty_ctx(),
1264 13,
1265 &mut |m| warnings.push(m.to_string()),
1266 theme::default_theme(),
1267 theme::Capability::None,
1268 false,
1269 );
1270 assert!(line.contains("longprefix"), "got {line:?}");
1275 assert!(!line.contains('…'), "no end-ellipsis: {line:?}");
1276 }
1277
1278 #[test]
1281 fn render_to_runs_empty_input_yields_no_runs() {
1282 let segments: Vec<Box<dyn Segment>> = vec![];
1283 let runs = render_to_runs(&segments, &empty_ctx(), 100, &mut |_| {});
1284 assert!(runs.is_empty());
1285 }
1286
1287 #[test]
1288 fn render_to_runs_emits_segment_then_separator_then_segment() {
1289 let segments: Vec<Box<dyn Segment>> = vec![
1292 Box::new(StubSegment(Ok(Some(RenderedSegment::new("a"))))),
1293 Box::new(StubSegment(Ok(Some(RenderedSegment::new("b"))))),
1294 ];
1295 let runs = render_to_runs(&segments, &empty_ctx(), 100, &mut |_| {});
1296 assert_eq!(runs.len(), 3);
1297 assert_eq!(runs[0].text, "a");
1298 assert_eq!(runs[0].style, Style::default());
1299 assert_eq!(runs[1].text, " ");
1300 assert_eq!(runs[1].style, Style::default());
1301 assert_eq!(runs[2].text, "b");
1302 assert_eq!(runs[2].style, Style::default());
1303 }
1304
1305 #[test]
1306 fn render_to_runs_preserves_segment_style() {
1307 use crate::theme::Role;
1311 let segments: Vec<Box<dyn Segment>> = vec![
1312 Box::new(StubSegment(Ok(Some(RenderedSegment::new("plain"))))),
1313 Box::new(StubSegment(Ok(Some(
1314 RenderedSegment::new("warn").with_role(Role::Warning),
1315 )))),
1316 ];
1317 let runs = render_to_runs(&segments, &empty_ctx(), 100, &mut |_| {});
1318 assert_eq!(runs.len(), 3);
1319 assert_eq!(runs[2].text, "warn");
1320 assert_eq!(runs[2].style.role, Some(Role::Warning));
1321 }
1322
1323 #[test]
1324 fn render_to_runs_skips_separator_none_between_segments() {
1325 let segments: Vec<Box<dyn Segment>> = vec![
1328 Box::new(StubSegment(Ok(Some(RenderedSegment::with_separator(
1329 "a",
1330 Separator::None,
1331 ))))),
1332 Box::new(StubSegment(Ok(Some(RenderedSegment::new("b"))))),
1333 ];
1334 let runs = render_to_runs(&segments, &empty_ctx(), 100, &mut |_| {});
1335 assert_eq!(runs.len(), 2);
1336 assert_eq!(runs[0].text, "a");
1337 assert_eq!(runs[1].text, "b");
1338 }
1339
1340 #[test]
1341 fn render_to_runs_drops_segments_under_width_pressure() {
1342 let segments: Vec<Box<dyn Segment>> = vec![
1346 Box::new(StubSegment(Ok(Some(
1347 RenderedSegment::new("keep").with_role(crate::theme::Role::Primary),
1348 )))),
1349 Box::new(DroppableStub("droppable")),
1350 Box::new(StubSegment(Ok(Some(RenderedSegment::new("anchor"))))),
1351 ];
1352 let runs = render_to_runs(&segments, &empty_ctx(), 12, &mut |_| {});
1355 let texts: Vec<&str> = runs.iter().map(|r| r.text.as_str()).collect();
1356 assert_eq!(texts, vec!["keep", " ", "anchor"]);
1357 }
1358
1359 fn round_trip_segments() -> Vec<Box<dyn Segment>> {
1363 use crate::theme::Role;
1364 vec![
1365 Box::new(StubSegment(Ok(Some(
1366 RenderedSegment::new("ctx").with_role(Role::Info),
1367 )))),
1368 Box::new(StubSegment(Ok(Some(RenderedSegment::new("|"))))),
1369 Box::new(StubSegment(Ok(Some(
1370 RenderedSegment::new("err").with_role(Role::Error),
1371 )))),
1372 ]
1373 }
1374
1375 fn round_trip_assert(terminal_width: u16, capability: theme::Capability, hyperlinks: bool) {
1376 let segments = round_trip_segments();
1377 let direct = render_with_warn(
1378 &segments,
1379 &empty_ctx(),
1380 terminal_width,
1381 &mut |_| {},
1382 theme::default_theme(),
1383 capability,
1384 hyperlinks,
1385 );
1386 let runs = render_to_runs(&segments, &empty_ctx(), terminal_width, &mut |_| {});
1387 let recomposed = runs_to_ansi(&runs, theme::default_theme(), capability, hyperlinks);
1388 assert_eq!(
1389 direct, recomposed,
1390 "cap={capability:?} width={terminal_width} hyperlinks={hyperlinks}"
1391 );
1392 }
1393
1394 #[test]
1395 fn render_to_runs_then_runs_to_ansi_matches_render_with_warn() {
1396 round_trip_assert(100, theme::Capability::Palette16, false);
1400 }
1401
1402 #[test]
1403 fn render_to_runs_round_trip_holds_under_capability_none() {
1404 round_trip_assert(100, theme::Capability::None, false);
1409 }
1410
1411 #[test]
1412 fn render_to_runs_round_trip_holds_under_width_pressure() {
1413 round_trip_assert(5, theme::Capability::Palette16, false);
1418 }
1419
1420 #[test]
1421 fn render_to_runs_round_trip_holds_with_hyperlinks_enabled() {
1422 round_trip_assert(100, theme::Capability::Palette16, true);
1429 }
1430
1431 #[test]
1432 fn render_to_runs_with_one_survivor_emits_no_trailing_separator() {
1433 let segments: Vec<Box<dyn Segment>> = vec![
1437 Box::new(StubSegment(Ok(Some(RenderedSegment::new("a"))))),
1438 Box::new(DroppableStub("droppable")),
1439 ];
1440 let runs = render_to_runs(&segments, &empty_ctx(), 1, &mut |_| {});
1443 assert_eq!(runs.len(), 1);
1444 assert_eq!(runs[0].text, "a");
1445 }
1446
1447 #[test]
1448 fn render_to_runs_emits_powerline_chevron_with_muted_role() {
1449 use crate::theme::Role;
1453 struct PowerlineSeg;
1454 impl Segment for PowerlineSeg {
1455 fn render(&self, _: &DataContext, _: &RenderContext) -> RenderResult {
1456 Ok(Some(RenderedSegment::new("a").with_role(Role::Primary)))
1457 }
1458 fn defaults(&self) -> SegmentDefaults {
1459 SegmentDefaults::with_priority(10).with_default_separator(Separator::powerline())
1460 }
1461 }
1462 let segments: Vec<Box<dyn Segment>> = vec![
1463 Box::new(PowerlineSeg),
1464 Box::new(StubSegment(Ok(Some(RenderedSegment::new("b"))))),
1465 ];
1466 let runs = render_to_runs(&segments, &empty_ctx(), 100, &mut |_| {});
1467 assert_eq!(runs.len(), 3);
1468 assert_eq!(runs[1].text, " \u{E0B0} ");
1469 assert_eq!(runs[1].style.role, Some(Role::Muted));
1470 }
1471
1472 #[test]
1473 fn powerline_separator_emits_padded_chevron_with_correct_width() {
1474 assert_eq!(Separator::powerline().width(), 3);
1481 assert_eq!(Separator::powerline().text(), " \u{E0B0} ");
1482 }
1483
1484 #[test]
1485 fn powerline_chevrons_are_charged_to_total_width_in_layout() {
1486 let item = |text: &str| Item {
1493 rendered: RenderedSegment::new(text),
1494 defaults: SegmentDefaults::with_priority(0)
1495 .with_default_separator(Separator::powerline()),
1496 segment: noop_segment(),
1497 };
1498 let items = vec![item("aaaa"), item("bbbb"), item("cccc")];
1499 let chev = u32::from(Separator::powerline().width());
1500 assert_eq!(total_width(&items), 4 + chev + 4 + chev + 4);
1501 }
1502
1503 #[test]
1504 fn render_with_warn_emits_powerline_chevron_wrapped_in_muted_sgr() {
1505 struct PowerlineSeg(&'static str, theme::Role);
1514 impl Segment for PowerlineSeg {
1515 fn render(&self, _: &DataContext, _: &RenderContext) -> RenderResult {
1516 Ok(Some(RenderedSegment::new(self.0).with_role(self.1)))
1517 }
1518 fn defaults(&self) -> SegmentDefaults {
1519 SegmentDefaults::with_priority(10).with_default_separator(Separator::powerline())
1520 }
1521 }
1522 let segments: Vec<Box<dyn Segment>> = vec![
1523 Box::new(PowerlineSeg("a", theme::Role::Primary)),
1524 Box::new(PowerlineSeg("b", theme::Role::Info)),
1525 ];
1526 let line = render_with_warn(
1527 &segments,
1528 &empty_ctx(),
1529 100,
1530 &mut |_| {},
1531 theme::default_theme(),
1532 theme::Capability::Palette16,
1533 false,
1534 );
1535 let muted_sgr = theme::sgr_open(
1536 &Style::role(theme::Role::Muted),
1537 theme::default_theme(),
1538 theme::Capability::Palette16,
1539 );
1540 let expected = format!("{muted_sgr} \u{E0B0} \x1b[0m");
1541 assert!(
1542 line.contains(&expected),
1543 "padded chevron with Muted SGR not in line: {line:?} (expected substring: {expected:?})"
1544 );
1545 }
1546
1547 #[test]
1548 fn render_to_runs_emits_literal_separator_with_default_style() {
1549 struct PipeSepSegment;
1553 impl Segment for PipeSepSegment {
1554 fn render(&self, _: &DataContext, _: &RenderContext) -> RenderResult {
1555 Ok(Some(
1556 RenderedSegment::new("a").with_role(crate::theme::Role::Warning),
1557 ))
1558 }
1559 fn defaults(&self) -> SegmentDefaults {
1560 SegmentDefaults::with_priority(10)
1561 .with_default_separator(Separator::Literal(Cow::Borrowed(" | ")))
1562 }
1563 }
1564 let segments: Vec<Box<dyn Segment>> = vec![
1565 Box::new(PipeSepSegment),
1566 Box::new(StubSegment(Ok(Some(RenderedSegment::new("b"))))),
1567 ];
1568 let runs = render_to_runs(&segments, &empty_ctx(), 100, &mut |_| {});
1569 assert_eq!(runs.len(), 3);
1570 assert_eq!(runs[1].text, " | ");
1571 assert_eq!(runs[1].style, Style::default());
1572 }
1573
1574 #[test]
1575 fn runs_to_ansi_emits_osc8_around_styled_run_when_hyperlinks_supported() {
1576 use crate::theme::Role;
1581 let runs = vec![StyledRun::new(
1582 "branch",
1583 Style::role(Role::Primary).with_hyperlink("https://example.com/b"),
1584 )];
1585 let out = runs_to_ansi(
1586 &runs,
1587 theme::default_theme(),
1588 theme::Capability::Palette16,
1589 true,
1590 );
1591 assert_eq!(
1592 out, "\x1b]8;;https://example.com/b\x1b\\\x1b[95mbranch\x1b[0m\x1b]8;;\x1b\\",
1593 "got {out:?}"
1594 );
1595 }
1596
1597 #[test]
1598 fn runs_to_ansi_drops_hyperlink_when_not_supported() {
1599 use crate::theme::Role;
1604 let runs = vec![StyledRun::new(
1605 "branch",
1606 Style::role(Role::Primary).with_hyperlink("https://example.com/b"),
1607 )];
1608 let out = runs_to_ansi(
1609 &runs,
1610 theme::default_theme(),
1611 theme::Capability::Palette16,
1612 false,
1613 );
1614 assert_eq!(out, "\x1b[95mbranch\x1b[0m");
1615 assert!(!out.contains("\x1b]8"), "no OSC 8: {out:?}");
1616 }
1617
1618 #[test]
1619 fn runs_to_ansi_emits_no_osc8_when_style_has_no_hyperlink() {
1620 let runs = vec![StyledRun::new("plain", Style::default())];
1624 let out = runs_to_ansi(&runs, theme::default_theme(), theme::Capability::None, true);
1625 assert_eq!(out, "plain");
1626 assert!(!out.contains("\x1b]8"), "no OSC 8: {out:?}");
1627 }
1628
1629 #[test]
1630 fn runs_to_ansi_emits_osc8_around_unstyled_run() {
1631 let runs = vec![StyledRun::new(
1635 "click",
1636 Style::default().with_hyperlink("https://example.com"),
1637 )];
1638 let out = runs_to_ansi(&runs, theme::default_theme(), theme::Capability::None, true);
1639 assert_eq!(out, "\x1b]8;;https://example.com\x1b\\click\x1b]8;;\x1b\\");
1640 }
1641
1642 #[test]
1643 fn osc8_pair_balanced_when_hyperlinked_run_is_truncated() {
1644 let mut rendered = RenderedSegment::new("very-long-branch-name")
1651 .with_style(Style::default().with_hyperlink("https://example.com/branch"));
1652 rendered = truncate_to(rendered, 8);
1653 let runs = vec![StyledRun::new(
1655 rendered.text().to_string(),
1656 rendered.style.clone(),
1657 )];
1658 let out = runs_to_ansi(&runs, theme::default_theme(), theme::Capability::None, true);
1659 assert!(
1660 out.starts_with("\x1b]8;;https://example.com/branch\x1b\\"),
1661 "OSC 8 open present: {out:?}"
1662 );
1663 assert!(
1664 out.ends_with("\x1b]8;;\x1b\\"),
1665 "OSC 8 close present: {out:?}"
1666 );
1667 assert!(out.contains('…'), "truncation marker preserved: {out:?}");
1668 assert_eq!(
1669 out.matches("\x1b]8;;").count(),
1670 2,
1671 "exactly one open and one close: {out:?}"
1672 );
1673 }
1674
1675 #[test]
1676 fn osc8_pair_balanced_when_hyperlinked_run_truncated_to_zero() {
1677 let rendered = RenderedSegment::new("anything")
1681 .with_style(Style::default().with_hyperlink("https://example.com"));
1682 let truncated = truncate_to(rendered, 0);
1683 let runs = vec![StyledRun::new(
1684 truncated.text().to_string(),
1685 truncated.style.clone(),
1686 )];
1687 let out = runs_to_ansi(&runs, theme::default_theme(), theme::Capability::None, true);
1688 assert_eq!(
1689 out, "\x1b]8;;https://example.com\x1b\\\x1b]8;;\x1b\\",
1690 "empty-text run still emits balanced OSC 8 pair: {out:?}"
1691 );
1692 }
1693
1694 #[test]
1695 fn runs_to_ansi_emits_independent_osc8_pairs_for_adjacent_hyperlinked_runs() {
1696 let runs = vec![
1700 StyledRun::new("a", Style::default().with_hyperlink("https://a.example")),
1701 StyledRun::new("b", Style::default().with_hyperlink("https://b.example")),
1702 ];
1703 let out = runs_to_ansi(&runs, theme::default_theme(), theme::Capability::None, true);
1704 assert_eq!(
1705 out,
1706 "\x1b]8;;https://a.example\x1b\\a\x1b]8;;\x1b\\\x1b]8;;https://b.example\x1b\\b\x1b]8;;\x1b\\"
1707 );
1708 assert_eq!(out.matches("\x1b]8;;").count(), 4, "two opens + two closes");
1709 }
1710
1711 #[test]
1712 fn push_osc8_open_strips_control_chars_from_url() {
1713 let runs = vec![StyledRun::new(
1720 "x",
1721 Style::default().with_hyperlink("https://example.com\x1b\\evil\x07more"),
1722 )];
1723 let out = runs_to_ansi(&runs, theme::default_theme(), theme::Capability::None, true);
1724 assert_eq!(
1727 out.matches("\x1b]8;;").count(),
1728 2,
1729 "exactly one pair: {out:?}"
1730 );
1731 assert!(!out.contains("\x1b\\evil"), "ESC \\ stripped: {out:?}");
1732 assert!(!out.contains('\x07'), "BEL stripped: {out:?}");
1733 assert!(
1734 out.contains("https://example.com\\evilmore"),
1735 "non-control chars survive: {out:?}"
1736 );
1737 }
1738
1739 #[test]
1740 fn push_osc8_open_strips_c1_string_terminator_and_nul() {
1741 let runs = vec![StyledRun::new(
1748 "x",
1749 Style::default().with_hyperlink("https://a.example\x00b\x7fc\u{009C}d"),
1750 )];
1751 let out = runs_to_ansi(&runs, theme::default_theme(), theme::Capability::None, true);
1752 assert_eq!(out.matches("\x1b]8;;").count(), 2, "single pair: {out:?}");
1753 assert!(!out.contains('\x00'), "NUL stripped: {out:?}");
1754 assert!(!out.contains('\x7f'), "DEL stripped: {out:?}");
1755 assert!(!out.contains('\u{009C}'), "C1 ST stripped: {out:?}");
1756 assert!(out.contains("https://a.examplebcd"));
1757 }
1758
1759 #[test]
1760 fn runs_to_ansi_capability_none_emits_unwrapped_text() {
1761 use crate::theme::Role;
1766 let runs = vec![
1767 StyledRun::new("plain", Style::default()),
1768 StyledRun::new(" ", Style::default()),
1769 StyledRun::new("warn", Style::role(Role::Warning)),
1770 ];
1771 let out = runs_to_ansi(
1772 &runs,
1773 theme::default_theme(),
1774 theme::Capability::None,
1775 false,
1776 );
1777 assert_eq!(out, "plain warn");
1778 assert!(!out.contains('\x1b'), "unexpected ANSI escape: {out:?}");
1779 }
1780
1781 struct DroppableStub(&'static str);
1785 impl Segment for DroppableStub {
1786 fn render(&self, _: &DataContext, _: &RenderContext) -> RenderResult {
1787 Ok(Some(RenderedSegment::new(self.0)))
1788 }
1789 fn defaults(&self) -> SegmentDefaults {
1790 SegmentDefaults::with_priority(200)
1791 }
1792 }
1793}