use std::collections::HashSet;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use super::icons::IconSet;
use super::layout::{Composition, closest_composition, compute_layout, curated_layouts};
use super::panel::{PanelState, render_panel};
use super::theme::Theme;
const PIN_RATIO: f64 = 2.0;
pub struct EventsGrid<'a> {
pub panels: Vec<PanelState<'a>>,
pub theme: &'a Theme,
pub icons: &'a IconSet,
pub composition: Composition,
pub focused: Option<usize>,
pub layout_cycle_index: usize,
}
impl<'a> EventsGrid<'a> {
#[must_use]
pub const fn new(theme: &'a Theme, icons: &'a IconSet) -> Self {
Self {
panels: Vec::new(),
theme,
icons,
composition: Composition(vec![]),
focused: None,
layout_cycle_index: 0,
}
}
pub fn open_panel(&mut self, session_id: String) -> usize {
if let Some(idx) = self.panel_for_session(&session_id) {
return idx;
}
let panel = PanelState::new(session_id, self.theme, self.icons);
self.panels.push(panel);
let new_count = self.panels.len();
self.composition = closest_composition(&self.composition, new_count);
self.sync_cycle_index();
if new_count == 1 {
self.focused = Some(0);
}
new_count - 1
}
pub fn close_panel(&mut self, index: usize) {
if index >= self.panels.len() {
return;
}
self.panels.remove(index);
let new_count = self.panels.len();
self.composition = closest_composition(&self.composition, new_count);
self.sync_cycle_index();
if new_count == 0 {
self.focused = None;
return;
}
if let Some(focused) = self.focused {
if index == focused {
self.focused = Some(index.min(new_count - 1));
} else if index < focused {
self.focused = Some(focused - 1);
}
}
}
pub const fn focus_next(&mut self) {
if self.panels.is_empty() {
return;
}
self.focused = Some(match self.focused {
Some(idx) => (idx + 1) % self.panels.len(),
None => 0,
});
}
pub const fn focus_prev(&mut self) {
if self.panels.is_empty() {
return;
}
let len = self.panels.len();
self.focused = Some(match self.focused {
Some(0) | None => len - 1,
Some(idx) => idx - 1,
});
}
pub const fn focus_panel(&mut self, index: usize) {
if index < self.panels.len() {
self.focused = Some(index);
}
}
pub fn toggle_pin(&mut self) {
if let Some(idx) = self.focused
&& let Some(panel) = self.panels.get_mut(idx)
{
panel.pinned = !panel.pinned;
}
}
pub fn clear_pins(&mut self) {
for panel in &mut self.panels {
panel.pinned = false;
}
}
fn sync_cycle_index(&mut self) {
let layouts = curated_layouts(self.panels.len());
self.layout_cycle_index = layouts
.iter()
.position(|c| *c == self.composition)
.unwrap_or(0);
}
pub fn cycle_layout(&mut self) {
let layouts = curated_layouts(self.panels.len());
if layouts.is_empty() {
return;
}
self.layout_cycle_index = (self.layout_cycle_index + 1) % layouts.len();
self.composition = layouts[self.layout_cycle_index].clone();
}
#[must_use]
pub fn focused_panel(&self) -> Option<&PanelState<'a>> {
self.focused.and_then(|idx| self.panels.get(idx))
}
pub fn focused_panel_mut(&mut self) -> Option<&mut PanelState<'a>> {
self.focused.and_then(|idx| self.panels.get_mut(idx))
}
#[must_use]
pub fn pinned_indices(&self) -> HashSet<usize> {
self.panels
.iter()
.enumerate()
.filter(|(_, p)| p.pinned)
.map(|(i, _)| i)
.collect()
}
#[must_use]
pub fn panel_for_session(&self, session_id: &str) -> Option<usize> {
self.panels.iter().position(|p| p.session_id == session_id)
}
}
pub fn render_grid(grid: &EventsGrid<'_>, area: Rect, buf: &mut Buffer) {
if grid.panels.is_empty() {
return;
}
let pinned = grid.pinned_indices();
let layout = compute_layout(area, &grid.composition, &pinned, PIN_RATIO);
for panel_rect in &layout.panels {
if panel_rect.index >= grid.panels.len() {
continue;
}
let is_focused = grid.focused == Some(panel_rect.index);
render_panel(
&grid.panels[panel_rect.index],
panel_rect.rect,
buf,
is_focused,
);
}
}
#[cfg(test)]
#[allow(
clippy::expect_used,
reason = "tests use expect for readable assertions"
)]
mod tests {
use super::*;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use crate::config::IconConfig;
use crate::session::SessionMessage;
fn test_theme() -> Theme {
Theme::new()
}
fn test_icons() -> IconSet {
IconSet::from_config(IconConfig::default())
}
fn make_message(method: &str) -> SessionMessage {
SessionMessage {
id: 0,
r#type: "lsp".to_string(),
method: method.to_string(),
server: "rust-analyzer".to_string(),
client: "catenary".to_string(),
request_id: None,
parent_id: None,
timestamp: chrono::Utc::now(),
payload: serde_json::Value::Object(serde_json::Map::new()),
}
}
fn buffer_to_string(buf: &Buffer) -> String {
let mut s = String::new();
for y in 0..buf.area.height {
for x in 0..buf.area.width {
let cell = &buf[(x, y)];
s.push_str(cell.symbol());
}
s.push('\n');
}
s
}
#[test]
fn test_grid_open_panel() {
let theme = test_theme();
let icons = test_icons();
let mut grid = EventsGrid::new(&theme, &icons);
let idx = grid.open_panel("session-1".to_string());
assert_eq!(idx, 0);
assert_eq!(grid.panels.len(), 1);
assert_eq!(grid.focused, Some(0));
assert_eq!(grid.composition.total(), 1);
}
#[test]
fn test_grid_open_duplicate() {
let theme = test_theme();
let icons = test_icons();
let mut grid = EventsGrid::new(&theme, &icons);
grid.open_panel("abc".to_string());
let idx = grid.open_panel("abc".to_string());
assert_eq!(idx, 0);
assert_eq!(grid.panels.len(), 1);
}
#[test]
fn test_grid_open_multiple() {
let theme = test_theme();
let icons = test_icons();
let mut grid = EventsGrid::new(&theme, &icons);
grid.open_panel("s1".to_string());
grid.open_panel("s2".to_string());
grid.open_panel("s3".to_string());
assert_eq!(grid.panels.len(), 3);
assert_eq!(grid.composition.total(), 3);
}
#[test]
fn test_grid_close_panel() {
let theme = test_theme();
let icons = test_icons();
let mut grid = EventsGrid::new(&theme, &icons);
grid.open_panel("s1".to_string());
grid.open_panel("s2".to_string());
grid.open_panel("s3".to_string());
grid.close_panel(1);
assert_eq!(grid.panels.len(), 2);
assert_eq!(grid.panels[0].session_id, "s1");
assert_eq!(grid.panels[1].session_id, "s3");
}
#[test]
fn test_grid_close_focused_moves_focus() {
let theme = test_theme();
let icons = test_icons();
let mut grid = EventsGrid::new(&theme, &icons);
grid.open_panel("s1".to_string());
grid.open_panel("s2".to_string());
grid.open_panel("s3".to_string());
grid.focus_panel(1);
assert_eq!(grid.focused, Some(1));
grid.close_panel(1);
assert_eq!(grid.focused, Some(1));
assert_eq!(grid.panels[1].session_id, "s3");
}
#[test]
fn test_grid_close_before_focused() {
let theme = test_theme();
let icons = test_icons();
let mut grid = EventsGrid::new(&theme, &icons);
grid.open_panel("s1".to_string());
grid.open_panel("s2".to_string());
grid.open_panel("s3".to_string());
grid.focus_panel(2);
assert_eq!(grid.focused, Some(2));
grid.close_panel(0);
assert_eq!(grid.focused, Some(1));
assert_eq!(grid.panels[1].session_id, "s3");
}
#[test]
fn test_grid_close_last_panel() {
let theme = test_theme();
let icons = test_icons();
let mut grid = EventsGrid::new(&theme, &icons);
grid.open_panel("s1".to_string());
grid.close_panel(0);
assert!(grid.panels.is_empty());
assert_eq!(grid.focused, None);
}
#[test]
fn test_grid_focus_next() {
let theme = test_theme();
let icons = test_icons();
let mut grid = EventsGrid::new(&theme, &icons);
grid.open_panel("s1".to_string());
grid.open_panel("s2".to_string());
grid.open_panel("s3".to_string());
grid.focus_panel(0);
grid.focus_next();
assert_eq!(grid.focused, Some(1));
grid.focus_next();
assert_eq!(grid.focused, Some(2));
grid.focus_next();
assert_eq!(grid.focused, Some(0));
}
#[test]
fn test_grid_focus_prev() {
let theme = test_theme();
let icons = test_icons();
let mut grid = EventsGrid::new(&theme, &icons);
grid.open_panel("s1".to_string());
grid.open_panel("s2".to_string());
grid.open_panel("s3".to_string());
grid.focus_panel(0);
grid.focus_prev();
assert_eq!(grid.focused, Some(2));
}
#[test]
fn test_grid_toggle_pin() {
let theme = test_theme();
let icons = test_icons();
let mut grid = EventsGrid::new(&theme, &icons);
grid.open_panel("s1".to_string());
grid.open_panel("s2".to_string());
grid.focus_panel(0);
grid.toggle_pin();
assert!(grid.panels[0].pinned);
assert!(!grid.panels[1].pinned);
}
#[test]
fn test_grid_clear_pins() {
let theme = test_theme();
let icons = test_icons();
let mut grid = EventsGrid::new(&theme, &icons);
grid.open_panel("s1".to_string());
grid.open_panel("s2".to_string());
grid.focus_panel(0);
grid.toggle_pin();
grid.focus_panel(1);
grid.toggle_pin();
assert!(grid.panels[0].pinned);
assert!(grid.panels[1].pinned);
grid.clear_pins();
assert!(!grid.panels[0].pinned);
assert!(!grid.panels[1].pinned);
}
#[test]
fn test_grid_cycle_layout() {
let theme = test_theme();
let icons = test_icons();
let mut grid = EventsGrid::new(&theme, &icons);
grid.open_panel("s1".to_string());
grid.open_panel("s2".to_string());
grid.open_panel("s3".to_string());
let initial = grid.composition.clone();
grid.cycle_layout();
assert_ne!(
grid.composition, initial,
"first cycle should change composition"
);
}
#[test]
fn test_grid_render_two_panels() {
let theme = test_theme();
let icons = test_icons();
let mut grid = EventsGrid::new(&theme, &icons);
grid.open_panel("panel-a".to_string());
grid.open_panel("panel-b".to_string());
grid.panels[0].load_messages(vec![make_message("hover")]);
grid.panels[1].load_messages(vec![make_message("definition")]);
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).expect("terminal creation");
terminal
.draw(|f| {
let area = f.area();
render_grid(&grid, area, f.buffer_mut());
})
.expect("draw");
let buf = terminal.backend().buffer().clone();
let content = buffer_to_string(&buf);
assert!(content.contains("panel-a"), "expected panel-a session id");
assert!(content.contains("panel-b"), "expected panel-b session id");
assert!(
content.contains("hover"),
"expected hover method in panel-a"
);
assert!(
content.contains("definition"),
"expected definition method in panel-b"
);
}
#[test]
fn test_grid_pinned_indices() {
let theme = test_theme();
let icons = test_icons();
let mut grid = EventsGrid::new(&theme, &icons);
grid.open_panel("s1".to_string());
grid.open_panel("s2".to_string());
grid.open_panel("s3".to_string());
grid.focus_panel(0);
grid.toggle_pin();
grid.focus_panel(2);
grid.toggle_pin();
let pinned = grid.pinned_indices();
assert!(pinned.contains(&0));
assert!(!pinned.contains(&1));
assert!(pinned.contains(&2));
assert_eq!(pinned.len(), 2);
}
}