use crate::data_context::DataContext;
use crate::segments::{
text_width, RenderContext, RenderedSegment, Segment, SegmentDefaults, Separator, WidthBounds,
};
use crate::theme::{self, Capability, Style, StyledRun, Theme};
use unicode_segmentation::UnicodeSegmentation;
#[must_use]
pub fn render(segments: &[Box<dyn Segment>], ctx: &DataContext, terminal_width: u16) -> String {
let mut warn = |msg: &str| crate::lsm_error!("{msg}");
render_with_warn(
segments,
ctx,
terminal_width,
&mut warn,
theme::default_theme(),
Capability::None,
false,
)
}
#[must_use]
pub fn render_with_warn(
segments: &[Box<dyn Segment>],
ctx: &DataContext,
terminal_width: u16,
warn: &mut dyn FnMut(&str),
theme: &Theme,
capability: Capability,
hyperlinks: bool,
) -> String {
let runs = render_to_runs(segments, ctx, terminal_width, warn);
runs_to_ansi(&runs, theme, capability, hyperlinks)
}
#[must_use]
pub fn render_to_runs(
segments: &[Box<dyn Segment>],
ctx: &DataContext,
terminal_width: u16,
warn: &mut dyn FnMut(&str),
) -> Vec<StyledRun> {
let rc = RenderContext::new(terminal_width);
let items = collect_items_with(segments, ctx, &rc, warn);
let laid_out = apply_layout(items, ctx, &rc, terminal_width);
items_to_runs(&laid_out)
}
#[must_use]
pub fn runs_to_ansi(
runs: &[StyledRun],
theme: &Theme,
capability: Capability,
hyperlinks: bool,
) -> String {
let mut out = String::new();
for run in runs {
let link = run.style.hyperlink.as_deref().filter(|_| hyperlinks);
if let Some(url) = link {
push_osc8_open(&mut out, url);
}
let open = theme::sgr_open(&run.style, theme, capability);
if open.is_empty() {
out.push_str(&run.text);
} else {
out.push_str(&open);
out.push_str(&run.text);
out.push_str(theme::sgr_reset());
}
if link.is_some() {
push_osc8_close(&mut out);
}
}
out
}
fn push_osc8_open(out: &mut String, url: &str) {
out.push_str("\x1b]8;;");
for c in url.chars() {
if !c.is_control() {
out.push(c);
}
}
out.push_str("\x1b\\");
}
fn push_osc8_close(out: &mut String) {
out.push_str("\x1b]8;;\x1b\\");
}
struct Item<'a> {
rendered: RenderedSegment,
defaults: SegmentDefaults,
segment: &'a dyn Segment,
}
fn collect_items_with<'a>(
segments: &'a [Box<dyn Segment>],
ctx: &DataContext,
rc: &RenderContext,
warn: &mut dyn FnMut(&str),
) -> Vec<Item<'a>> {
segments
.iter()
.filter_map(|seg| {
let defaults = seg.defaults();
let rendered = match seg.render(ctx, rc) {
Ok(Some(r)) => r,
Ok(None) => return None,
Err(err) => {
warn(&format!("segment error: {err}"));
return None;
}
};
apply_width_bounds(rendered, defaults.width).map(|r| Item {
rendered: r,
defaults,
segment: seg.as_ref(),
})
})
.collect()
}
fn apply_layout<'a>(
mut items: Vec<Item<'a>>,
ctx: &DataContext,
rc: &RenderContext,
terminal_width: u16,
) -> Vec<Item<'a>> {
let budget = u32::from(terminal_width);
loop {
let total = total_width(&items);
if total <= budget {
break;
}
let Some(drop_idx) = items
.iter()
.enumerate()
.filter(|(_, item)| item.defaults.priority > 0)
.max_by_key(|(_, item)| item.defaults.priority)
.map(|(i, _)| i)
else {
break;
};
let overflow = total - budget;
if let Some(shrunk) = try_shrink(&items[drop_idx], ctx, rc, overflow) {
items[drop_idx].rendered = shrunk;
continue;
}
if items[drop_idx].defaults.truncatable {
if let Some(reflowed) = try_reflow(&items[drop_idx], overflow) {
items[drop_idx] = reflowed;
continue;
}
}
items.remove(drop_idx);
}
items
}
#[cfg(test)]
fn render_items(
items: Vec<Item<'_>>,
ctx: &DataContext,
rc: &RenderContext,
terminal_width: u16,
theme: &Theme,
capability: Capability,
) -> String {
let laid_out = apply_layout(items, ctx, rc, terminal_width);
let runs = items_to_runs(&laid_out);
runs_to_ansi(&runs, theme, capability, false)
}
fn items_to_runs(items: &[Item<'_>]) -> Vec<StyledRun> {
let mut runs = Vec::with_capacity(items.len().saturating_mul(2));
for (i, item) in items.iter().enumerate() {
runs.push(StyledRun {
text: item.rendered.text.clone(),
style: item.rendered.style.clone(),
});
if i + 1 < items.len() {
let sep = effective_separator(item);
let sep_text = sep.text();
if !sep_text.is_empty() {
runs.push(StyledRun {
text: sep_text.to_string(),
style: separator_style(sep),
});
}
}
}
runs
}
fn separator_style(sep: &Separator) -> Style {
match sep {
Separator::Powerline { .. } => Style::role(theme::Role::Muted),
_ => Style::default(),
}
}
fn total_width(items: &[Item<'_>]) -> u32 {
if items.is_empty() {
return 0;
}
let seg_sum: u32 = items.iter().map(|i| u32::from(i.rendered.width)).sum();
let sep_sum: u32 = items
.iter()
.take(items.len() - 1)
.map(|item| u32::from(effective_separator(item).width()))
.sum();
seg_sum + sep_sum
}
fn effective_separator<'i>(item: &'i Item<'_>) -> &'i Separator {
item.rendered
.right_separator
.as_ref()
.unwrap_or(&item.defaults.default_separator)
}
fn apply_width_bounds(
rendered: RenderedSegment,
bounds: Option<WidthBounds>,
) -> Option<RenderedSegment> {
let Some(bounds) = bounds else {
return Some(rendered);
};
if rendered.width < bounds.min() {
return None;
}
if rendered.width > bounds.max() {
return Some(truncate_to(rendered, bounds.max()));
}
Some(rendered)
}
fn try_reflow<'a>(item: &Item<'a>, overflow: u32) -> Option<Item<'a>> {
let floor = item.defaults.width.map_or(2, |b| b.min().max(2));
let cur = item.rendered.width;
let target = u32::from(cur).checked_sub(overflow)?;
let target_u16 = u16::try_from(target).ok()?;
if target_u16 < floor {
return None;
}
let truncated = truncate_to(item.rendered.clone(), target_u16);
if truncated.width < floor {
return None;
}
Some(Item {
rendered: truncated,
defaults: item.defaults.clone(),
segment: item.segment,
})
}
fn try_shrink(
item: &Item<'_>,
ctx: &DataContext,
rc: &RenderContext,
overflow: u32,
) -> Option<RenderedSegment> {
let cur = item.rendered.width;
let target = u16::try_from(u32::from(cur).checked_sub(overflow)?).ok()?;
let min_floor = item.defaults.width.map_or(0, |b| b.min());
if target < min_floor {
return None;
}
let shrunk = item.segment.shrink_to_fit(ctx, rc, target)?;
if shrunk.width > target {
crate::lsm_warn!(
"segment shrink_to_fit returned width {} > target {}; rejecting",
shrunk.width,
target,
);
return None;
}
if shrunk.width < min_floor {
return None;
}
Some(shrunk)
}
pub(crate) fn truncate_to(rendered: RenderedSegment, max_cells: u16) -> RenderedSegment {
if max_cells == 0 {
return RenderedSegment::from_parts(
String::new(),
0,
rendered.right_separator,
rendered.style,
);
}
let budget = max_cells.saturating_sub(1);
let mut out = String::new();
let mut used: u16 = 0;
for cluster in rendered.text.graphemes(true) {
let w = text_width(cluster);
if used.saturating_add(w) > budget {
break;
}
out.push_str(cluster);
used = used.saturating_add(w);
}
out.push('…');
RenderedSegment::from_parts(
out,
used.saturating_add(1),
rendered.right_separator,
rendered.style,
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::input::{ModelInfo, StatusContext, Tool, WorkspaceInfo};
use crate::theme;
use std::borrow::Cow;
use std::path::PathBuf;
use std::sync::Arc;
struct NoopSegment;
impl Segment for NoopSegment {
fn render(&self, _ctx: &DataContext, _rc: &RenderContext) -> RenderResult {
Ok(None)
}
}
static NOOP: NoopSegment = NoopSegment;
fn noop_segment() -> &'static dyn Segment {
&NOOP
}
fn empty_ctx() -> DataContext {
DataContext::new(StatusContext {
tool: Tool::ClaudeCode,
model: Some(ModelInfo {
display_name: "X".into(),
}),
workspace: Some(WorkspaceInfo {
project_dir: PathBuf::from("/"),
git_worktree: None,
}),
context_window: None,
cost: None,
effort: None,
vim: None,
output_style: None,
agent_name: None,
version: None,
raw: Arc::new(serde_json::Value::Null),
})
}
fn empty_rc() -> RenderContext {
RenderContext::new(80)
}
fn item(text: &str, priority: u8) -> Item<'static> {
Item {
rendered: RenderedSegment::new(text),
defaults: SegmentDefaults::with_priority(priority),
segment: noop_segment(),
}
}
fn render_plain(items: Vec<Item<'_>>, terminal_width: u16) -> String {
render_items(
items,
&empty_ctx(),
&empty_rc(),
terminal_width,
theme::default_theme(),
theme::Capability::None,
)
}
#[test]
fn render_items_wraps_each_styled_segment_under_palette16() {
use crate::theme::Role;
let items = vec![
Item {
rendered: RenderedSegment::new("a"),
defaults: SegmentDefaults::with_priority(10),
segment: noop_segment(),
},
Item {
rendered: RenderedSegment::new("b").with_role(Role::Warning),
defaults: SegmentDefaults::with_priority(10),
segment: noop_segment(),
},
Item {
rendered: RenderedSegment::new("c"),
defaults: SegmentDefaults::with_priority(10),
segment: noop_segment(),
},
];
let out = render_items(
items,
&empty_ctx(),
&empty_rc(),
100,
theme::default_theme(),
theme::Capability::Palette16,
);
assert_eq!(out, "a \x1b[93mb\x1b[0m c");
}
#[test]
fn total_width_counts_inter_segment_separators_only() {
let items = vec![item("ab", 10), item("cd", 10), item("ef", 10)];
assert_eq!(total_width(&items), 8);
}
#[test]
fn total_width_zero_for_empty() {
assert_eq!(total_width(&[]), 0);
}
#[test]
fn total_width_single_segment_has_no_separator() {
let items = vec![item("abcde", 10)];
assert_eq!(total_width(&items), 5);
}
#[test]
fn no_width_pressure_renders_all_with_separators() {
let items = vec![item("one", 10), item("two", 20), item("three", 30)];
assert_eq!(render_plain(items, 100), "one two three");
}
#[test]
fn drops_highest_priority_under_pressure() {
let items = vec![
item("aaaa", 10),
item("bbbb", 200), item("cccc", 50),
];
let out = render_plain(items, 10);
assert!(!out.contains("bbbb"));
assert!(out.contains("aaaa"));
assert!(out.contains("cccc"));
}
#[test]
fn drops_in_descending_priority_order() {
let items = vec![
item("one", 10),
item("two", 200), item("three", 20),
item("four", 150), item("five", 30),
];
assert_eq!(render_plain(items, 15), "one three five");
}
#[test]
fn priority_zero_never_drops_even_over_budget() {
let items = vec![item("aaaa", 0), item("bbbb", 0)];
let out = render_plain(items, 3);
assert_eq!(out, "aaaa bbbb");
}
#[test]
fn priority_drop_recomputes_budget_with_powerline_separators() {
let item_pl = |text: &'static str, priority: u8| Item {
rendered: RenderedSegment::new(text),
defaults: SegmentDefaults::with_priority(priority)
.with_default_separator(Separator::powerline()),
segment: noop_segment(),
};
let items = vec![item_pl("aaaa", 0), item_pl("bbbb", 200), item_pl("cccc", 0)];
let out = render_plain(items, 14);
assert!(out.contains("aaaa"));
assert!(!out.contains("bbbb"));
assert!(out.contains("cccc"));
assert!(
out.contains('\u{E0B0}'),
"chevron survives the drop: {out:?}"
);
}
#[test]
fn mix_drops_positives_keeps_zeros() {
let items = vec![
item("keep-me", 0),
item("droppable", 200),
item("sticky", 0),
];
let out = render_plain(items, 20);
assert_eq!(out, "keep-me sticky");
}
#[test]
fn no_trailing_separator() {
let items = vec![item("a", 10), item("b", 10)];
assert_eq!(render_plain(items, 100), "a b");
}
#[test]
fn empty_input_renders_empty_string() {
assert_eq!(render_plain(vec![], 100), "");
}
#[test]
fn respects_custom_separator_from_defaults() {
let items = vec![
Item {
rendered: RenderedSegment::new("a"),
defaults: SegmentDefaults {
priority: 10,
width: None,
default_separator: Separator::Literal(Cow::Borrowed(" | ")),
truncatable: false,
},
segment: noop_segment(),
},
Item {
rendered: RenderedSegment::new("b"),
defaults: SegmentDefaults::with_priority(10),
segment: noop_segment(),
},
];
assert_eq!(render_plain(items, 100), "a | b");
}
#[test]
fn render_override_separator_beats_default() {
let items = vec![
Item {
rendered: RenderedSegment::with_separator("a", Separator::None),
defaults: SegmentDefaults::with_priority(10),
segment: noop_segment(),
},
Item {
rendered: RenderedSegment::new("b"),
defaults: SegmentDefaults::with_priority(10),
segment: noop_segment(),
},
];
assert_eq!(render_plain(items, 100), "ab");
}
#[test]
fn apply_width_bounds_drops_below_min() {
let bounds = WidthBounds::new(5, 10);
let rendered = RenderedSegment::new("abc"); assert!(apply_width_bounds(rendered, bounds).is_none());
}
#[test]
fn apply_width_bounds_truncates_above_max() {
let bounds = WidthBounds::new(0, 5);
let rendered = RenderedSegment::new("abcdefghij"); let truncated = apply_width_bounds(rendered, bounds).expect("truncated");
assert_eq!(truncated.width, 5);
assert!(truncated.text.ends_with('…'));
assert_eq!(truncated.text, "abcd…");
}
#[test]
fn apply_width_bounds_passthrough_within_range() {
let bounds = WidthBounds::new(2, 10);
let original = RenderedSegment::new("hello");
let result = apply_width_bounds(original.clone(), bounds).expect("kept");
assert_eq!(result, original);
}
#[test]
fn apply_width_bounds_none_is_passthrough() {
let original = RenderedSegment::new("anything");
let result = apply_width_bounds(original.clone(), None).expect("kept");
assert_eq!(result, original);
}
#[test]
fn truncate_to_zero_yields_empty() {
let out = truncate_to(RenderedSegment::new("abc"), 0);
assert_eq!(out.text, "");
assert_eq!(out.width, 0);
}
#[test]
fn truncate_handles_wide_grapheme_without_splitting() {
let bounds = WidthBounds::new(0, 6);
let truncated =
apply_width_bounds(RenderedSegment::new("42% · 200k"), bounds).expect("truncated");
assert_eq!(truncated.text, "42% ·…");
assert_eq!(truncated.width, 6);
}
#[test]
fn truncate_preserves_combining_mark_with_base() {
let r = RenderedSegment::new("ab\u{65}\u{301}de");
assert_eq!(r.width, 5);
let out = truncate_to(r, 4);
assert_eq!(out.text, "ab\u{65}\u{301}…");
assert_eq!(out.width, 4);
}
#[test]
fn truncate_does_not_split_zwj_emoji_sequence() {
let text = "a\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F466}b";
let r = RenderedSegment::new(text);
let out = truncate_to(r, 3);
assert_eq!(out.text, "a…");
assert_eq!(out.width, 2);
}
#[test]
fn truncate_to_max_cells_one_emits_only_ellipsis() {
let r = RenderedSegment::new("anything");
let out = truncate_to(r, 1);
assert_eq!(out.text, "…");
assert_eq!(out.width, 1);
}
#[test]
fn priority_ties_drop_rightmost_first() {
let items = vec![item("left", 200), item("mid", 50), item("right", 200)];
assert_eq!(render_plain(items, 10), "left mid");
}
#[test]
fn separator_none_not_charged_to_budget() {
let items = vec![
Item {
rendered: RenderedSegment::new("a"),
defaults: SegmentDefaults::with_priority(200),
segment: noop_segment(),
},
Item {
rendered: RenderedSegment::with_separator("b", Separator::None),
defaults: SegmentDefaults::with_priority(200),
segment: noop_segment(),
},
Item {
rendered: RenderedSegment::new("c"),
defaults: SegmentDefaults::with_priority(200),
segment: noop_segment(),
},
];
assert_eq!(render_plain(items, 4), "a bc");
}
#[test]
fn total_width_returns_u32_beyond_u16_range() {
let items = vec![
Item {
rendered: RenderedSegment::new("x".repeat(u16::MAX as usize)),
defaults: SegmentDefaults::with_priority(10),
segment: noop_segment(),
},
Item {
rendered: RenderedSegment::new("x".repeat(u16::MAX as usize)),
defaults: SegmentDefaults::with_priority(10),
segment: noop_segment(),
},
Item {
rendered: RenderedSegment::new("x".repeat(u16::MAX as usize)),
defaults: SegmentDefaults::with_priority(10),
segment: noop_segment(),
},
];
assert_eq!(total_width(&items), 3 * u32::from(u16::MAX) + 2);
}
#[test]
fn all_priority_zero_keeps_every_segment_even_when_overfull() {
let items = vec![item("aaa", 0), item("bbb", 0), item("ccc", 0)];
assert_eq!(render_plain(items, 4), "aaa bbb ccc");
}
use crate::segments::{RenderResult, SegmentError};
struct StubSegment(RenderResult);
impl Segment for StubSegment {
fn render(&self, _ctx: &DataContext, _rc: &RenderContext) -> RenderResult {
match &self.0 {
Ok(Some(r)) => Ok(Some(r.clone())),
Ok(None) => Ok(None),
Err(e) => Err(SegmentError::new(e.message.clone())),
}
}
}
#[test]
fn segment_error_is_logged_and_hides_segment() {
let segments: Vec<Box<dyn Segment>> = vec![
Box::new(StubSegment(Ok(Some(RenderedSegment::new("ok-before"))))),
Box::new(StubSegment(Err(SegmentError::new("boom")))),
Box::new(StubSegment(Ok(Some(RenderedSegment::new("ok-after"))))),
];
let mut warnings = Vec::new();
let items = collect_items_with(&segments, &empty_ctx(), &empty_rc(), &mut |msg| {
warnings.push(msg.to_string());
});
assert_eq!(items.len(), 2);
assert_eq!(items[0].rendered.text, "ok-before");
assert_eq!(items[1].rendered.text, "ok-after");
assert_eq!(warnings.len(), 1);
assert!(warnings[0].contains("segment error"));
assert!(warnings[0].contains("boom"));
}
#[test]
fn ok_none_is_silently_hidden() {
let segments: Vec<Box<dyn Segment>> = vec![
Box::new(StubSegment(Ok(Some(RenderedSegment::new("visible"))))),
Box::new(StubSegment(Ok(None))),
];
let mut warnings = Vec::new();
let items = collect_items_with(&segments, &empty_ctx(), &empty_rc(), &mut |msg| {
warnings.push(msg.to_string());
});
assert_eq!(items.len(), 1);
assert!(warnings.is_empty());
}
struct WidthEcho;
impl Segment for WidthEcho {
fn render(&self, _ctx: &DataContext, rc: &RenderContext) -> RenderResult {
Ok(Some(RenderedSegment::new(rc.terminal_width.to_string())))
}
}
#[test]
fn render_context_threads_terminal_width_into_segments() {
let segments: Vec<Box<dyn Segment>> = vec![Box::new(WidthEcho)];
let mut warnings = Vec::new();
let rc = RenderContext::new(42);
let items = collect_items_with(&segments, &empty_ctx(), &rc, &mut |msg| {
warnings.push(msg.to_string());
});
assert_eq!(items.len(), 1);
assert_eq!(items[0].rendered.text, "42");
}
#[test]
fn render_with_warn_constructs_render_context_from_terminal_width_arg() {
let segments: Vec<Box<dyn Segment>> = vec![Box::new(WidthEcho)];
let mut warnings = Vec::new();
let line = render_with_warn(
&segments,
&empty_ctx(),
137,
&mut |msg| warnings.push(msg.to_string()),
theme::default_theme(),
theme::Capability::None,
false,
);
assert!(line.contains("137"), "got {line:?}");
}
fn truncatable_item(text: &str, priority: u8) -> Item<'static> {
Item {
rendered: RenderedSegment::new(text),
defaults: SegmentDefaults::with_priority(priority).with_truncatable(true),
segment: noop_segment(),
}
}
#[test]
fn reflow_truncates_highest_priority_before_dropping() {
let items = vec![
truncatable_item("linesmith/very-long-feature-branch-name", 200),
item("Sonnet", 0),
];
let out = render_plain(items, 30);
assert!(out.starts_with("linesmith/very-long-fe"), "got {out:?}");
assert!(out.ends_with("… Sonnet"), "got {out:?}");
assert_eq!(text_width(&out), 30);
}
#[test]
fn reflow_drops_when_truncation_would_fall_below_floor() {
let items = vec![truncatable_item("workspace-name", 200), item("KEEP", 0)];
let out = render_plain(items, 4);
assert_eq!(out, "KEEP");
}
#[test]
fn reflow_respects_explicit_width_min_floor() {
let bounds = WidthBounds::new(8, u16::MAX).expect("valid");
let mut wide = truncatable_item("abcdefghijklmnop", 200); wide.defaults.width = Some(bounds);
let items = vec![wide, item("X", 0)];
let out = render_plain(items, 10);
assert!(out.contains('…'), "got {out:?}");
assert!(out.ends_with(" X"), "got {out:?}");
let bounds = WidthBounds::new(8, u16::MAX).expect("valid");
let mut wide = truncatable_item("abcdefghijklmnop", 200);
wide.defaults.width = Some(bounds);
let items = vec![wide, item("X", 0)];
let out = render_plain(items, 9);
assert_eq!(out, "X");
}
#[test]
fn non_truncatable_drops_unchanged_under_pressure() {
let items = vec![item("45% · 200k", 200), item("Sonnet", 0)];
let out = render_plain(items, 10);
assert_eq!(out, "Sonnet");
}
#[test]
fn reflow_iterates_when_first_truncation_insufficient() {
let items = vec![
truncatable_item("aaaaaaaaaa", 100),
truncatable_item("bbbbbbbbbb", 100),
item("KEEP", 0),
];
let out = render_plain(items, 12);
assert_eq!(out, "aaaaaa… KEEP");
assert_eq!(text_width(&out), 12);
}
#[test]
fn reflow_does_not_touch_priority_zero_even_when_truncatable() {
let items = vec![
Item {
rendered: RenderedSegment::new("untouchable-long-name"),
defaults: SegmentDefaults::with_priority(0).with_truncatable(true),
segment: noop_segment(),
},
item("Sonnet", 0),
];
let out = render_plain(items, 5);
assert_eq!(out, "untouchable-long-name Sonnet");
}
struct ShrinkableSegment {
full: &'static str,
compact: &'static str,
}
impl Segment for ShrinkableSegment {
fn render(&self, _ctx: &DataContext, _rc: &RenderContext) -> RenderResult {
Ok(Some(RenderedSegment::new(self.full)))
}
fn shrink_to_fit(
&self,
_ctx: &DataContext,
_rc: &RenderContext,
target: u16,
) -> Option<RenderedSegment> {
let r = RenderedSegment::new(self.compact);
(r.width <= target).then_some(r)
}
fn defaults(&self) -> SegmentDefaults {
SegmentDefaults::with_priority(200)
}
}
struct AnchorSegment(&'static str);
impl Segment for AnchorSegment {
fn render(&self, _ctx: &DataContext, _rc: &RenderContext) -> RenderResult {
Ok(Some(RenderedSegment::new(self.0)))
}
fn defaults(&self) -> SegmentDefaults {
SegmentDefaults::with_priority(0)
}
}
#[test]
fn shrink_to_fit_replaces_full_render_when_compact_form_fits() {
let segments: Vec<Box<dyn Segment>> = vec![
Box::new(ShrinkableSegment {
full: "longbranch * ↑2 ↓1",
compact: "longbranch",
}),
Box::new(AnchorSegment("KEEP")),
];
let mut warnings = Vec::new();
let line = render_with_warn(
&segments,
&empty_ctx(),
17,
&mut |m| warnings.push(m.to_string()),
theme::default_theme(),
theme::Capability::None,
false,
);
assert_eq!(line, "longbranch KEEP");
}
#[test]
fn shrink_to_fit_falls_back_to_drop_when_compact_form_too_wide() {
let segments: Vec<Box<dyn Segment>> = vec![
Box::new(ShrinkableSegment {
full: "longbranch",
compact: "stilltoolongtruly",
}),
Box::new(AnchorSegment("X")),
];
let mut warnings = Vec::new();
let line = render_with_warn(
&segments,
&empty_ctx(),
5,
&mut |m| warnings.push(m.to_string()),
theme::default_theme(),
theme::Capability::None,
false,
);
assert_eq!(line, "X");
}
#[test]
fn shrink_to_fit_honors_configured_width_min_floor() {
struct LowFloorShrink;
impl Segment for LowFloorShrink {
fn render(&self, _: &DataContext, _: &RenderContext) -> RenderResult {
Ok(Some(RenderedSegment::new("longerprefix")))
}
fn shrink_to_fit(
&self,
_: &DataContext,
_: &RenderContext,
_target: u16,
) -> Option<RenderedSegment> {
Some(RenderedSegment::new("five5"))
}
fn defaults(&self) -> SegmentDefaults {
SegmentDefaults::with_priority(200)
.with_width(WidthBounds::new(8, u16::MAX).expect("valid"))
}
}
let segments: Vec<Box<dyn Segment>> =
vec![Box::new(LowFloorShrink), Box::new(AnchorSegment("X"))];
let line = render_with_warn(
&segments,
&empty_ctx(),
7,
&mut |_| {},
theme::default_theme(),
theme::Capability::None,
false,
);
assert_eq!(line, "X");
}
#[test]
fn shrink_to_fit_rejects_too_wide_response_and_drops() {
struct MisbehavingSegment;
impl Segment for MisbehavingSegment {
fn render(&self, _: &DataContext, _: &RenderContext) -> RenderResult {
Ok(Some(RenderedSegment::new("longbranch")))
}
fn shrink_to_fit(
&self,
_: &DataContext,
_: &RenderContext,
_target: u16,
) -> Option<RenderedSegment> {
Some(RenderedSegment::new("stilltoolongtruly"))
}
fn defaults(&self) -> SegmentDefaults {
SegmentDefaults::with_priority(200)
}
}
let segments: Vec<Box<dyn Segment>> =
vec![Box::new(MisbehavingSegment), Box::new(AnchorSegment("X"))];
let line = render_with_warn(
&segments,
&empty_ctx(),
5,
&mut |_| {},
theme::default_theme(),
theme::Capability::None,
false,
);
assert_eq!(line, "X");
}
#[test]
fn shrink_to_fit_runs_before_truncatable_end_ellipsis() {
struct DualSegment;
impl Segment for DualSegment {
fn render(&self, _ctx: &DataContext, _rc: &RenderContext) -> RenderResult {
Ok(Some(RenderedSegment::new("longprefix-with-tail")))
}
fn shrink_to_fit(
&self,
_ctx: &DataContext,
_rc: &RenderContext,
target: u16,
) -> Option<RenderedSegment> {
let r = RenderedSegment::new("longprefix");
(r.width <= target).then_some(r)
}
fn defaults(&self) -> SegmentDefaults {
SegmentDefaults::with_priority(200).with_truncatable(true)
}
}
let segments: Vec<Box<dyn Segment>> = vec![
Box::new(DualSegment),
Box::new(StubSegment(Ok(Some(RenderedSegment::new("X"))))),
];
let mut warnings = Vec::new();
let line = render_with_warn(
&segments,
&empty_ctx(),
13,
&mut |m| warnings.push(m.to_string()),
theme::default_theme(),
theme::Capability::None,
false,
);
assert!(line.contains("longprefix"), "got {line:?}");
assert!(!line.contains('…'), "no end-ellipsis: {line:?}");
}
#[test]
fn render_to_runs_empty_input_yields_no_runs() {
let segments: Vec<Box<dyn Segment>> = vec![];
let runs = render_to_runs(&segments, &empty_ctx(), 100, &mut |_| {});
assert!(runs.is_empty());
}
#[test]
fn render_to_runs_emits_segment_then_separator_then_segment() {
let segments: Vec<Box<dyn Segment>> = vec![
Box::new(StubSegment(Ok(Some(RenderedSegment::new("a"))))),
Box::new(StubSegment(Ok(Some(RenderedSegment::new("b"))))),
];
let runs = render_to_runs(&segments, &empty_ctx(), 100, &mut |_| {});
assert_eq!(runs.len(), 3);
assert_eq!(runs[0].text, "a");
assert_eq!(runs[0].style, Style::default());
assert_eq!(runs[1].text, " ");
assert_eq!(runs[1].style, Style::default());
assert_eq!(runs[2].text, "b");
assert_eq!(runs[2].style, Style::default());
}
#[test]
fn render_to_runs_preserves_segment_style() {
use crate::theme::Role;
let segments: Vec<Box<dyn Segment>> = vec![
Box::new(StubSegment(Ok(Some(RenderedSegment::new("plain"))))),
Box::new(StubSegment(Ok(Some(
RenderedSegment::new("warn").with_role(Role::Warning),
)))),
];
let runs = render_to_runs(&segments, &empty_ctx(), 100, &mut |_| {});
assert_eq!(runs.len(), 3);
assert_eq!(runs[2].text, "warn");
assert_eq!(runs[2].style.role, Some(Role::Warning));
}
#[test]
fn render_to_runs_skips_separator_none_between_segments() {
let segments: Vec<Box<dyn Segment>> = vec![
Box::new(StubSegment(Ok(Some(RenderedSegment::with_separator(
"a",
Separator::None,
))))),
Box::new(StubSegment(Ok(Some(RenderedSegment::new("b"))))),
];
let runs = render_to_runs(&segments, &empty_ctx(), 100, &mut |_| {});
assert_eq!(runs.len(), 2);
assert_eq!(runs[0].text, "a");
assert_eq!(runs[1].text, "b");
}
#[test]
fn render_to_runs_drops_segments_under_width_pressure() {
let segments: Vec<Box<dyn Segment>> = vec![
Box::new(StubSegment(Ok(Some(
RenderedSegment::new("keep").with_role(crate::theme::Role::Primary),
)))),
Box::new(DroppableStub("droppable")),
Box::new(StubSegment(Ok(Some(RenderedSegment::new("anchor"))))),
];
let runs = render_to_runs(&segments, &empty_ctx(), 12, &mut |_| {});
let texts: Vec<&str> = runs.iter().map(|r| r.text.as_str()).collect();
assert_eq!(texts, vec!["keep", " ", "anchor"]);
}
fn round_trip_segments() -> Vec<Box<dyn Segment>> {
use crate::theme::Role;
vec![
Box::new(StubSegment(Ok(Some(
RenderedSegment::new("ctx").with_role(Role::Info),
)))),
Box::new(StubSegment(Ok(Some(RenderedSegment::new("|"))))),
Box::new(StubSegment(Ok(Some(
RenderedSegment::new("err").with_role(Role::Error),
)))),
]
}
fn round_trip_assert(terminal_width: u16, capability: theme::Capability, hyperlinks: bool) {
let segments = round_trip_segments();
let direct = render_with_warn(
&segments,
&empty_ctx(),
terminal_width,
&mut |_| {},
theme::default_theme(),
capability,
hyperlinks,
);
let runs = render_to_runs(&segments, &empty_ctx(), terminal_width, &mut |_| {});
let recomposed = runs_to_ansi(&runs, theme::default_theme(), capability, hyperlinks);
assert_eq!(
direct, recomposed,
"cap={capability:?} width={terminal_width} hyperlinks={hyperlinks}"
);
}
#[test]
fn render_to_runs_then_runs_to_ansi_matches_render_with_warn() {
round_trip_assert(100, theme::Capability::Palette16, false);
}
#[test]
fn render_to_runs_round_trip_holds_under_capability_none() {
round_trip_assert(100, theme::Capability::None, false);
}
#[test]
fn render_to_runs_round_trip_holds_under_width_pressure() {
round_trip_assert(5, theme::Capability::Palette16, false);
}
#[test]
fn render_to_runs_round_trip_holds_with_hyperlinks_enabled() {
round_trip_assert(100, theme::Capability::Palette16, true);
}
#[test]
fn render_to_runs_with_one_survivor_emits_no_trailing_separator() {
let segments: Vec<Box<dyn Segment>> = vec![
Box::new(StubSegment(Ok(Some(RenderedSegment::new("a"))))),
Box::new(DroppableStub("droppable")),
];
let runs = render_to_runs(&segments, &empty_ctx(), 1, &mut |_| {});
assert_eq!(runs.len(), 1);
assert_eq!(runs[0].text, "a");
}
#[test]
fn render_to_runs_emits_powerline_chevron_with_muted_role() {
use crate::theme::Role;
struct PowerlineSeg;
impl Segment for PowerlineSeg {
fn render(&self, _: &DataContext, _: &RenderContext) -> RenderResult {
Ok(Some(RenderedSegment::new("a").with_role(Role::Primary)))
}
fn defaults(&self) -> SegmentDefaults {
SegmentDefaults::with_priority(10).with_default_separator(Separator::powerline())
}
}
let segments: Vec<Box<dyn Segment>> = vec![
Box::new(PowerlineSeg),
Box::new(StubSegment(Ok(Some(RenderedSegment::new("b"))))),
];
let runs = render_to_runs(&segments, &empty_ctx(), 100, &mut |_| {});
assert_eq!(runs.len(), 3);
assert_eq!(runs[1].text, " \u{E0B0} ");
assert_eq!(runs[1].style.role, Some(Role::Muted));
}
#[test]
fn powerline_separator_emits_padded_chevron_with_correct_width() {
assert_eq!(Separator::powerline().width(), 3);
assert_eq!(Separator::powerline().text(), " \u{E0B0} ");
}
#[test]
fn powerline_chevrons_are_charged_to_total_width_in_layout() {
let item = |text: &str| Item {
rendered: RenderedSegment::new(text),
defaults: SegmentDefaults::with_priority(0)
.with_default_separator(Separator::powerline()),
segment: noop_segment(),
};
let items = vec![item("aaaa"), item("bbbb"), item("cccc")];
let chev = u32::from(Separator::powerline().width());
assert_eq!(total_width(&items), 4 + chev + 4 + chev + 4);
}
#[test]
fn render_with_warn_emits_powerline_chevron_wrapped_in_muted_sgr() {
struct PowerlineSeg(&'static str, theme::Role);
impl Segment for PowerlineSeg {
fn render(&self, _: &DataContext, _: &RenderContext) -> RenderResult {
Ok(Some(RenderedSegment::new(self.0).with_role(self.1)))
}
fn defaults(&self) -> SegmentDefaults {
SegmentDefaults::with_priority(10).with_default_separator(Separator::powerline())
}
}
let segments: Vec<Box<dyn Segment>> = vec![
Box::new(PowerlineSeg("a", theme::Role::Primary)),
Box::new(PowerlineSeg("b", theme::Role::Info)),
];
let line = render_with_warn(
&segments,
&empty_ctx(),
100,
&mut |_| {},
theme::default_theme(),
theme::Capability::Palette16,
false,
);
let muted_sgr = theme::sgr_open(
&Style::role(theme::Role::Muted),
theme::default_theme(),
theme::Capability::Palette16,
);
let expected = format!("{muted_sgr} \u{E0B0} \x1b[0m");
assert!(
line.contains(&expected),
"padded chevron with Muted SGR not in line: {line:?} (expected substring: {expected:?})"
);
}
#[test]
fn render_to_runs_emits_literal_separator_with_default_style() {
struct PipeSepSegment;
impl Segment for PipeSepSegment {
fn render(&self, _: &DataContext, _: &RenderContext) -> RenderResult {
Ok(Some(
RenderedSegment::new("a").with_role(crate::theme::Role::Warning),
))
}
fn defaults(&self) -> SegmentDefaults {
SegmentDefaults::with_priority(10)
.with_default_separator(Separator::Literal(Cow::Borrowed(" | ")))
}
}
let segments: Vec<Box<dyn Segment>> = vec![
Box::new(PipeSepSegment),
Box::new(StubSegment(Ok(Some(RenderedSegment::new("b"))))),
];
let runs = render_to_runs(&segments, &empty_ctx(), 100, &mut |_| {});
assert_eq!(runs.len(), 3);
assert_eq!(runs[1].text, " | ");
assert_eq!(runs[1].style, Style::default());
}
#[test]
fn runs_to_ansi_emits_osc8_around_styled_run_when_hyperlinks_supported() {
use crate::theme::Role;
let runs = vec![StyledRun::new(
"branch",
Style::role(Role::Primary).with_hyperlink("https://example.com/b"),
)];
let out = runs_to_ansi(
&runs,
theme::default_theme(),
theme::Capability::Palette16,
true,
);
assert_eq!(
out, "\x1b]8;;https://example.com/b\x1b\\\x1b[95mbranch\x1b[0m\x1b]8;;\x1b\\",
"got {out:?}"
);
}
#[test]
fn runs_to_ansi_drops_hyperlink_when_not_supported() {
use crate::theme::Role;
let runs = vec![StyledRun::new(
"branch",
Style::role(Role::Primary).with_hyperlink("https://example.com/b"),
)];
let out = runs_to_ansi(
&runs,
theme::default_theme(),
theme::Capability::Palette16,
false,
);
assert_eq!(out, "\x1b[95mbranch\x1b[0m");
assert!(!out.contains("\x1b]8"), "no OSC 8: {out:?}");
}
#[test]
fn runs_to_ansi_emits_no_osc8_when_style_has_no_hyperlink() {
let runs = vec![StyledRun::new("plain", Style::default())];
let out = runs_to_ansi(&runs, theme::default_theme(), theme::Capability::None, true);
assert_eq!(out, "plain");
assert!(!out.contains("\x1b]8"), "no OSC 8: {out:?}");
}
#[test]
fn runs_to_ansi_emits_osc8_around_unstyled_run() {
let runs = vec![StyledRun::new(
"click",
Style::default().with_hyperlink("https://example.com"),
)];
let out = runs_to_ansi(&runs, theme::default_theme(), theme::Capability::None, true);
assert_eq!(out, "\x1b]8;;https://example.com\x1b\\click\x1b]8;;\x1b\\");
}
#[test]
fn osc8_pair_balanced_when_hyperlinked_run_is_truncated() {
let mut rendered = RenderedSegment::new("very-long-branch-name")
.with_style(Style::default().with_hyperlink("https://example.com/branch"));
rendered = truncate_to(rendered, 8);
let runs = vec![StyledRun::new(
rendered.text().to_string(),
rendered.style.clone(),
)];
let out = runs_to_ansi(&runs, theme::default_theme(), theme::Capability::None, true);
assert!(
out.starts_with("\x1b]8;;https://example.com/branch\x1b\\"),
"OSC 8 open present: {out:?}"
);
assert!(
out.ends_with("\x1b]8;;\x1b\\"),
"OSC 8 close present: {out:?}"
);
assert!(out.contains('…'), "truncation marker preserved: {out:?}");
assert_eq!(
out.matches("\x1b]8;;").count(),
2,
"exactly one open and one close: {out:?}"
);
}
#[test]
fn osc8_pair_balanced_when_hyperlinked_run_truncated_to_zero() {
let rendered = RenderedSegment::new("anything")
.with_style(Style::default().with_hyperlink("https://example.com"));
let truncated = truncate_to(rendered, 0);
let runs = vec![StyledRun::new(
truncated.text().to_string(),
truncated.style.clone(),
)];
let out = runs_to_ansi(&runs, theme::default_theme(), theme::Capability::None, true);
assert_eq!(
out, "\x1b]8;;https://example.com\x1b\\\x1b]8;;\x1b\\",
"empty-text run still emits balanced OSC 8 pair: {out:?}"
);
}
#[test]
fn runs_to_ansi_emits_independent_osc8_pairs_for_adjacent_hyperlinked_runs() {
let runs = vec![
StyledRun::new("a", Style::default().with_hyperlink("https://a.example")),
StyledRun::new("b", Style::default().with_hyperlink("https://b.example")),
];
let out = runs_to_ansi(&runs, theme::default_theme(), theme::Capability::None, true);
assert_eq!(
out,
"\x1b]8;;https://a.example\x1b\\a\x1b]8;;\x1b\\\x1b]8;;https://b.example\x1b\\b\x1b]8;;\x1b\\"
);
assert_eq!(out.matches("\x1b]8;;").count(), 4, "two opens + two closes");
}
#[test]
fn push_osc8_open_strips_control_chars_from_url() {
let runs = vec![StyledRun::new(
"x",
Style::default().with_hyperlink("https://example.com\x1b\\evil\x07more"),
)];
let out = runs_to_ansi(&runs, theme::default_theme(), theme::Capability::None, true);
assert_eq!(
out.matches("\x1b]8;;").count(),
2,
"exactly one pair: {out:?}"
);
assert!(!out.contains("\x1b\\evil"), "ESC \\ stripped: {out:?}");
assert!(!out.contains('\x07'), "BEL stripped: {out:?}");
assert!(
out.contains("https://example.com\\evilmore"),
"non-control chars survive: {out:?}"
);
}
#[test]
fn push_osc8_open_strips_c1_string_terminator_and_nul() {
let runs = vec![StyledRun::new(
"x",
Style::default().with_hyperlink("https://a.example\x00b\x7fc\u{009C}d"),
)];
let out = runs_to_ansi(&runs, theme::default_theme(), theme::Capability::None, true);
assert_eq!(out.matches("\x1b]8;;").count(), 2, "single pair: {out:?}");
assert!(!out.contains('\x00'), "NUL stripped: {out:?}");
assert!(!out.contains('\x7f'), "DEL stripped: {out:?}");
assert!(!out.contains('\u{009C}'), "C1 ST stripped: {out:?}");
assert!(out.contains("https://a.examplebcd"));
}
#[test]
fn runs_to_ansi_capability_none_emits_unwrapped_text() {
use crate::theme::Role;
let runs = vec![
StyledRun::new("plain", Style::default()),
StyledRun::new(" ", Style::default()),
StyledRun::new("warn", Style::role(Role::Warning)),
];
let out = runs_to_ansi(
&runs,
theme::default_theme(),
theme::Capability::None,
false,
);
assert_eq!(out, "plain warn");
assert!(!out.contains('\x1b'), "unexpected ANSI escape: {out:?}");
}
struct DroppableStub(&'static str);
impl Segment for DroppableStub {
fn render(&self, _: &DataContext, _: &RenderContext) -> RenderResult {
Ok(Some(RenderedSegment::new(self.0)))
}
fn defaults(&self) -> SegmentDefaults {
SegmentDefaults::with_priority(200)
}
}
}