use ratatui::{
Frame,
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::Paragraph,
};
use crate::ui::theme::Theme;
#[inline]
pub fn filter_chip_bg(t: &Theme) -> Color {
t.bg2
}
#[inline]
pub fn caps_label_style(t: &Theme, bg: Color) -> Style {
Style::default()
.fg(t.comment)
.bg(bg)
.add_modifier(Modifier::BOLD)
}
#[inline]
pub fn caps_subtitle_style(t: &Theme, bg: Color) -> Style {
Style::default()
.fg(t.comment)
.bg(bg)
.add_modifier(Modifier::DIM)
}
pub fn draw_caps_header_with_refresh(
frame: &mut Frame,
area: Rect,
label: &str,
subtitle: Option<&str>,
bg: Color,
t: &Theme,
ascii: bool,
) -> Option<Rect> {
let refresh_text = crate::ui::refresh_glyph::chip_icon_only(ascii);
let refresh_w = refresh_text.chars().count() as u16;
let refresh_x = area.x.saturating_add(area.width.saturating_sub(refresh_w));
let label_w = label.chars().count() as u16;
let fits = area.width >= label_w + refresh_w + 3;
let mut spans = vec![
Span::styled(" ", Style::default().bg(bg)),
Span::styled(label.to_string(), caps_label_style(t, bg)),
];
if let Some(sub) = subtitle
&& !sub.is_empty()
{
spans.push(Span::styled(sub.to_string(), caps_subtitle_style(t, bg)));
}
frame.render_widget(
Paragraph::new(Line::from(spans)),
Rect {
x: area.x,
y: area.y,
width: if fits {
area.width.saturating_sub(refresh_w)
} else {
area.width
},
height: 1,
},
);
if !fits {
return None;
}
let refresh_rect = Rect {
x: refresh_x,
y: area.y,
width: refresh_w,
height: 1,
};
frame.render_widget(
Paragraph::new(Line::from(vec![Span::styled(
refresh_text,
Style::default().fg(t.cyan).bg(bg),
)])),
refresh_rect,
);
Some(refresh_rect)
}
pub fn mode_chip_text(key: &str, value: &str, widest: usize) -> String {
let pad = widest.saturating_sub(value.chars().count());
format!(" {key}: {value}{} ", " ".repeat(pad))
}
pub fn mode_chip_icon(ascii: bool) -> &'static str {
if ascii { " ~ " } else { " \u{f0dc} " }
}
pub fn mode_chip_style(t: &Theme) -> Style {
Style::default()
.fg(t.bg)
.bg(t.cyan)
.add_modifier(Modifier::BOLD)
}
#[allow(clippy::too_many_arguments)]
pub fn draw_caps_header_with_chips(
frame: &mut Frame,
area: Rect,
label: &str,
subtitle: Option<&str>,
chip: Option<&str>,
bg: Color,
t: &Theme,
ascii: bool,
) -> (Option<Rect>, Option<Rect>) {
let refresh_text = crate::ui::refresh_glyph::chip_icon_only(ascii);
let refresh_w = refresh_text.chars().count() as u16;
let label_w = label.chars().count() as u16;
let sub_w = subtitle.map(|s| s.chars().count() as u16).unwrap_or(0);
let refresh_fits = area.width >= label_w + refresh_w + 3;
if !refresh_fits {
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(" ", Style::default().bg(bg)),
Span::styled(label.to_string(), caps_label_style(t, bg)),
])),
area,
);
return (None, None);
}
let sub_fits = area.width >= label_w + sub_w + refresh_w + 3;
let subtitle = if sub_fits { subtitle } else { None };
let sub_w = if sub_fits { sub_w } else { 0 };
let full_w = chip.map(|c| c.chars().count() as u16).unwrap_or(0);
let icon = mode_chip_icon(ascii);
let icon_w = icon.chars().count() as u16;
let (chip, chip_w) = if chip.is_none() {
(None, 0)
} else if area.width >= label_w + sub_w + refresh_w + full_w + 4 {
(chip, full_w)
} else if area.width >= label_w + sub_w + refresh_w + icon_w + 4 {
(Some(icon), icon_w)
} else {
(None, 0)
};
let chip_fits = chip_w > 0;
let refresh_x = area.x + area.width.saturating_sub(refresh_w);
let chip_x = refresh_x.saturating_sub(chip_w + 1);
let mut spans = vec![
Span::styled(" ", Style::default().bg(bg)),
Span::styled(label.to_string(), caps_label_style(t, bg)),
];
if let Some(sub) = subtitle
&& !sub.is_empty()
{
spans.push(Span::styled(sub.to_string(), caps_subtitle_style(t, bg)));
}
frame.render_widget(
Paragraph::new(Line::from(spans)),
Rect {
width: if chip_fits {
chip_x.saturating_sub(area.x)
} else {
area.width.saturating_sub(refresh_w)
},
..area
},
);
let chip_rect = if chip_fits {
let r = Rect {
x: chip_x,
y: area.y,
width: chip_w,
height: 1,
};
frame.render_widget(
Paragraph::new(Line::from(Span::styled(
chip.unwrap_or("").to_string(),
mode_chip_style(t),
))),
r,
);
Some(r)
} else {
None
};
let refresh_rect = Rect {
x: refresh_x,
y: area.y,
width: refresh_w,
height: 1,
};
frame.render_widget(
Paragraph::new(Line::from(Span::styled(
refresh_text,
Style::default().fg(t.cyan).bg(bg),
))),
refresh_rect,
);
(chip_rect, Some(refresh_rect))
}
pub fn list_scroll_window(
scroll: &mut usize,
cursor: usize,
total: usize,
visible_rows: usize,
) -> (usize, usize, bool) {
if visible_rows == 0 || total == 0 {
*scroll = 0;
return (0, 0, false);
}
if cursor < *scroll {
*scroll = cursor;
} else if cursor >= *scroll + visible_rows {
*scroll = cursor + 1 - visible_rows;
}
let max_scroll = total.saturating_sub(visible_rows);
if *scroll > max_scroll {
*scroll = max_scroll;
}
(
*scroll,
visible_rows.min(total - *scroll),
total > visible_rows,
)
}
#[cfg(test)]
mod mode_chip_tests {
use super::*;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
fn render(w: u16, chip: Option<&str>) -> (String, Option<Rect>, Option<Rect>) {
let t = crate::ui::theme::cur();
let mut term = Terminal::new(TestBackend::new(w, 1)).unwrap();
let mut got = (None, None);
term.draw(|f| {
got = draw_caps_header_with_chips(
f,
Rect {
x: 0,
y: 0,
width: w,
height: 1,
},
"FINDINGS",
Some(" (38)"),
chip,
t.bg_darker,
&t,
true,
);
})
.unwrap();
let buf = term.backend().buffer();
let row: String = (0..w).map(|x| buf[(x, 0)].symbol()).collect();
(row, got.0, got.1)
}
#[test]
fn the_chip_paints_its_value_and_returns_a_hit_rect() {
let (row, chip, refresh) = render(60, Some(&mode_chip_text("sort", "Newest first", 12)));
assert!(row.contains("FINDINGS"), "title missing: {row:?}");
assert!(
row.contains("sort: Newest first"),
"the chip's value is not on screen: {row:?}"
);
let chip = chip.expect("no chip rect returned — the chip would be unclickable");
assert!(refresh.is_some(), "refresh chip lost");
let painted: String = row
.chars()
.skip(chip.x as usize)
.take(chip.width as usize)
.collect();
assert!(
painted.contains("Newest first"),
"the returned rect does not cover the chip text: {painted:?}"
);
}
#[test]
fn the_chip_is_reachable_at_the_default_sidebar_width() {
for w in [26u16, 30, 34] {
let (row, chip, refresh) = render(w, Some(&mode_chip_text("sort", "Newest first", 12)));
assert!(
chip.is_some(),
"no sort affordance at all at width {w} (the shipped default \
leaves ~26): {row:?}"
);
assert!(refresh.is_some(), "refresh chip lost at width {w}");
assert!(row.contains("FINDINGS"), "title lost at width {w}: {row:?}");
let r = chip.unwrap();
let painted: String = row
.chars()
.skip(r.x as usize)
.take(r.width as usize)
.collect();
assert!(
!painted.trim().is_empty(),
"the chip rect at width {w} covers only blanks: {painted:?}"
);
}
}
#[test]
fn the_chip_keeps_a_fixed_width_across_modes() {
let widest = crate::ui::list_sort::ListSort::all()
.iter()
.map(|m| m.label().chars().count())
.max()
.unwrap();
let mut rects = Vec::new();
for m in crate::ui::list_sort::ListSort::all() {
let (_, chip, _) = render(60, Some(&mode_chip_text("sort", m.label(), widest)));
rects.push(chip.expect("chip vanished for a mode"));
}
let first = rects[0];
for (m, r) in crate::ui::list_sort::ListSort::all().iter().zip(&rects) {
assert_eq!(
(r.x, r.width),
(first.x, first.width),
"the chip moved or resized for {m:?} — a repeat-click would miss it"
);
}
}
#[test]
fn a_chip_that_does_not_fit_is_dropped_not_clipped() {
let (row, chip, refresh) = render(17, Some(&mode_chip_text("sort", "Newest first", 12)));
assert!(chip.is_none(), "a chip was returned at 17 cells: {row:?}");
assert!(
!row.contains("sort:"),
"the chip painted anyway, clipped: {row:?}"
);
assert!(
refresh.is_some(),
"the refresh chip was dropped before the sort chip: {row:?}"
);
assert!(row.contains("FINDINGS"), "title lost: {row:?}");
}
#[test]
fn a_long_subtitle_drops_itself_before_the_refresh_chip() {
let t = crate::ui::theme::cur();
let mut term = Terminal::new(TestBackend::new(22, 1)).unwrap();
let mut got = (None, None);
term.draw(|f| {
got = draw_caps_header_with_chips(
f,
Rect {
x: 0,
y: 0,
width: 22,
height: 1,
},
"FINDINGS",
Some(" (10 of 92)"),
None,
t.bg_darker,
&t,
true,
);
})
.unwrap();
let buf = term.backend().buffer();
let row: String = (0..22).map(|x| buf[(x, 0)].symbol()).collect();
assert!(
got.1.is_some(),
"the refresh chip was dropped to make room for a count: {row:?}"
);
assert!(row.contains("FINDINGS"), "title lost: {row:?}");
}
#[test]
fn no_chip_leaves_the_refresh_only_header_intact() {
let (row, chip, refresh) = render(60, None);
assert!(chip.is_none());
assert!(refresh.is_some(), "refresh chip missing with no mode chip");
assert!(row.contains("FINDINGS"));
}
}
#[cfg(test)]
mod drop_order_consistency_tests {
#[test]
fn every_panel_drops_its_mode_chip_before_its_refresh_chip() {
for f in ["src/ui/agents_panel.rs", "src/ui/cloud_agents_panel.rs"] {
let src = std::fs::read_to_string(f).unwrap();
assert!(
src.contains("let show_view = ") && src.contains("let show_refresh = "),
"{f} does not compute the two thresholds separately, so one \
chip cannot drop before the other"
);
let refresh_line = src
.lines()
.find(|l| l.trim_start().starts_with("let show_refresh = "))
.unwrap();
assert!(
!refresh_line.contains("view_w"),
"{f}: the refresh threshold still counts the view chip, so \
refresh drops first: {refresh_line}"
);
}
}
}