use jiff::Zoned;
use ratatui::{
Frame,
buffer::Buffer,
layout::Rect,
style::{Modifier, Style, Stylize},
text::{Line, Span},
widgets::Widget,
};
use super::{
ACCENT, BAR, chrome,
text::{compact_size, fit, highlight},
time::{age_group, age_label},
};
use crate::{app::App, index::Session};
pub(crate) struct Columns {
title: usize,
project: usize,
model: usize,
size: usize,
}
impl Columns {
const TURNS_AND_AGE: usize = 5 + 1 + 6;
pub(crate) fn for_width(width: u16) -> Self {
let width = width as usize;
let project = match width {
90 .. => 24,
60 .. => 16,
_ => 0,
};
let model = if width >= 78 { 10 } else { 0 };
let size = if width >= 100 { 5 } else { 0 };
let shown = [project, model, size].iter().filter(|&&c| c > 0).count();
let margins = 3 + shown + 1 + 1;
let fixed = margins + project + model + size + Self::TURNS_AND_AGE;
return Self {
title: width.saturating_sub(fixed).max(8),
project,
model,
size,
};
}
fn cells(&self, project: Span, model: Span, size: Span, turns_and_age: Span) -> Vec<Span<'static>> {
let left = |width: usize, cell: Span| Span::styled(fit(&cell.content, width), cell.style);
let right = |width: usize, cell: Span| Span::styled(format!("{:>width$}", cell.content), cell.style);
return [
(self.project > 0).then(|| left(self.project, project)),
(self.model > 0).then(|| left(self.model, model)),
(self.size > 0).then(|| right(self.size, size)),
Some(right(Self::TURNS_AND_AGE, turns_and_age)),
]
.into_iter()
.flatten()
.flat_map(|cell| [Span::raw(" "), cell])
.collect();
}
}
pub(crate) fn column_labels(buf: &mut Buffer, area: Rect, columns: &Columns) {
let mut spans: Vec<Span> = vec![" ".into(), fit("title", columns.title).dim()];
spans.extend(columns.cells(
"project".dim(),
"model".dim(),
"size".dim(),
format!("{:>5} {:>6}", "turns", "age").dim(),
));
Line::from(spans).render(area, buf);
}
enum Slot {
Gap,
Header(&'static str),
Item(usize),
}
fn slots(app: &App, grouped: bool, now: &Zoned) -> Vec<Slot> {
if !grouped {
return app.matches.iter().map(|&i| Slot::Item(i)).collect();
}
let group = |i: &usize| age_group(&app.sessions[*i].updated, now);
return app
.matches
.chunk_by(|a, b| group(a) == group(b))
.enumerate()
.flat_map(|(n, chunk)| {
let gap = (n > 0).then_some(Slot::Gap);
let items = chunk.iter().map(|&i| Slot::Item(i));
return gap.into_iter().chain([Slot::Header(group(&chunk[0]))]).chain(items);
})
.collect();
}
pub(crate) fn draw(frame: &mut Frame, app: &App, selected: usize, scroll: &mut u16, area: Rect, columns: &Columns) {
let now = Zoned::now();
let slots = slots(app, app.search.is_empty(), &now);
if slots.is_empty() {
let message = if app.sessions.is_empty() { "no chats yet" } else { "nothing matches" };
frame.render_widget(Line::from(message.dim().italic()).centered(), Rect { y: area.y + 1, height: 1, ..area });
return;
}
let current = app.matches.get(selected).copied();
let cursor = slots.iter().position(|slot| matches!(slot, Slot::Item(i) if Some(*i) == current)).unwrap_or(0);
let from = if selected == 0 { 0 } else { *scroll };
*scroll = scroll_to_show(from, cursor as u16, area.height, slots.len() as u16);
let scroll = *scroll as usize;
let list = Rect {
width: area.width.saturating_sub(1),
..area
};
for (index, slot) in slots.iter().enumerate().skip(scroll).take(area.height as usize) {
let at = Rect::new(list.x, area.y + (index - scroll) as u16, list.width, 1);
match slot {
Slot::Gap => {}
Slot::Header(label) => chrome::hrule(frame.buffer_mut(), at, Some(label)),
Slot::Item(i) => {
let session = &app.sessions[*i];
let hits = app.search.match_indices(&session.title);
frame.render_widget(item_line(session, index == cursor, &hits, columns, &now), at);
}
}
}
scrollbar(
frame.buffer_mut(),
Rect { x: area.right() - 1, width: 1, ..area },
scroll as u16,
slots.len() as u16,
);
}
fn scroll_to_show(mut scroll: u16, cursor: u16, height: u16, total: u16) -> u16 {
let margin = 2.min(height / 4);
if cursor + 1 + margin > scroll + height {
scroll = cursor + 1 + margin - height;
}
if cursor < scroll + margin {
scroll = cursor.saturating_sub(margin);
}
return scroll.min(total.saturating_sub(height));
}
fn scrollbar(buf: &mut Buffer, area: Rect, scroll: u16, total: u16) {
if total <= area.height || area.height == 0 {
return;
}
let track = area.height as u32;
let thumb = ((track * track) / total as u32).max(1);
let top = (scroll as u32 * (track - thumb)) / (total - area.height) as u32;
for i in 0 .. track {
let on = i >= top && i < top + thumb;
let style = if on { Style::new().fg(ACCENT) } else { Style::new().dim() };
buf[(area.x, area.y + i as u16)].set_symbol(if on { "┃" } else { "│" }).set_style(style);
}
}
fn item_line(s: &Session, selected: bool, hits: &[u32], columns: &Columns, now: &Zoned) -> Line<'static> {
let (bar, title_style, meta) = if selected {
(BAR.fg(ACCENT).bold(), Style::new().fg(ACCENT).bold(), Style::new())
}
else {
(" ".into(), Style::new(), Style::new().dim())
};
let project_style = if s.cwd.is_dir() { meta } else { meta.add_modifier(Modifier::CROSSED_OUT) };
let project = match &s.branch {
Some(branch) => format!("{} ⎇ {branch}", s.project()),
None => s.project(),
};
let mut spans: Vec<Span> = vec![" ".into(), bar, " ".into()];
spans.extend(highlight(&fit(&s.title, columns.title), hits, title_style));
spans.extend(columns.cells(
Span::styled(project, project_style),
Span::styled(s.model.clone().unwrap_or_default(), meta),
Span::styled(compact_size(s.bytes), meta),
Span::styled(format!("{:>5} {:>6}", s.turns, age_label(&s.updated, now)), meta),
));
return Line::from(spans);
}