use std::io::{self, Stdout};
use crossterm::{
event::{DisableMouseCapture, EnableMouseCapture},
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use ratatui::{
Frame, Terminal,
backend::CrosstermBackend,
layout::Rect,
style::{Color, Modifier, Style},
text::Span,
widgets::{Block, Borders, Clear, Paragraph},
};
pub const ALL_SENTINEL: &str = "__all__";
pub const ACTIVITY_PERCENT: u16 = 60;
pub const LEFT_PANEL_MAX: u16 = 28;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ThreeWaySortKey {
#[default]
Activity,
Name,
Count,
}
impl ThreeWaySortKey {
pub fn next(self) -> Self {
match self {
Self::Activity => Self::Name,
Self::Name => Self::Count,
Self::Count => Self::Activity,
}
}
pub fn label(self, labels: &[&'static str; 3]) -> &'static str {
match self {
Self::Activity => labels[0],
Self::Name => labels[1],
Self::Count => labels[2],
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ListFocus {
#[default]
List,
Input,
}
impl ListFocus {
pub fn toggled(self) -> Self {
match self {
Self::List => Self::Input,
Self::Input => Self::List,
}
}
}
pub trait ListItem {
fn id(&self) -> &str;
fn name(&self) -> &str;
fn project(&self) -> &str;
fn activity_ts(&self) -> Option<chrono::DateTime<chrono::Utc>>;
fn count(&self) -> u64;
}
pub fn matches_filter<T: ListItem>(item: &T, filter_lower: &str) -> bool {
if filter_lower.is_empty() {
return true;
}
item.name().to_lowercase().contains(filter_lower)
|| item.project().to_lowercase().contains(filter_lower)
}
pub fn filtered_sorted<T: ListItem + Clone>(
items: &[T],
filter: &str,
sort_key: ThreeWaySortKey,
) -> Vec<T> {
let filter_lower = filter.to_lowercase();
let mut rows: Vec<T> = items
.iter()
.filter(|item| matches_filter(*item, &filter_lower))
.cloned()
.collect();
match sort_key {
ThreeWaySortKey::Activity => {
rows.sort_by(|a, b| match (a.activity_ts(), b.activity_ts()) {
(Some(x), Some(y)) => y.cmp(&x).then_with(|| b.count().cmp(&a.count())),
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => b.count().cmp(&a.count()),
});
}
ThreeWaySortKey::Name => rows.sort_by(|a, b| a.name().cmp(b.name())),
ThreeWaySortKey::Count => rows.sort_by_key(|b| std::cmp::Reverse(b.count())),
}
rows
}
pub fn visible_ids<T: ListItem + Clone>(
items: &[T],
filter: &str,
sort_key: ThreeWaySortKey,
group_by_project: bool,
) -> Vec<String> {
let visible = filtered_sorted(items, filter, sort_key);
let mut ids = Vec::with_capacity(visible.len() + 1);
ids.push(ALL_SENTINEL.to_string());
if group_by_project {
let mut seen: Vec<String> = Vec::new();
for item in &visible {
let proj = item.project().to_string();
if !seen.iter().any(|s| s == &proj) {
seen.push(proj);
}
}
for project in &seen {
for item in visible.iter().filter(|i| i.project() == project) {
ids.push(item.id().to_string());
}
}
} else {
for row in &visible {
ids.push(row.id().to_string());
}
}
ids
}
pub fn current_visible_id<T: ListItem>(items: &[T], selected: usize) -> String {
if selected == 0 {
return ALL_SENTINEL.to_string();
}
items
.get(selected - 1)
.map(|i| i.id().to_string())
.unwrap_or_else(|| ALL_SENTINEL.to_string())
}
pub fn id_to_cursor<T: ListItem>(items: &[T], target_id: &str) -> Option<usize> {
if target_id == ALL_SENTINEL {
return Some(0);
}
items
.iter()
.position(|i| i.id() == target_id)
.map(|p| p + 1)
}
pub fn navigate_step<T: ListItem + Clone>(
items: &[T],
selected: usize,
filter: &str,
sort_key: ThreeWaySortKey,
group_by_project: bool,
delta: i32,
) -> usize {
let ids = visible_ids(items, filter, sort_key, group_by_project);
let current = current_visible_id(items, selected);
let Some(pos) = ids.iter().position(|id| id == ¤t) else {
return 0;
};
let new_pos: usize = if delta < 0 {
if pos == 0 {
return selected;
}
pos - 1
} else {
if pos + 1 >= ids.len() {
return selected;
}
pos + 1
};
let new_id = &ids[new_pos];
id_to_cursor(items, new_id).unwrap_or(selected)
}
pub fn navigate_up<T: ListItem + Clone>(
items: &[T],
selected: usize,
filter: &str,
sort_key: ThreeWaySortKey,
group_by_project: bool,
) -> usize {
navigate_step(items, selected, filter, sort_key, group_by_project, -1)
}
pub fn navigate_down<T: ListItem + Clone>(
items: &[T],
selected: usize,
filter: &str,
sort_key: ThreeWaySortKey,
group_by_project: bool,
) -> usize {
navigate_step(items, selected, filter, sort_key, group_by_project, 1)
}
pub fn truncate(s: &str, max: usize) -> String {
if s.chars().count() <= max {
s.to_string()
} else {
let kept: String = s.chars().take(max.saturating_sub(1)).collect();
format!("{kept}…")
}
}
pub fn left_panel_width(width: u16) -> u16 {
LEFT_PANEL_MAX.min(width / 3)
}
pub fn panel_block(name: &str, focused: bool) -> Block<'static> {
let border_style = if focused {
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::DarkGray)
};
Block::default()
.borders(Borders::ALL)
.border_style(border_style)
.title(Span::styled(
format!(" {name} "),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
))
}
pub fn render_help_overlay(frame: &mut Frame, help: &str) {
let area = frame.area();
let w = 60.min(area.width);
let h = 9.min(area.height);
let rect = Rect {
x: area.x + area.width.saturating_sub(w) / 2,
y: area.y + area.height.saturating_sub(h) / 2,
width: w,
height: h,
};
frame.render_widget(Clear, rect);
frame.render_widget(
Paragraph::new(help.to_string())
.style(Style::default().fg(Color::White))
.block(
Block::default()
.borders(Borders::ALL)
.title(" Help — press ? or Esc to close "),
),
rect,
);
}
pub fn enter_tui() -> anyhow::Result<Terminal<CrosstermBackend<Stdout>>> {
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let terminal = Terminal::new(backend)?;
Ok(terminal)
}
pub fn leave_tui(terminal: &mut Terminal<CrosstermBackend<Stdout>>) -> anyhow::Result<()> {
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::monitor::dashboard::{IndexRow, PalaceRow};
#[test]
fn test_three_way_sort_key_cycle() {
assert_eq!(ThreeWaySortKey::default(), ThreeWaySortKey::Activity);
assert_eq!(ThreeWaySortKey::Activity.next(), ThreeWaySortKey::Name);
assert_eq!(ThreeWaySortKey::Name.next(), ThreeWaySortKey::Count);
assert_eq!(ThreeWaySortKey::Count.next(), ThreeWaySortKey::Activity);
}
#[test]
fn test_three_way_sort_key_label() {
let mem = &["Activity", "Name", "Vectors"];
let search = &["Activity", "Name", "Chunks"];
assert_eq!(ThreeWaySortKey::Activity.label(mem), "Activity");
assert_eq!(ThreeWaySortKey::Name.label(mem), "Name");
assert_eq!(ThreeWaySortKey::Count.label(mem), "Vectors");
assert_eq!(ThreeWaySortKey::Count.label(search), "Chunks");
}
#[test]
fn test_list_focus_toggle() {
assert_eq!(ListFocus::default(), ListFocus::List);
assert_eq!(ListFocus::List.toggled(), ListFocus::Input);
assert_eq!(ListFocus::Input.toggled(), ListFocus::List);
}
#[test]
fn test_truncate() {
assert_eq!(truncate("short", 12), "short");
assert_eq!(truncate("a-very-long-id", 8), "a-very-…");
assert_eq!(truncate("", 4), "");
}
#[test]
fn test_left_panel_width() {
assert_eq!(left_panel_width(200), LEFT_PANEL_MAX);
assert_eq!(left_panel_width(60), 20);
assert_eq!(left_panel_width(30), 10);
}
#[test]
fn test_filtered_sorted_palaces() {
let palaces = vec![
PalaceRow {
id: "a".into(),
name: "alpha".into(),
vector_count: 100,
..Default::default()
},
PalaceRow {
id: "b".into(),
name: "beta".into(),
vector_count: 50,
..Default::default()
},
];
let rows = filtered_sorted(&palaces, "", ThreeWaySortKey::Count);
assert_eq!(rows[0].id, "a", "Count sort puts higher count first");
let rows = filtered_sorted(&palaces, "BETA", ThreeWaySortKey::Name);
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].id, "b");
}
#[test]
fn test_filtered_sorted_indexes() {
let indexes = vec![
IndexRow {
id: "alpha".into(),
chunk_count: 100,
..Default::default()
},
IndexRow {
id: "beta".into(),
chunk_count: 50,
..Default::default()
},
];
let rows = filtered_sorted(&indexes, "", ThreeWaySortKey::Name);
assert_eq!(rows[0].id, "alpha");
let rows = filtered_sorted(&indexes, "BETA", ThreeWaySortKey::Name);
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].id, "beta");
}
#[test]
fn test_visible_ids_and_navigation() {
let items = vec![
PalaceRow {
id: "a".into(),
name: "alpha".into(),
vector_count: 10,
..Default::default()
},
PalaceRow {
id: "b".into(),
name: "beta".into(),
vector_count: 5,
..Default::default()
},
];
let ids = visible_ids(&items, "", ThreeWaySortKey::Name, false);
assert_eq!(ids, vec![ALL_SENTINEL, "a", "b"]);
let next = navigate_down(&items, 0, "", ThreeWaySortKey::Name, false);
assert_eq!(next, 1);
let next = navigate_down(&items, 1, "", ThreeWaySortKey::Name, false);
assert_eq!(next, 2);
let next = navigate_down(&items, 2, "", ThreeWaySortKey::Name, false);
assert_eq!(next, 2);
let next = navigate_up(&items, 2, "", ThreeWaySortKey::Name, false);
assert_eq!(next, 1);
let next = navigate_up(&items, 0, "", ThreeWaySortKey::Name, false);
assert_eq!(next, 0);
}
}