use ratatui::{
Frame,
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, HighlightSpacing, List, ListItem, ListState, Paragraph, Wrap},
};
use crate::monitor::memory_tui::MemoryFocus;
use crate::monitor::memory_tui::activity::spinner_tick;
use crate::monitor::memory_tui::state::MemoryTuiState;
use crate::monitor::memory_tui::view::{
ALL_LABEL, KEY_HINT, drawer_detail_body, drawer_panel_lines, help_text, palace_lines_at,
sort_label, stats_lines, title_line,
};
use crate::monitor::tui_common::{
self, ACTIVITY_PERCENT, enter_tui, leave_tui, left_panel_width, panel_block,
};
use crate::monitor::utils::DaemonStatus;
pub async fn run_with_url(base_url: String) -> anyhow::Result<()> {
use crate::monitor::memory_client::MemoryClient;
use crate::monitor::memory_tui::event_loop::run_loop;
let mut client = MemoryClient::new(base_url.clone());
let mut state = MemoryTuiState::new(base_url);
let mut terminal = enter_tui()?;
let result = run_loop(&mut terminal, &mut state, &mut client).await;
leave_tui(&mut terminal)?;
result
}
pub fn render(frame: &mut Frame, state: &mut MemoryTuiState) {
let area = frame.area();
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), Constraint::Min(4), Constraint::Length(3), Constraint::Length(1), ])
.split(area);
frame.render_widget(
Paragraph::new(Line::from(Span::styled(
title_line(state),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
))),
rows[0],
);
let split = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Length(left_panel_width(area.width)),
Constraint::Min(10),
])
.split(rows[1]);
let list_focused = state.focus == MemoryFocus::List;
let now = chrono::Utc::now();
let tick = spinner_tick();
let rendered_rows = palace_lines_at(state, now, tick);
let palace_items: Vec<ListItem> = rendered_rows
.iter()
.map(|row| {
let style = if row.is_header || row.is_all {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else if let Some(color) = row.activity.and_then(|a| a.color()) {
Style::default().fg(color)
} else {
Style::default()
};
ListItem::new(Line::from(Span::styled(row.text.clone(), style)))
})
.collect();
let show_filter_bar = state.filter_active || !state.filter.is_empty();
let (filter_area, list_area) = if show_filter_bar {
let inner = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(3)])
.split(split[0]);
(Some(inner[0]), inner[1])
} else {
(None, split[0])
};
if let Some(area) = filter_area {
let border_color = if state.filter_active {
Color::Yellow
} else {
Color::DarkGray
};
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled("🔍 ", Style::default().fg(Color::Yellow)),
Span::styled(
state.filter.as_str().to_string(),
Style::default().fg(Color::White),
),
Span::styled(
if state.filter_active { "_" } else { "" },
Style::default().fg(Color::Cyan),
),
]))
.block(
Block::default()
.borders(Borders::ALL)
.border_style(
Style::default()
.fg(border_color)
.add_modifier(Modifier::BOLD),
)
.title(Span::styled(
" FILTER ",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)),
),
area,
);
}
let palace_visible = list_area.height.saturating_sub(2) as usize;
let visible_row = rendered_rows
.iter()
.position(|row| row.selected && !row.is_header)
.unwrap_or(0);
state.sync_scroll_to(visible_row, palace_visible);
let palace_title = format!("PALACES [{}]", sort_label(state.sort_key));
let highlight_style = Style::default()
.fg(Color::Black)
.bg(Color::Cyan)
.add_modifier(Modifier::BOLD);
let mut palace_state = ListState::default()
.with_offset(state.scroll_offset)
.with_selected(Some(visible_row));
frame.render_stateful_widget(
List::new(palace_items)
.block(panel_block(&palace_title, list_focused))
.highlight_style(highlight_style)
.highlight_symbol("> ")
.highlight_spacing(HighlightSpacing::Always),
list_area,
&mut palace_state,
);
if state.drawer_detail_open {
let right_split = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(40), Constraint::Percentage(60)])
.split(split[1]);
render_activity_and_stats(frame, state, right_split[0]);
render_detail_pane(frame, state, right_split[1]);
} else {
render_activity_and_stats(frame, state, split[1]);
}
let input_focused = state.focus == MemoryFocus::Input;
let cursor = if input_focused { "_" } else { "" };
let input_style = if input_focused {
Style::default().fg(Color::Cyan)
} else {
Style::default().fg(Color::DarkGray)
};
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled("RECALL ▶ ", Style::default().fg(Color::Yellow)),
Span::styled(format!("{}{cursor}", state.input), input_style),
]))
.block(panel_block("RECALL", input_focused)),
rows[2],
);
frame.render_widget(
Paragraph::new(Line::from(Span::styled(
KEY_HINT,
Style::default().fg(Color::DarkGray),
))),
rows[3],
);
if state.show_help {
tui_common::render_help_overlay(frame, &help_text());
}
}
fn render_activity_and_stats(frame: &mut Frame, state: &MemoryTuiState, area: Rect) {
let right = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage(ACTIVITY_PERCENT),
Constraint::Percentage(100 - ACTIVITY_PERCENT),
])
.split(area);
let scope = state.scope_filter();
let drawer_pane_focused = state.focus == MemoryFocus::DrawerPane;
let activity_title = match scope {
Some(id) if drawer_pane_focused => format!("DRAWER ▶ {id}"),
Some(id) => format!("ACTIVITY — {id}"),
None if drawer_pane_focused => format!("DRAWER ▶ {ALL_LABEL}"),
None => format!("ACTIVITY — {ALL_LABEL}"),
};
let activity_height = right[0].height.saturating_sub(2) as usize;
let drawer_total = state
.selected
.checked_sub(1)
.and_then(|i| state.palaces.get(i))
.map(|p| p.drawer_count)
.unwrap_or(0);
let drawer_lines = drawer_panel_lines(state, drawer_total);
if !drawer_lines.is_empty() {
let take = activity_height.max(1);
let visible_lines: Vec<String> = drawer_lines.into_iter().take(take).collect();
let items: Vec<ListItem> = visible_lines
.iter()
.map(|s| ListItem::new(s.clone()))
.collect();
let selected_row = if drawer_pane_focused && !state.drawer_list.drawers.is_empty() {
Some((state.drawer_cursor + 1).min(visible_lines.len().saturating_sub(1)))
} else {
None
};
let highlight_style = Style::default()
.fg(Color::Black)
.bg(Color::Cyan)
.add_modifier(Modifier::BOLD);
let mut list_state = ListState::default().with_selected(selected_row);
frame.render_stateful_widget(
List::new(items)
.block(panel_block(&activity_title, drawer_pane_focused))
.highlight_style(highlight_style)
.highlight_symbol("> ")
.highlight_spacing(HighlightSpacing::Always),
right[0],
&mut list_state,
);
} else {
let fallback_items: Vec<ListItem> = if state.log.has_scoped(scope) {
state
.log
.tail_scoped(scope, activity_height.max(1))
.map(|line| ListItem::new(line.as_str()))
.collect()
} else if state.daemon_status == DaemonStatus::Connecting {
vec![ListItem::new("Loading…")]
} else {
vec![ListItem::new("(no activity yet)")]
};
frame.render_widget(
List::new(fallback_items).block(panel_block(&activity_title, drawer_pane_focused)),
right[0],
);
}
let stats_items: Vec<ListItem> = stats_lines(state).into_iter().map(ListItem::new).collect();
frame.render_widget(
List::new(stats_items).block(panel_block("STATISTICS", false)),
right[1],
);
}
fn render_detail_pane(frame: &mut Frame, state: &MemoryTuiState, area: Rect) {
let id_prefix = state
.drawer_list
.drawers
.get(state.drawer_detail_idx)
.map(|d| {
let n = d.id.len().min(8);
d.id[..n].to_string()
})
.unwrap_or_default();
let title = if id_prefix.is_empty() {
" DETAIL ".to_string()
} else {
format!(" DETAIL — {id_prefix} ")
};
let border_style = Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD);
let block = Block::default()
.borders(Borders::ALL)
.border_style(border_style)
.title(Span::styled(
title,
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
));
let body = drawer_detail_body(state);
let para = Paragraph::new(body)
.style(Style::default().fg(Color::White))
.wrap(Wrap { trim: false })
.scroll((state.drawer_detail_scroll as u16, 0))
.block(block);
frame.render_widget(para, area);
}