use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph};
use ratatui::Frame;
use crate::app::App;
use crate::theme;
use crate::types::Zone;
pub fn render_sidebar(frame: &mut Frame, area: Rect, app: &App) {
let t = theme::theme();
let block = Block::default()
.title(" Info ")
.title_style(theme::title_style())
.borders(Borders::ALL)
.border_style(Style::default().fg(t.border));
let inner = block.inner(area);
frame.render_widget(block, area);
let has_scan = app.last_scan.is_some();
let constraints = if has_scan {
vec![
Constraint::Length(5), Constraint::Length(6), Constraint::Length(3), Constraint::Length(3), Constraint::Min(3), ]
} else {
vec![
Constraint::Length(5), Constraint::Length(3), Constraint::Length(3), Constraint::Min(3), ]
};
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints(constraints)
.split(inner);
render_project_section(frame, chunks[0], app, &t);
if has_scan {
render_scan_summary(frame, chunks[1], app, &t);
render_context_zen_section(frame, chunks[2], app, &t);
render_deadlines(frame, chunks[3], &t);
render_quick_actions(frame, chunks[4], &t);
} else {
render_context_zen_section(frame, chunks[1], app, &t);
render_deadlines(frame, chunks[2], &t);
render_quick_actions(frame, chunks[3], &t);
}
}
fn render_project_section(frame: &mut Frame, area: Rect, app: &App, t: &theme::ThemeColors) {
let project_name = app
.project_path
.file_name().map_or_else(|| "project".to_string(), |n| n.to_string_lossy().to_string());
let mut lines = vec![
Line::from(Span::styled(
format!(" {project_name}/"),
Style::default()
.fg(t.fg)
.add_modifier(Modifier::BOLD),
)),
];
if let Some(scan) = &app.last_scan {
let zone_emoji = match scan.score.zone {
Zone::Green => "🟢",
Zone::Yellow => "🟡",
Zone::Red => "🔴",
};
lines.push(Line::from(vec![
Span::raw(" Score: "),
Span::styled(
format!("{:.0}/100", scan.score.total_score),
Style::default().fg(theme::zone_color(scan.score.zone)),
),
Span::raw(format!(" {zone_emoji}")),
]));
lines.push(Line::from(vec![
Span::styled(
format!(" {}✓", scan.score.passed_checks),
Style::default().fg(t.zone_green),
),
Span::raw(" "),
Span::styled(
format!("{}✗", scan.score.failed_checks),
Style::default().fg(t.zone_red),
),
Span::raw(format!(
" {} files",
scan.files_scanned
)),
]));
} else {
lines.push(Line::from(Span::styled(
" No scan yet",
Style::default().fg(t.muted),
)));
}
let p = Paragraph::new(lines);
frame.render_widget(p, area);
}
fn render_scan_summary(frame: &mut Frame, area: Rect, app: &App, t: &theme::ThemeColors) {
let Some(scan) = &app.last_scan else {
return;
};
let items: Vec<ListItem<'_>> = scan
.score
.category_scores
.iter()
.take(area.height as usize)
.map(|cat| {
let failed = cat.obligation_count.saturating_sub(cat.passed_count);
let (icon, color) = if failed == 0 {
("✓", t.zone_green)
} else {
("✗", t.zone_red)
};
ListItem::new(Line::from(vec![
Span::styled(format!(" {icon} "), Style::default().fg(color)),
Span::raw(&cat.category),
]))
})
.collect();
let list = List::new(items).block(
Block::default()
.title(" Checks ")
.title_style(Style::default().fg(t.muted))
.borders(Borders::TOP)
.border_style(Style::default().fg(t.border)),
);
frame.render_widget(list, area);
}
fn render_context_zen_section(frame: &mut Frame, area: Rect, app: &App, t: &theme::ThemeColors) {
let ctx_pct = crate::widgets::context_meter::context_pct(app.messages.len(), 32);
let ctx_color = crate::widgets::context_meter::context_color(ctx_pct);
let zen_status = if app.zen_active {
format!("Zen {}/{}", app.zen_messages_used, app.zen_messages_limit)
} else {
"Zen: off".to_string()
};
let lines = vec![
Line::from(vec![
Span::styled(" Ctx: ", Style::default().fg(t.muted)),
Span::styled(
format!("{ctx_pct}%"),
Style::default().fg(ctx_color),
),
Span::styled(
format!(" {zen_status}"),
Style::default().fg(if app.zen_active { t.zone_green } else { t.muted }),
),
]),
];
let p = Paragraph::new(lines).block(
Block::default()
.borders(Borders::TOP)
.border_style(Style::default().fg(t.border)),
);
frame.render_widget(p, area);
}
fn render_deadlines(frame: &mut Frame, area: Rect, t: &theme::ThemeColors) {
let days_remaining = {
use std::time::{SystemTime, UNIX_EPOCH};
let now_secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
const EU_AI_ACT_SECS: u64 = 1_785_628_800; EU_AI_ACT_SECS.saturating_sub(now_secs) / 86_400
};
let (icon, color) = if days_remaining <= 30 {
("🔴", t.zone_red)
} else if days_remaining <= 90 {
("🟡", t.zone_yellow)
} else {
("⚠", t.accent)
};
let lines = vec![
Line::from(vec![
Span::styled(
format!(" {icon} {days_remaining}d "),
Style::default().fg(color).add_modifier(Modifier::BOLD),
),
Span::styled("EU AI Act", Style::default().fg(t.muted)),
]),
];
let p = Paragraph::new(lines).block(
Block::default()
.title(" Deadlines ")
.title_style(Style::default().fg(t.muted))
.borders(Borders::TOP)
.border_style(Style::default().fg(t.border)),
);
frame.render_widget(p, area);
}
fn render_quick_actions(frame: &mut Frame, area: Rect, t: &theme::ThemeColors) {
let lines = vec![
Line::from(vec![
Span::styled(" /scan ", Style::default().fg(t.accent)),
Span::styled("rescan project", Style::default().fg(t.muted)),
]),
Line::from(vec![
Span::styled(" /help ", Style::default().fg(t.accent)),
Span::styled("all commands", Style::default().fg(t.muted)),
]),
Line::from(vec![
Span::styled(" Ctrl+P ", Style::default().fg(t.accent)),
Span::styled("command palette", Style::default().fg(t.muted)),
]),
];
let p = Paragraph::new(lines).block(
Block::default()
.title(" Actions ")
.title_style(Style::default().fg(t.muted))
.borders(Borders::TOP)
.border_style(Style::default().fg(t.border)),
);
frame.render_widget(p, area);
}
#[cfg(test)]
mod tests {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
use super::*;
fn render_sidebar_to_string(app: &App, width: u16, height: u16) -> String {
let backend = TestBackend::new(width, height);
let mut terminal = Terminal::new(backend).expect("terminal");
terminal
.draw(|frame| render_sidebar(frame, frame.area(), app))
.expect("render");
let buf = terminal.backend().buffer().clone();
let mut output = String::new();
for y in 0..buf.area.height {
for x in 0..buf.area.width {
output.push_str(buf[(x, y)].symbol());
}
output.push('\n');
}
output
}
#[test]
fn snapshot_sidebar_default() {
crate::theme::init_theme("dark");
let app = App::new(crate::config::TuiConfig::default());
let buf = render_sidebar_to_string(&app, 30, 20);
insta::with_settings!({
filters => vec![
(r"⚠ \d+d", "⚠ [Nd]"),
]
}, {
insta::assert_snapshot!(buf);
});
}
#[test]
fn test_sidebar_renders_without_scan() {
crate::theme::init_theme("dark");
let backend = TestBackend::new(30, 20);
let mut terminal = Terminal::new(backend).expect("terminal");
let app = App::new(crate::config::TuiConfig::default());
terminal
.draw(|frame| render_sidebar(frame, frame.area(), &app))
.expect("render");
}
}