use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use crate::app::{App, DynamicSegment, SegmentSide};
use crate::focus::Focus;
use crate::input::EditingMode;
use crate::ui::{icons, theme};
const PL_RIGHT: &str = "\u{e0b0}"; const PL_LEFT: &str = "\u{e0b2}";
fn local_tz_offset_secs() -> i64 {
use std::sync::OnceLock;
static CACHE: OnceLock<i64> = OnceLock::new();
*CACHE.get_or_init(|| {
if let Ok(s) = std::env::var("TZ_OFFSET_HOURS")
&& let Ok(h) = s.parse::<i32>()
{
return h as i64 * 3600;
}
let Ok(out) = std::process::Command::new("date").arg("+%z").output() else {
return 0;
};
let s = String::from_utf8_lossy(&out.stdout);
let s = s.trim();
if s.len() != 5 {
return 0;
}
let sign: i64 = if s.starts_with('-') { -1 } else { 1 };
let Ok(hh) = s[1..3].parse::<i64>() else {
return 0;
};
let Ok(mm) = s[3..5].parse::<i64>() else {
return 0;
};
sign * (hh * 3600 + mm * 60)
})
}
struct Seg {
text: String,
fg: Color,
bg: Color,
bold: bool,
underline_range: Option<(usize, usize)>,
fg_range: Option<(usize, usize, Color)>,
bg_range: Option<(usize, usize, Color)>,
}
impl Seg {
fn new(text: impl Into<String>, fg: Color, bg: Color) -> Self {
Seg {
text: text.into(),
fg,
bg,
bold: false,
underline_range: None,
fg_range: None,
bg_range: None,
}
}
fn bold(mut self) -> Self {
self.bold = true;
self
}
fn underline_range(mut self, start: usize, end: usize) -> Self {
if end > start {
self.underline_range = Some((start, end));
}
self
}
#[allow(dead_code)]
fn fg_range(mut self, start: usize, end: usize, color: Color) -> Self {
if end > start {
self.fg_range = Some((start, end, color));
}
self
}
#[allow(dead_code)]
fn bg_range(mut self, start: usize, end: usize, color: Color) -> Self {
if end > start {
self.bg_range = Some((start, end, color));
}
self
}
fn style(&self) -> Style {
let s = Style::default().fg(self.fg).bg(self.bg);
if self.bold {
s.add_modifier(Modifier::BOLD)
} else {
s
}
}
fn cols(&self) -> usize {
self.text.chars().count()
}
fn to_spans(&self) -> Vec<Span<'static>> {
let base = self.style();
let cols = self.cols();
if cols == 0 {
return Vec::new();
}
let ul = self
.underline_range
.map(|(s, e)| (s.min(cols), e.min(cols)));
let fg = self.fg_range.map(|(s, e, c)| (s.min(cols), e.min(cols), c));
let bg = self.bg_range.map(|(s, e, c)| (s.min(cols), e.min(cols), c));
let any = ul.map(|(s, e)| e > s).unwrap_or(false)
|| fg.map(|(s, e, _)| e > s).unwrap_or(false)
|| bg.map(|(s, e, _)| e > s).unwrap_or(false);
if !any {
return vec![Span::styled(self.text.clone(), base)];
}
let mut cuts: Vec<usize> = vec![0, cols];
if let Some((s, e)) = ul {
cuts.push(s);
cuts.push(e);
}
if let Some((s, e, _)) = fg {
cuts.push(s);
cuts.push(e);
}
if let Some((s, e, _)) = bg {
cuts.push(s);
cuts.push(e);
}
cuts.sort_unstable();
cuts.dedup();
let byte_at = |char_idx: usize| -> usize {
self.text
.char_indices()
.nth(char_idx)
.map(|(b, _)| b)
.unwrap_or(self.text.len())
};
let mut spans: Vec<Span<'static>> = Vec::with_capacity(cuts.len());
for w in cuts.windows(2) {
let (a, b) = (w[0], w[1]);
if a >= b {
continue;
}
let mid = a;
let in_ul = ul.map(|(s, e)| mid >= s && mid < e).unwrap_or(false);
let in_fg = fg.map(|(s, e, _)| mid >= s && mid < e).unwrap_or(false);
let in_bg = bg.map(|(s, e, _)| mid >= s && mid < e).unwrap_or(false);
let mut style = base;
if let Some((_, _, c)) = fg
&& in_fg
{
style = style.fg(c);
}
if let Some((_, _, c)) = bg
&& in_bg
{
style = style.bg(c);
}
if in_ul {
style = style.add_modifier(Modifier::UNDERLINED);
}
let ba = byte_at(a);
let bb = byte_at(b);
spans.push(Span::styled(self.text[ba..bb].to_string(), style));
}
spans
}
}
fn dynamic_lane_budget(total_width: usize) -> usize {
(total_width / 3).max(20)
}
struct RenderedDynamicSegment {
id: String,
text: String,
color: Option<String>,
}
fn collect_dynamic_segments(
all: &[DynamicSegment],
side: SegmentSide,
total_width: usize,
user_order: &[String],
) -> Vec<RenderedDynamicSegment> {
let mut candidates: Vec<&DynamicSegment> = all.iter().filter(|s| s.side == side).collect();
if candidates.is_empty() {
return Vec::new();
}
use std::collections::HashMap;
let order_pos: HashMap<&str, usize> = user_order
.iter()
.enumerate()
.map(|(i, id)| (id.as_str(), i))
.collect();
candidates.sort_by(|a, b| {
let a_ord = order_pos.get(a.id.as_str()).copied();
let b_ord = order_pos.get(b.id.as_str()).copied();
match (a_ord, b_ord) {
(Some(ai), Some(bi)) => ai.cmp(&bi),
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => b.priority.cmp(&a.priority),
}
});
let mut budget = dynamic_lane_budget(total_width);
let mut out: Vec<RenderedDynamicSegment> = Vec::new();
for s in candidates {
let natural_width = s.text.chars().count();
let desired = natural_width.min(s.max_width as usize);
let alloc = desired.min(budget);
let need = natural_width.min(s.min_width as usize);
if alloc < need {
continue;
}
let mut text = if natural_width > alloc {
ellipsize(&s.text, alloc)
} else {
s.text.clone()
};
if !text.starts_with(' ') {
text.insert(0, ' ');
}
if !text.ends_with(' ') {
text.push(' ');
}
let final_width = text.chars().count();
budget = budget.saturating_sub(final_width);
out.push(RenderedDynamicSegment {
id: s.id.clone(),
text,
color: s.color.clone(),
});
}
out
}
fn format_tokens(n: u64) -> String {
if n >= 1_000_000 {
format!("{:.1}M", n as f64 / 1_000_000.0)
} else if n >= 1_000 {
format!("{:.1}k", n as f64 / 1_000.0)
} else {
n.to_string()
}
}
fn seg_from_dynamic(rd: &RenderedDynamicSegment) -> Seg {
let t = theme::cur();
let bg = match rd.color.as_deref() {
Some("red") => t.red,
Some("orange") => t.orange,
Some("yellow") => t.yellow,
Some("green") => t.green,
Some("blue") => t.blue,
Some("cyan") => t.cyan,
Some("teal") => t.teal,
Some("purple") => t.purple,
Some("pink") => t.pink,
Some("magenta") => t.purple,
Some("comment") => t.comment,
Some(s) if s.starts_with('#') => parse_hex_color(s).unwrap_or(t.comment),
_ => t.comment,
};
let fg = if is_dark_bg(bg) { t.fg } else { t.bg_darker };
Seg::new(rd.text.clone(), fg, bg)
}
fn is_dark_bg(c: ratatui::style::Color) -> bool {
use ratatui::style::Color;
let (r, g, b) = match c {
Color::Rgb(r, g, b) => (r, g, b),
_ => return false,
};
let luma = 0.299 * r as f32 + 0.587 * g as f32 + 0.114 * b as f32;
luma < 128.0
}
fn parse_hex_color(s: &str) -> Option<ratatui::style::Color> {
let hex = s.trim_start_matches('#');
let (r, g, b) = match hex.len() {
6 => (
u8::from_str_radix(&hex[0..2], 16).ok()?,
u8::from_str_radix(&hex[2..4], 16).ok()?,
u8::from_str_radix(&hex[4..6], 16).ok()?,
),
3 => {
let expand = |c: char| c.to_digit(16).map(|d| (d * 17) as u8);
let mut it = hex.chars();
(
expand(it.next()?)?,
expand(it.next()?)?,
expand(it.next()?)?,
)
}
_ => return None,
};
Some(ratatui::style::Color::Rgb(r, g, b))
}
fn ellipsize(s: &str, target_cols: usize) -> String {
let cur = s.chars().count();
if cur <= target_cols {
return s.to_string();
}
let take = target_cols.saturating_sub(1);
let mut out: String = s.chars().take(take).collect();
out.push('…');
out
}
fn tier_color(percent: u16, t: &theme::Theme) -> ratatui::style::Color {
if percent >= 85 {
t.red
} else if percent >= 60 {
t.yellow
} else {
t.green
}
}
fn sparkline_char(percent: u16) -> char {
const BLOCKS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
let clamped = percent.min(100) as usize;
let idx = (clamped * (BLOCKS.len() - 1)) / 100;
BLOCKS[idx.min(BLOCKS.len() - 1)]
}
fn account_urgency(u: &crate::ai_usage::ClaudeUsage, now_secs: u64) -> f32 {
if u.fetched_at == 0 || u.last_error.is_some() {
return 0.0;
}
if u.percent >= 100 {
return 0.0;
}
let remaining = 100u16.saturating_sub(u.percent) as f32;
let reset_at = if u.resets_at > now_secs {
u.resets_at
} else if u.weekly_resets_at > now_secs {
u.weekly_resets_at
} else {
return 0.0;
};
let hours = (reset_at - now_secs) as f32 / 3600.0;
if hours <= 0.05 {
return remaining * 1000.0;
}
remaining / hours
}
fn hours_until_first_reset(
accounts: &[crate::ai_usage::ClaudeAccountUsage],
now_secs: u64,
) -> Option<u64> {
accounts
.iter()
.filter_map(|a| {
let u = &a.usage;
let session = if u.resets_at > now_secs {
Some(u.resets_at)
} else {
None
};
let weekly = if u.weekly_resets_at > now_secs {
Some(u.weekly_resets_at)
} else {
None
};
session.into_iter().chain(weekly).min()
})
.min()
.map(|when| (when - now_secs) / 3600)
}
fn render_claude_chip_all_accounts(app: &App, t: &theme::Theme) -> (String, ratatui::style::Color) {
if app.ai_usage_claude_accounts.is_empty() {
return (" \u{F1E00} … ".to_string(), t.comment);
}
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let mut sparkline = String::new();
let mut worst: u16 = 0;
let mut any_error = false;
let mut any_fetched_gt_zero = false;
let mut all_near_empty = true;
for acc in &app.ai_usage_claude_accounts {
let u = &acc.usage;
if u.last_error.is_some() {
sparkline.push('!');
any_error = true;
all_near_empty = false;
} else if u.fetched_at > 0 {
sparkline.push(sparkline_char(u.percent));
worst = worst.max(u.percent);
any_fetched_gt_zero = true;
if u.percent < 90 {
all_near_empty = false;
}
} else {
sparkline.push('…');
all_near_empty = false;
}
}
let reset_suffix = |now: u64| -> String {
match hours_until_first_reset(&app.ai_usage_claude_accounts, now) {
Some(0) => " ⟳<1h".to_string(),
Some(h) if h < 100 => format!(" ⟳{h}h"),
Some(_) => " ⟳soon".to_string(),
None => String::new(),
}
};
let suffix: String = if !any_fetched_gt_zero {
String::new()
} else if all_near_empty {
reset_suffix(now)
} else {
let mut urgencies: Vec<(f32, char)> = app
.ai_usage_claude_accounts
.iter()
.filter_map(|a| {
let u = &a.usage;
let urg = account_urgency(u, now);
if urg <= 0.0 {
return None;
}
let letter = account_abbrev(&a.name).chars().next()?;
Some((urg, letter))
})
.collect();
urgencies.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
match urgencies.as_slice() {
[(top, letter), rest @ ..] if rest.is_empty() || rest[0].0 * 1.5 < *top => {
format!(" →{letter}")
}
_ => reset_suffix(now),
}
};
let color = if any_error && worst == 0 {
t.red
} else if any_fetched_gt_zero {
tier_color(worst, t)
} else {
t.comment
};
let text = format!(" \u{F1E00} {sparkline}{suffix} ");
(text, color)
}
struct ClaudeChipResult {
text: String,
tier_fg: ratatui::style::Color,
tier_range: Option<(usize, usize)>,
}
fn render_single_account_chip(
u: &crate::ai_usage::ClaudeUsage,
mode: &str,
letter_prefix: &str,
show_reset: bool,
t: &theme::Theme,
) -> ClaudeChipResult {
let prefix = if letter_prefix.is_empty() {
String::new()
} else {
format!("{letter_prefix} ")
};
if u.last_error.is_some() {
return ClaudeChipResult {
text: format!(" \u{F1E00} {prefix}—! "),
tier_fg: t.red,
tier_range: None,
};
}
if u.fetched_at == 0 {
return ClaudeChipResult {
text: format!(" \u{F1E00} {prefix}— "),
tier_fg: t.comment,
tier_range: None,
};
}
let session_r = if show_reset {
format_reset_suffix(u.resets_at, "5h")
} else {
String::new()
};
let weekly_r = if show_reset {
format_reset_suffix(u.weekly_resets_at, "7d")
} else {
String::new()
};
let (label, tier_pct) = match mode {
"weekly" => (
format!(" \u{F1E00} {prefix}{}%{} ", u.weekly_percent, weekly_r),
u.weekly_percent,
),
"both" => (
format!(
" \u{F1E00} {prefix}{}%{} {}%{} ",
u.percent, session_r, u.weekly_percent, weekly_r
),
u.percent.max(u.weekly_percent),
),
_ => (
format!(" \u{F1E00} {prefix}{}%{} ", u.percent, session_r),
u.percent,
),
};
let cols = label.chars().count();
let prefix_cols = prefix.chars().count();
let numeric_start = 3 + prefix_cols;
let numeric_end = cols.saturating_sub(1); let tier_range = if numeric_end > numeric_start {
Some((numeric_start, numeric_end))
} else {
None
};
ClaudeChipResult {
text: label,
tier_fg: tier_color(tier_pct, t),
tier_range,
}
}
fn format_reset_suffix(resets_at: u64, window_fallback: &str) -> String {
if resets_at == 0 {
return format!(" {window_fallback}");
}
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let remaining = resets_at.saturating_sub(now);
if remaining == 0 {
return format!(" {window_fallback}");
}
if remaining < 60 {
" <1m".to_string()
} else if remaining < 3600 {
format!(" {}m", remaining / 60)
} else if remaining < 86_400 {
format!(" {}h", remaining / 3600)
} else {
format!(" {}d", remaining / 86_400)
}
}
fn account_abbrev(name: &str) -> String {
let a = name
.chars()
.next()
.map(|c| c.to_ascii_uppercase())
.unwrap_or('?');
a.to_string()
}
pub fn draw(frame: &mut Frame, app: &mut App, area: Rect) {
frame.render_widget(
Paragraph::new("").style(Style::default().bg(theme::cur().statusline)),
area,
);
if area.width == 0 {
return;
}
let width = area.width as usize;
let arrows = !app.config.ui.ascii_icons;
let nerd = !app.config.ui.ascii_icons;
let (mode_label, mode_bg) = mode_chip(app);
let is_vim_mode = matches!(
app.editing_mode(),
EditingMode::Insert
| EditingMode::Replace
| EditingMode::Visual
| EditingMode::VisualLine
| EditingMode::VisualBlock
| EditingMode::Normal
);
let mut left: Vec<Seg> = Vec::new();
let dyn_left = collect_dynamic_segments(
&app.dynamic_segments,
SegmentSide::Left,
width,
&app.config.ui.statusline_segment_order,
);
let mut branch_seg_idx: Option<usize> = None;
let mut pr_seg_idx: Option<usize> = None;
let mut file_glyph_idx: Option<usize> = None;
let mut file_name_idx: Option<usize> = None;
let mut diag_err_idx: Option<usize> = None;
let mut diag_warn_idx: Option<usize> = None;
let mut symbol_seg_idx: Option<usize> = None;
let mut macro_seg_idx: Option<usize> = None;
let mut find_seg_idx: Option<usize> = None;
let mut sel_seg_idx: Option<usize> = None;
let mut progress_seg_idx: Option<usize> = None;
let mut bg_tasks_seg_idx: Option<usize> = None;
let mut ai_seg_idx: Option<usize> = None;
app.rects.statusline_branch_chip = None;
app.rects.statusline_mode_chip = None;
app.rects.statusline_file_chip = None;
app.rects.statusline_diagnostics_chip = None;
app.rects.statusline_language_chip = None;
app.rects.statusline_symbol_chip = None;
app.rects.statusline_pr_chip = None;
app.rects.statusline_macro_chip = None;
app.rects.statusline_find_chip = None;
app.rects.statusline_sel_chip = None;
app.rects.statusline_progress_chip = None;
app.rects.statusline_bg_tasks_chip = None;
app.rects.statusline_ai_chip = None;
let mode_seg_start = left.len();
if nerd && is_vim_mode {
let glyph_fg = if mode_bg == theme::cur().orange {
theme::cur().bg_darker
} else {
theme::cur().orange
};
left.push(Seg::new(" \u{e7c5} ".to_string(), glyph_fg, mode_bg).bold());
left.push(Seg::new(format!("{mode_label} "), theme::cur().bg_darker, mode_bg).bold());
} else {
left.push(Seg::new(format!(" {mode_label} "), theme::cur().bg_darker, mode_bg).bold());
}
let mode_seg_end = left.len(); let mut dyn_left_placements: Vec<(usize, String)> = Vec::with_capacity(dyn_left.len());
for spec in &dyn_left {
dyn_left_placements.push((left.len(), spec.id.clone()));
left.push(seg_from_dynamic(spec));
}
{
let g = app.git.snapshot();
if let Some(branch) = &g.branch {
let provider = if nerd {
g.provider_icon.unwrap_or("\u{F126}")
} else {
""
};
let mut txt = if provider.is_empty() {
format!(" {branch}")
} else {
format!(" {provider} {branch}")
};
if g.ahead > 0 {
txt.push_str(&format!(" ⇡{}", g.ahead));
}
if g.behind > 0 {
txt.push_str(&format!(" ⇣{}", g.behind));
}
if g.added > 0 {
txt.push_str(&format!(" \u{F0419} {}", g.added)); }
if g.changed > 0 {
txt.push_str(&format!(" \u{F06D5} {}", g.changed)); }
if g.removed > 0 {
txt.push_str(&format!(" \u{F0374} {}", g.removed)); }
if g.conflicts > 0 {
txt.push_str(&format!(" ⚠{}", g.conflicts));
}
txt.push(' ');
branch_seg_idx = Some(left.len());
left.push(Seg::new(txt, theme::cur().green, theme::cur().bg2));
}
}
if let Some(pr) = app.git_rail.pulls.iter().find(|p| p.is_current_branch) {
let chip = format!(" {}{} ", pr.host_tag, pr.number_label);
pr_seg_idx = Some(left.len());
left.push(Seg::new(chip, theme::cur().purple, theme::cur().bg2));
}
match app.active_editor() {
Some(b) => {
let p = b.path.clone().unwrap_or_else(|| b.display_name().into());
let (glyph, gc) = icons::for_path(&p, false, false, nerd);
file_glyph_idx = Some(left.len());
left.push(Seg::new(format!(" {glyph} "), gc, theme::cur().statusline));
let name = format!("{}{} ", b.display_name(), if b.dirty { " ●" } else { "" });
file_name_idx = Some(left.len());
left.push(Seg::new(name, theme::cur().fg, theme::cur().statusline));
let (errs, warns) =
b.all_diagnostics()
.fold((0u32, 0u32), |(e, w), d| match d.severity {
crate::lsp::Severity::Error => (e + 1, w),
crate::lsp::Severity::Warning => (e, w + 1),
_ => (e, w),
});
if errs > 0 {
diag_err_idx = Some(left.len());
left.push(Seg::new(
format!(" {errs} "),
theme::cur().red,
theme::cur().statusline,
));
}
if warns > 0 {
diag_warn_idx = Some(left.len());
left.push(Seg::new(
format!(" ⚠ {warns} "),
theme::cur().yellow,
theme::cur().statusline,
));
}
if let Some(ext) = b.language_ext.as_deref() {
let symbols = crate::regex_outline::extract_symbols(b.editor.text(), ext);
let row = b.editor.row_col().0 as u32;
if let Some(s) = symbols.iter().rev().find(|s| s.line <= row) {
let label: String = s.name.chars().take(40).collect();
symbol_seg_idx = Some(left.len());
left.push(Seg::new(
format!(" › {label} "),
theme::cur().purple,
theme::cur().statusline,
));
}
}
if let crate::app::MacroState::Recording { register, .. } = &app.macro_state {
macro_seg_idx = Some(left.len());
left.push(Seg::new(
format!(" ● rec @{register} "),
theme::cur().bg_darker,
theme::cur().red,
));
}
if let Some(f) = b.find.as_ref()
&& !f.matches.is_empty()
{
let cur = f.current.map(|i| i + 1).unwrap_or(0);
let m = f.matches.len();
let q: String = f.query.chars().take(24).collect();
let ellip = if f.query.chars().count() > 24 {
"…"
} else {
""
};
find_seg_idx = Some(left.len());
left.push(Seg::new(
format!(" /{q}{ellip} {cur}/{m} "),
theme::cur().bg_darker,
theme::cur().yellow,
));
}
}
None => left.push(Seg::new(
" [no file] ",
theme::cur().comment,
theme::cur().statusline,
)),
}
let mut right: Vec<Seg> = Vec::new();
let dyn_right = collect_dynamic_segments(
&app.dynamic_segments,
SegmentSide::Right,
width,
&app.config.ui.statusline_segment_order,
);
let mut dyn_right_placements: Vec<(usize, String)> = Vec::with_capacity(dyn_right.len());
for spec in &dyn_right {
dyn_right_placements.push((right.len(), spec.id.clone()));
right.push(seg_from_dynamic(spec));
}
app.rects.statusline_test_chip = None;
let test_chip_label = match &app.last_test_run {
Some((label, pane_idx)) => {
if *pane_idx < app.panes.len() {
Some((label.clone(), *pane_idx))
} else {
None
}
}
None => None,
};
let mut test_seg_idx: Option<usize> = None;
if let Some((label, _pane_idx)) = test_chip_label.clone() {
test_seg_idx = Some(right.len());
right.push(Seg::new(
format!(" \u{1F9EA} {label} "),
theme::cur().bg_darker,
theme::cur().yellow,
));
}
app.rects.statusline_ai_claude_chip = None;
app.rects.statusline_ai_codex_chip = None;
let mut ai_claude_seg_idx: Option<usize> = None;
let mut ai_codex_seg_idx: Option<usize> = None;
let claude_enabled = app
.config
.ui
.integration_icons
.iter()
.any(|ic| ic.id == "claude_code" && ic.enabled);
let codex_enabled = app
.config
.ui
.integration_icons
.iter()
.any(|ic| ic.id == "codex" && ic.enabled);
if claude_enabled {
let t = theme::cur();
let mode = app
.config
.ai
.as_table()
.and_then(|t| t.get("claude_meter_mode"))
.and_then(|v| v.as_str())
.unwrap_or("session");
let show_reset = app
.config
.ai
.as_table()
.and_then(|t| t.get("claude_show_reset"))
.and_then(|v| v.as_bool())
.unwrap_or(false);
let multi_mode = if app.ai_usage_claude_accounts.len() > 1 {
app.config.ai_claude_multi_mode()
} else {
crate::config::ClaudeMultiMode::Off
};
struct ClaudeRender {
text: String,
tier_fg: Color,
tier_range: Option<(usize, usize)>,
underline: (usize, usize),
}
let claude_render: ClaudeRender = match multi_mode {
crate::config::ClaudeMultiMode::Compact => {
let (text, tier_fg) = render_claude_chip_all_accounts(app, &t);
let cols = text.chars().count();
let tier_range = if cols > 4 { Some((3, cols - 1)) } else { None };
ClaudeRender {
text,
tier_fg,
tier_range,
underline: (0, 0),
}
}
crate::config::ClaudeMultiMode::Ticker => {
let n = app.ai_usage_claude_accounts.len();
let idx = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| (d.as_secs() / 4) as usize % n)
.unwrap_or(0);
let acc = &app.ai_usage_claude_accounts[idx];
let letter = account_abbrev(&acc.name);
let res = render_single_account_chip(&acc.usage, mode, &letter, show_reset, &t);
let active_name: Option<String> = app
.config
.claude_accounts()
.into_iter()
.find(|a| a.active)
.map(|a| a.name);
let is_active = active_name.as_deref() == Some(acc.name.as_str());
let underline = if is_active { (3, 4) } else { (0, 0) };
ClaudeRender {
text: res.text,
tier_fg: res.tier_fg,
tier_range: res.tier_range,
underline,
}
}
crate::config::ClaudeMultiMode::Off => {
let active = app.active_claude_account();
let usage_ref = active.map(|a| &a.usage);
match usage_ref {
Some(u) => {
let res = render_single_account_chip(u, mode, "", show_reset, &t);
ClaudeRender {
text: res.text,
tier_fg: res.tier_fg,
tier_range: res.tier_range,
underline: (0, 0),
}
}
None => ClaudeRender {
text: " \u{F1E00} … ".to_string(),
tier_fg: t.comment,
tier_range: None,
underline: (0, 0),
},
}
}
};
ai_claude_seg_idx = Some(right.len());
let claude_bg = theme::brand_color_for_builtin("claude_code").unwrap_or(t.orange);
let base_fg = if claude_render.tier_range.is_none() {
claude_render.tier_fg
} else {
t.bg_darker
};
let mut seg = Seg::new(claude_render.text, base_fg, claude_bg);
let (u_start, u_end) = claude_render.underline;
seg = seg.underline_range(u_start, u_end);
right.push(seg);
}
if codex_enabled {
let t = theme::cur();
let (text, has_data) = match &app.ai_usage_codex {
Some(u) if u.tokens_today > 0 => (
format!(" \u{F1E01} {} ", format_tokens(u.tokens_today)),
true,
),
Some(_) => (" \u{F1E01} 0 ".to_string(), true),
None => (" \u{F1E01} … ".to_string(), false),
};
ai_codex_seg_idx = Some(right.len());
let fg = if has_data { t.bg_darker } else { t.comment };
right.push(Seg::new(text, fg, t.cyan));
}
app.rects.statusline_coverage_chip = None;
app.ensure_coverage_loaded();
let mut coverage_seg_idx: Option<usize> = None;
let mode = app.config.ui.coverage_chip_mode.as_str();
let has_f = app
.coverage_trends
.as_ref()
.and_then(|t| t.overall_current())
.is_some();
let has_c = app
.istanbul_trends
.as_ref()
.and_then(|t| t.overall_current())
.is_some();
let ticker_show_f = if mode == "ticker" && has_f && has_c {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() / 4 % 2 == 0)
.unwrap_or(true)
} else {
has_f
};
let show_f = matches!(mode, "both" | "feature") || (mode == "ticker" && ticker_show_f);
let show_c = matches!(mode, "both" | "code") || (mode == "ticker" && !ticker_show_f);
let feature_now = app
.coverage_trends
.as_ref()
.and_then(|t| t.overall_current())
.filter(|_| show_f);
let feature_prev = app
.coverage_trends
.as_ref()
.and_then(|t| t.overall_at(7))
.filter(|_| show_f);
let code_now = app
.istanbul_trends
.as_ref()
.and_then(|t| t.overall_current())
.filter(|_| show_c);
let code_prev = app
.istanbul_trends
.as_ref()
.and_then(|t| t.overall_prev())
.filter(|_| show_c);
if feature_now.is_none()
&& let Some(c_now) = code_now
{
let t = theme::cur();
let (delta, fg): (String, Option<Color>) = match code_prev {
Some(p) => {
let d = c_now - p;
let is_zero_delta = d.abs() < 0.05;
let arrow = if is_zero_delta {
"±"
} else if d > 0.0 {
"▲"
} else {
"▼"
};
let color = if is_zero_delta {
None
} else if d > 0.0 {
Some(t.green)
} else {
Some(t.red)
};
(format!(" {arrow}{:.1}", d.abs()), color)
}
None => (String::new(), None),
};
let text = format!(" {} C {:.0}%{} ", coverage_glyph(app), c_now, delta);
coverage_seg_idx = Some(right.len());
let delta_cols = delta.chars().count();
let cols = text.chars().count();
let mut seg = Seg::new(text, t.bg_darker, t.teal);
if delta_cols > 0
&& let Some(fg) = fg
{
let start = cols - 1 - delta_cols; let end = cols - 1;
seg = seg.fg_range(start, end, fg);
}
right.push(seg);
} else if let Some(f_now) = feature_now {
let t = theme::cur();
let (f_delta, fg): (String, Option<Color>) = match feature_prev {
Some(p) => {
let d = f_now - p;
let is_zero_delta = d.abs() < 0.05;
let arrow = if is_zero_delta {
"±"
} else if d > 0.0 {
"▲"
} else {
"▼"
};
let color = if is_zero_delta {
None
} else if d > 0.0 {
Some(t.green)
} else {
Some(t.red)
};
(format!(" {arrow}{:.1}", d.abs()), color)
}
None => (String::new(), None),
};
let code_str = code_now
.map(|c_now| {
let delta = match code_prev {
Some(p) => {
let d = c_now - p;
let arrow = if d.abs() < 0.05 {
"±"
} else if d > 0.0 {
"▲"
} else {
"▼"
};
format!(" {arrow}{:.1}", d.abs())
}
None => String::new(),
};
format!(" · C {:.0}%{}", c_now, delta)
})
.unwrap_or_default();
let text = format!(
" {} F {:.0}%{}{} ",
coverage_glyph(app),
f_now,
f_delta,
code_str
);
coverage_seg_idx = Some(right.len());
let f_delta_cols = f_delta.chars().count();
let cols = text.chars().count();
let mut seg = Seg::new(text, t.bg_darker, t.teal);
if f_delta_cols > 0
&& let Some(fg) = fg
{
let head_cols = format!(" {} F {:.0}%", coverage_glyph(app), f_now)
.chars()
.count();
let start = head_cols;
let end = start + f_delta_cols;
if end <= cols {
seg = seg.fg_range(start, end, fg);
}
}
right.push(seg);
}
const NF_PLAY_BOX: char = '\u{F040E}'; const NF_PLAY: char = '\u{F040A}'; const NF_PAUSE: char = '\u{F03E4}'; const NF_FFWD: char = '\u{F04AD}'; let mixr_is_source = app
.now_playing
.as_ref()
.map(|np| np.source.eq_ignore_ascii_case("mixr"))
.unwrap_or(false);
let has_track_loaded = app
.now_playing
.as_ref()
.map(|np| !np.track.is_empty())
.unwrap_or(false);
let track_is_playing = app
.now_playing
.as_ref()
.map(|np| np.playing)
.unwrap_or(false);
let mut music_action_seg_idx: Option<usize> = None;
let (mixr_play_seg_idx, mixr_ffwd_seg_idx, mixr_seg_idx) = if has_track_loaded {
let np = app
.now_playing
.as_ref()
.expect("guarded by has_track_loaded");
let raw = if mixr_is_source || np.detail.is_empty() {
np.track.clone()
} else {
format!("{} - {}", np.detail, np.track)
};
let clean = raw.split_whitespace().collect::<Vec<_>>().join(" ");
let shown: String = if clean.chars().count() > 28 {
clean.chars().take(28).chain(std::iter::once('…')).collect()
} else {
clean
};
let src_lower = np.source.to_ascii_lowercase();
let (chip_fg, chip_bg) = match src_lower.as_str() {
"spotify" => (Color::Rgb(0x00, 0x00, 0x00), Color::Rgb(0x1D, 0xB9, 0x54)),
"music" => (Color::Rgb(0xFF, 0xFF, 0xFF), Color::Rgb(0xFA, 0x24, 0x3C)),
_ => (
Color::Rgb(0x00, 0x00, 0x00),
Color::Rgb(0xA6, 0xE2, 0x2E), ),
};
let glyph = if track_is_playing { NF_PAUSE } else { NF_PLAY };
let play_idx = right.len();
right.push(Seg::new(format!(" {glyph} "), chip_fg, chip_bg));
let ffwd_idx = right.len();
right.push(Seg::new(format!("{NF_FFWD} "), chip_fg, chip_bg));
let track_idx = right.len();
right.push(Seg::new(format!("{shown} "), chip_fg, chip_bg));
if !arrows {
right.push(Seg::new(
" ".to_string(),
theme::cur().fg,
theme::cur().statusline,
));
}
(Some(play_idx), Some(ffwd_idx), track_idx)
} else {
let source = app.config.ui.preferred_music_app.as_str();
let (source_glyph, chip_bg, chip_fg) = match source {
"spotify" => (
'\u{F1BC}', Color::Rgb(0x1D, 0xB9, 0x54), Color::Rgb(0x00, 0x00, 0x00),
),
"music" => (
'\u{E711}', Color::Rgb(0xFA, 0x24, 0x3C), Color::Rgb(0xFF, 0xFF, 0xFF),
),
_ => (
'\u{F1F00}', Color::Rgb(0xA6, 0xE2, 0x2E), Color::Rgb(0x00, 0x00, 0x00),
),
};
let brand_idx = right.len();
right.push(Seg::new(format!(" {} ", source_glyph), chip_fg, chip_bg));
let play_idx = right.len();
right.push(Seg::new(
format!("{} ", NF_PLAY_BOX), chip_fg,
chip_bg,
));
if !arrows {
right.push(Seg::new(
" ".to_string(),
theme::cur().fg,
theme::cur().statusline,
));
}
music_action_seg_idx = Some(play_idx);
(None, None, brand_idx)
};
let _ = mixr_is_source;
let mut clock_seg_idx: Option<usize> = None;
let mut stress_seg_idx: Option<usize> = None;
let mut lsp_seg_idx: Option<usize> = None;
let mut wrap_seg_idx: Option<usize> = None;
let mut autosave_seg_idx: Option<usize> = None;
let mut filesize_seg_idx: Option<usize> = None;
let mut lncol_seg_idx: Option<usize> = None;
app.rects.statusline_workspace_chip = None;
app.rects.statusline_clock_chip = None;
app.rects.statusline_mixr_chip = None;
app.rects.statusline_music_action_chip = None;
app.rects.statusline_mixr_play_chip = None;
app.rects.statusline_mixr_ffwd_chip = None;
app.rects.statusline_lsp_chip = None;
app.rects.statusline_wrap_chip = None;
app.rects.statusline_autosave_chip = None;
app.rects.statusline_filesize_chip = None;
app.rects.statusline_lncol_chip = None;
let lsp_n = app.lsp.server_count();
if lsp_n > 0 {
lsp_seg_idx = Some(right.len());
right.push(Seg::new(
format!(" LSP {lsp_n} "),
theme::cur().bg_darker,
theme::cur().blue,
));
}
if let Some(title) = app.lsp_progress.values().next()
&& !title.is_empty()
{
let label: String = title.chars().take(28).collect();
progress_seg_idx = Some(right.len());
right.push(Seg::new(
format!(" ⟳ {label} "),
theme::cur().bg_darker,
theme::cur().cyan,
));
}
let bg_n = app.background_task_count();
if bg_n >= 2 {
let ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0);
let frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧"];
let idx = (ms / 100) as usize % frames.len();
let spin = frames[idx];
bg_tasks_seg_idx = Some(right.len());
right.push(Seg::new(
format!(" {spin} {bg_n} "),
theme::cur().bg_darker,
theme::cur().cyan,
));
}
if app.ai_suggestion_in_flight() {
ai_seg_idx = Some(right.len());
right.push(Seg::new(
" \u{F0E2D} AI ".to_string(),
theme::cur().bg_darker,
theme::cur().orange,
));
}
if app.config.ui.wrap {
wrap_seg_idx = Some(right.len());
right.push(Seg::new(
" WRAP ".to_string(),
theme::cur().bg_darker,
theme::cur().purple,
));
}
let autosave = app.config.editor.autosave_secs;
if autosave > 0 {
autosave_seg_idx = Some(right.len());
let label = if nerd {
format!(" \u{F0193} {autosave}s ")
} else {
format!(" save {autosave}s ")
};
right.push(Seg::new(label, theme::cur().bg_darker, theme::cur().green));
}
if let Some(b) = app.active_editor() {
let (row, col) = b.editor.row_col();
let bytes = b.editor.text().len();
let size_label = format_byte_size(bytes);
filesize_seg_idx = Some(right.len());
right.push(Seg::new(
format!(" {size_label} "),
theme::cur().comment,
theme::cur().bg2,
));
lncol_seg_idx = Some(right.len());
right.push(Seg::new(
format!(" Ln {}/{} Col {} ", row + 1, b.editor.line_count(), col + 1,),
theme::cur().fg,
theme::cur().bg2,
));
if b.editor.has_selection() {
let n = b.editor.selected_text().chars().count();
sel_seg_idx = Some(right.len());
right.push(Seg::new(
format!(" Sel {n} "),
theme::cur().bg_darker,
theme::cur().yellow,
));
}
}
if app.config.ui.stress_meter {
let stress = app.stress_score();
let (filled, color) = if stress >= 70 {
(4, theme::cur().red)
} else if stress >= 40 {
(3, theme::cur().orange)
} else if stress >= 20 {
(2, theme::cur().yellow)
} else if stress > 0 {
(1, theme::cur().green)
} else {
(0, theme::cur().comment)
};
let mut bar = String::from(" ");
for i in 0..4 {
bar.push(if i < filled { '\u{2588}' } else { '\u{2591}' });
}
bar.push(' ');
stress_seg_idx = Some(right.len());
right.push(Seg::new(bar, color, theme::cur().bg2));
}
if app.config.ui.clock {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let off_secs = if app.clock_show_utc {
0
} else {
local_tz_offset_secs()
};
let resolved = (now as i64 + off_secs).rem_euclid(86400) as u64;
let hh = (resolved / 3600) % 24;
let mm = (resolved / 60) % 60;
let label = if app.clock_show_utc {
format!(" {hh:02}:{mm:02}Z ")
} else {
format!(" {hh:02}:{mm:02} ")
};
clock_seg_idx = Some(right.len());
right.push(Seg::new(label, theme::cur().comment, theme::cur().bg2));
}
let ws_name = app
.workspace
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("workspace");
let label_text = if app.repos.len() > 1 {
app.repos
.get(app.active_repo)
.map(|r| r.name.clone())
.unwrap_or_else(|| ws_name.to_string())
} else {
ws_name.to_string()
};
let folder_glyph = if nerd { "\u{f07b}" } else { "" };
let workspace_seg_idx: Option<usize> = Some(right.len());
right.push(
Seg::new(
format!("{folder_glyph} {label_text} "),
theme::cur().blue,
theme::cur().bg3,
)
.bold(),
);
let lang = app
.active_editor()
.and_then(|b| b.language_ext.clone())
.unwrap_or_else(|| "—".to_string());
let language_seg_idx: Option<usize> = Some(right.len());
right.push(
Seg::new(
format!(" {lang} "),
theme::cur().bg_darker,
theme::cur().blue,
)
.bold(),
);
let (_, projected_right_used, _) = render_right(&right, arrows, theme::cur().statusline);
let projected_left_used: usize = left.iter().map(|s| s.cols()).sum();
let min_gap = 4_usize;
let avail_for_left = width.saturating_sub(projected_right_used + min_gap);
if projected_left_used > avail_for_left
&& let Some((longest_idx, _)) = left.iter().enumerate().max_by_key(|(_, s)| s.cols())
{
let overshoot = projected_left_used - avail_for_left;
let cur_cols = left[longest_idx].cols();
let target_cols = cur_cols.saturating_sub(overshoot).max(3);
if target_cols < cur_cols {
left[longest_idx].text = ellipsize(&left[longest_idx].text, target_cols);
}
}
let (mut spans, used, left_rects) = render_left(&left, arrows, theme::cur().statusline);
let (right_spans, right_used, right_rects) =
render_right(&right, arrows, theme::cur().statusline);
let right_lane_x = area.x + area.width.saturating_sub(right_used as u16);
if let Some(idx) = workspace_seg_idx
&& let Some(&(start, w)) = right_rects.get(idx)
&& w > 0
{
app.rects.statusline_workspace_chip = Some(Rect {
x: right_lane_x + start as u16,
y: area.y,
width: w as u16,
height: 1,
});
}
if let Some(idx) = clock_seg_idx
&& let Some(&(start, w)) = right_rects.get(idx)
&& w > 0
{
app.rects.statusline_clock_chip = Some(Rect {
x: right_lane_x + start as u16,
y: area.y,
width: w as u16,
height: 1,
});
}
let to_rect = |idx_opt: Option<usize>, rects: &[(usize, usize)]| -> Option<Rect> {
let idx = idx_opt?;
let &(start, w) = rects.get(idx)?;
if w == 0 {
return None;
}
Some(Rect {
x: right_lane_x + start as u16,
y: area.y,
width: w as u16,
height: 1,
})
};
app.rects.statusline_mixr_chip = to_rect(Some(mixr_seg_idx), &right_rects);
app.rects.statusline_music_action_chip = to_rect(music_action_seg_idx, &right_rects);
app.rects.statusline_mixr_play_chip = to_rect(mixr_play_seg_idx, &right_rects);
app.rects.statusline_mixr_ffwd_chip = to_rect(mixr_ffwd_seg_idx, &right_rects);
app.rects.statusline_lsp_chip = to_rect(lsp_seg_idx, &right_rects);
app.rects.statusline_wrap_chip = to_rect(wrap_seg_idx, &right_rects);
app.rects.statusline_autosave_chip = to_rect(autosave_seg_idx, &right_rects);
app.rects.statusline_filesize_chip = to_rect(filesize_seg_idx, &right_rects);
if let Some(idx) = test_seg_idx
&& let Some(&(start, w)) = right_rects.get(idx)
&& w > 0
{
app.rects.statusline_test_chip = Some(Rect {
x: right_lane_x + start as u16,
y: area.y,
width: w as u16,
height: 1,
});
}
app.rects.statusline_lncol_chip = to_rect(lncol_seg_idx, &right_rects);
app.rects.statusline_stress_chip = to_rect(stress_seg_idx, &right_rects);
app.rects.statusline_segment_hits.clear();
for (seg_idx, id) in &dyn_right_placements {
if let Some(rect) = to_rect(Some(*seg_idx), &right_rects) {
app.rects.statusline_segment_hits.push((rect, id.clone()));
}
}
for (seg_idx, id) in &dyn_left_placements {
let Some(&(start, w)) = left_rects.get(*seg_idx) else {
continue;
};
if w == 0 || (start + w) as u16 > area.width {
continue;
}
app.rects.statusline_segment_hits.push((
Rect {
x: area.x + start as u16,
y: area.y,
width: w as u16,
height: 1,
},
id.clone(),
));
}
if let Some(idx) = ai_claude_seg_idx
&& let Some(&(start, w)) = right_rects.get(idx)
&& w > 0
{
app.rects.statusline_ai_claude_chip = Some(Rect {
x: right_lane_x + start as u16,
y: area.y,
width: w as u16,
height: 1,
});
}
if let Some(idx) = ai_codex_seg_idx
&& let Some(&(start, w)) = right_rects.get(idx)
&& w > 0
{
app.rects.statusline_ai_codex_chip = Some(Rect {
x: right_lane_x + start as u16,
y: area.y,
width: w as u16,
height: 1,
});
}
if let Some(idx) = coverage_seg_idx
&& let Some(&(start, w)) = right_rects.get(idx)
&& w > 0
{
app.rects.statusline_coverage_chip = Some(Rect {
x: right_lane_x + start as u16,
y: area.y,
width: w as u16,
height: 1,
});
}
if let Some(idx) = branch_seg_idx
&& let Some(&(start, w)) = left_rects.get(idx)
&& w > 0
&& (start + w) as u16 <= area.width
{
app.rects.statusline_branch_chip = Some(Rect {
x: area.x + start as u16,
y: area.y,
width: w as u16,
height: 1,
});
}
let left_to_rect = |idx_opt: Option<usize>, rects: &[(usize, usize)]| -> Option<Rect> {
let idx = idx_opt?;
let &(start, w) = rects.get(idx)?;
if w == 0 || (start + w) as u16 > area.width {
return None;
}
Some(Rect {
x: area.x + start as u16,
y: area.y,
width: w as u16,
height: 1,
})
};
if let (Some(g_idx), Some(n_idx)) = (file_glyph_idx, file_name_idx)
&& let (Some(&(g_start, _)), Some(&(n_start, n_w))) =
(left_rects.get(g_idx), left_rects.get(n_idx))
{
let total_w = (n_start + n_w).saturating_sub(g_start);
if total_w > 0 && (g_start + total_w) as u16 <= area.width {
app.rects.statusline_file_chip = Some(Rect {
x: area.x + g_start as u16,
y: area.y,
width: total_w as u16,
height: 1,
});
}
}
let diag_first = diag_err_idx.or(diag_warn_idx);
let diag_last = diag_warn_idx.or(diag_err_idx);
if let (Some(first), Some(last)) = (diag_first, diag_last)
&& let (Some(&(first_start, _)), Some(&(last_start, last_w))) =
(left_rects.get(first), left_rects.get(last))
{
let total_w = (last_start + last_w).saturating_sub(first_start);
if total_w > 0 && (first_start + total_w) as u16 <= area.width {
app.rects.statusline_diagnostics_chip = Some(Rect {
x: area.x + first_start as u16,
y: area.y,
width: total_w as u16,
height: 1,
});
}
}
app.rects.statusline_symbol_chip = left_to_rect(symbol_seg_idx, &left_rects);
app.rects.statusline_pr_chip = left_to_rect(pr_seg_idx, &left_rects);
app.rects.statusline_macro_chip = left_to_rect(macro_seg_idx, &left_rects);
app.rects.statusline_find_chip = left_to_rect(find_seg_idx, &left_rects);
app.rects.statusline_language_chip = to_rect(language_seg_idx, &right_rects);
app.rects.statusline_sel_chip = to_rect(sel_seg_idx, &right_rects);
app.rects.statusline_progress_chip = to_rect(progress_seg_idx, &right_rects);
app.rects.statusline_bg_tasks_chip = to_rect(bg_tasks_seg_idx, &right_rects);
app.rects.statusline_ai_chip = to_rect(ai_seg_idx, &right_rects);
if mode_seg_end > mode_seg_start
&& let Some(&(start, _)) = left_rects.get(mode_seg_start)
{
let last = mode_seg_end - 1;
if let Some(&(end_start, end_w)) = left_rects.get(last) {
let total_w = (end_start + end_w).saturating_sub(start);
if total_w > 0 && (start + total_w) as u16 <= area.width {
app.rects.statusline_mode_chip = Some(Rect {
x: area.x + start as u16,
y: area.y,
width: total_w as u16,
height: 1,
});
}
}
}
let mid_avail = width.saturating_sub(used + right_used);
let pending = app.pending_display();
let is_pending = pending
.as_deref()
.map(|s| !s.starts_with(':'))
.unwrap_or(false);
let middle = if is_pending {
pending.unwrap_or_default()
} else {
String::new()
};
let mid_text: String = {
let m = if middle.is_empty() {
String::new()
} else {
format!(" {middle} ")
};
let mc = m.chars().count();
if mc >= mid_avail {
m.chars().take(mid_avail).collect()
} else {
let total = mid_avail - mc;
let lp = total / 2;
format!("{}{}{}", " ".repeat(lp), m, " ".repeat(total - lp))
}
};
let mid_style = if is_pending {
Style::default()
.fg(theme::cur().yellow)
.bg(theme::cur().statusline)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
.fg(theme::cur().comment)
.bg(theme::cur().statusline)
};
spans.push(Span::styled(mid_text, mid_style));
spans.extend(right_spans);
frame.render_widget(Paragraph::new(Line::from(spans)), area);
}
fn render_left(
segs: &[Seg],
arrows: bool,
tail_bg: Color,
) -> (Vec<Span<'static>>, usize, Vec<(usize, usize)>) {
let mut out = Vec::new();
let mut used = 0;
let mut seg_rects: Vec<(usize, usize)> = Vec::with_capacity(segs.len());
for (i, s) in segs.iter().enumerate() {
let start = used;
for span in s.to_spans() {
out.push(span);
}
used += s.cols();
seg_rects.push((start, s.cols()));
let next_bg = segs.get(i + 1).map(|n| n.bg).unwrap_or(tail_bg);
if arrows && next_bg != s.bg {
out.push(Span::styled(
PL_RIGHT,
Style::default().fg(s.bg).bg(next_bg),
));
used += 1;
}
}
(out, used, seg_rects)
}
fn render_right(
segs: &[Seg],
arrows: bool,
head_bg: Color,
) -> (Vec<Span<'static>>, usize, Vec<(usize, usize)>) {
let mut out = Vec::new();
let mut used = 0;
let mut seg_rects: Vec<(usize, usize)> = Vec::with_capacity(segs.len());
for (i, s) in segs.iter().enumerate() {
let prev_bg = if i == 0 { head_bg } else { segs[i - 1].bg };
if arrows && prev_bg != s.bg {
out.push(Span::styled(PL_LEFT, Style::default().fg(s.bg).bg(prev_bg)));
used += 1;
}
let start = used;
for span in s.to_spans() {
out.push(span);
}
used += s.cols();
seg_rects.push((start, s.cols()));
}
(out, used, seg_rects)
}
fn mode_chip(app: &App) -> (&'static str, Color) {
match app.editing_mode() {
EditingMode::Insert => ("INSERT", theme::cur().green),
EditingMode::Replace => ("REPLACE", theme::cur().orange),
EditingMode::Visual => ("VISUAL", theme::cur().purple),
EditingMode::VisualLine => ("V-LINE", theme::cur().purple),
EditingMode::VisualBlock => ("V-BLOCK", theme::cur().purple),
EditingMode::Normal => ("NORMAL", theme::cur().red),
EditingMode::None => match app.focus {
Focus::Tree => ("TREE", theme::cur().blue),
Focus::Pane => {
if app.active_editor().map(|b| b.read_only).unwrap_or(true) {
("VIEW", theme::cur().cyan)
} else {
("EDIT", theme::cur().green)
}
}
Focus::RightPanel => ("PANEL", theme::cur().cyan),
Focus::BottomPanel => ("BOTTOM", theme::cur().cyan),
},
}
}
fn format_byte_size(bytes: usize) -> String {
if bytes < 1024 {
format!("{bytes}B")
} else if bytes < 1024 * 1024 {
let kb = bytes as f64 / 1024.0;
if kb < 10.0 {
format!("{kb:.1}K")
} else {
format!("{}K", kb as usize)
}
} else {
let mb = bytes as f64 / (1024.0 * 1024.0);
if mb < 10.0 {
format!("{mb:.1}M")
} else {
format!("{}M", mb as usize)
}
}
}
fn coverage_glyph(app: &crate::app::App) -> String {
const FALLBACK: &str = "\u{F437}";
app.config
.ui
.integration_icons
.iter()
.find(|ic| ic.id == "tattle_coverage")
.map(|ic| ic.glyph.clone())
.filter(|g| !g.is_empty())
.unwrap_or_else(|| FALLBACK.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn format_byte_size_picks_the_right_unit() {
assert_eq!(format_byte_size(0), "0B");
assert_eq!(format_byte_size(512), "512B");
assert_eq!(format_byte_size(1023), "1023B");
assert_eq!(format_byte_size(1024), "1.0K");
assert_eq!(format_byte_size(1536), "1.5K");
assert_eq!(format_byte_size(20 * 1024), "20K");
assert_eq!(format_byte_size(1024 * 1024), "1.0M");
assert_eq!(format_byte_size(20 * 1024 * 1024), "20M");
}
#[test]
fn draw_paints_the_line_column_chip() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let d = tempfile::tempdir().unwrap();
let ws = d.path().to_path_buf();
std::fs::write(ws.join("note.txt"), "one\ntwo\nthree\n").unwrap();
let mut app = App::new(ws.clone(), crate::config::Config::default()).unwrap();
app.open_path(&ws.join("note.txt"));
let mut term = Terminal::new(TestBackend::new(120, 1)).unwrap();
term.draw(|f| draw(f, &mut app, f.area())).unwrap();
let buf = term.backend().buffer();
let row: String = (0..buf.area.width).map(|x| buf[(x, 0)].symbol()).collect();
assert!(
row.contains("Ln 1/"),
"statusline missing line chip: {row:?}"
);
assert!(
row.contains("Col 1"),
"statusline missing column chip: {row:?}"
);
}
}