#![cfg(feature = "tui")]
use std::{io, time::Duration};
use crossterm::{
event::{self, Event, KeyCode, KeyModifiers},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
backend::CrosstermBackend,
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap},
Frame, Terminal,
};
use crate::diff::DiffTag;
use crate::state::{DiffEvent, INSPECTOR_STATE, Snapshot};
const ACCENT: Color = Color::Rgb(203, 166, 247);
const GREEN: Color = Color::Rgb(166, 227, 161);
const RED: Color = Color::Rgb(243, 139, 168);
const BLUE: Color = Color::Rgb(137, 180, 250);
const CYAN: Color = Color::Rgb(137, 220, 235);
const SUBTEXT: Color = Color::Rgb(166, 173, 200);
const SURFACE: Color = Color::Rgb(49, 50, 68);
#[derive(PartialEq, Clone, Copy)]
enum Panel {
Snapshots,
Diffs,
}
struct App {
panel: Panel,
snap_state: ListState,
diff_state: ListState,
expanded: bool,
scroll: u16,
}
impl App {
fn new() -> Self {
let mut snap_state = ListState::default();
snap_state.select(Some(0));
let mut diff_state = ListState::default();
diff_state.select(Some(0));
Self {
panel: Panel::Snapshots,
snap_state,
diff_state,
expanded: true,
scroll: 0,
}
}
fn selected_snap<'a>(&self, snaps: &'a [Snapshot]) -> Option<&'a Snapshot> {
self.snap_state.selected().and_then(|i| snaps.get(i))
}
fn selected_diff<'a>(&self, diffs: &'a [DiffEvent]) -> Option<&'a DiffEvent> {
self.diff_state.selected().and_then(|i| diffs.get(i))
}
fn move_up(&mut self) {
self.scroll = 0;
let state = self.active_state_mut();
let i = state.selected().unwrap_or(0);
state.select(Some(i.saturating_sub(1)));
}
fn move_down(&mut self, len: usize) {
self.scroll = 0;
let state = self.active_state_mut();
let i = state.selected().unwrap_or(0);
state.select(Some((i + 1).min(len.saturating_sub(1))));
}
fn active_state_mut(&mut self) -> &mut ListState {
match self.panel {
Panel::Snapshots => &mut self.snap_state,
Panel::Diffs => &mut self.diff_state,
}
}
}
pub fn run() -> io::Result<()> {
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let mut app = App::new();
loop {
let snapshots = INSPECTOR_STATE.snapshots();
let diffs = INSPECTOR_STATE.diffs();
terminal.draw(|f| draw(f, &mut app, &snapshots, &diffs))?;
if event::poll(Duration::from_millis(250))? {
if let Event::Key(key) = event::read()? {
let len = match app.panel {
Panel::Snapshots => snapshots.len(),
Panel::Diffs => diffs.len(),
};
match (key.code, key.modifiers) {
(KeyCode::Char('q'), _) | (KeyCode::Esc, _) | (KeyCode::Char('c'), KeyModifiers::CONTROL) => break,
(KeyCode::Tab, _) => {
app.panel = match app.panel {
Panel::Snapshots => Panel::Diffs,
Panel::Diffs => Panel::Snapshots,
};
app.scroll = 0;
}
(KeyCode::Up, _) => app.move_up(),
(KeyCode::Down, _) => app.move_down(len),
(KeyCode::Enter, _) => {
app.expanded = !app.expanded;
app.scroll = 0;
}
(KeyCode::PageUp, _) => app.scroll = app.scroll.saturating_sub(10),
(KeyCode::PageDown, _) => app.scroll += 10,
_ => {}
}
}
}
}
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
Ok(())
}
fn draw(f: &mut Frame, app: &mut App, snapshots: &[Snapshot], diffs: &[DiffEvent]) {
let area = f.area();
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Min(0)])
.split(area);
let tab_str = format!(
" lupa │ {}Snapshots ({}){} │ {}Diffs ({}){} │ ↑↓ navigate Tab switch q quit ",
if app.panel == Panel::Snapshots { "▶ " } else { " " },
snapshots.len(),
if app.panel == Panel::Snapshots { " ◀" } else { " " },
if app.panel == Panel::Diffs { "▶ " } else { " " },
diffs.len(),
if app.panel == Panel::Diffs { " ◀" } else { " " },
);
let header = Paragraph::new(tab_str).style(Style::default().fg(ACCENT).bg(SURFACE));
f.render_widget(header, chunks[0]);
let main = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(30), Constraint::Percentage(70)])
.split(chunks[1]);
match app.panel {
Panel::Snapshots => {
render_snap_list(f, app, snapshots, main[0]);
render_snap_detail(f, app, snapshots, main[1]);
}
Panel::Diffs => {
render_diff_list(f, app, diffs, main[0]);
render_diff_detail(f, app, diffs, main[1]);
}
}
}
fn render_snap_list(f: &mut Frame, app: &mut App, snaps: &[Snapshot], area: Rect) {
let items: Vec<ListItem> = snaps
.iter()
.enumerate()
.map(|(i, s)| ListItem::new(format!(" #{i} {}", s.label)))
.collect();
let list = List::new(items)
.block(Block::default().borders(Borders::ALL).title(" Snapshots ").border_style(Style::default().fg(ACCENT)))
.highlight_style(Style::default().bg(SURFACE).fg(ACCENT).add_modifier(Modifier::BOLD))
.highlight_symbol("▶ ");
f.render_stateful_widget(list, area, &mut app.snap_state);
}
fn render_snap_detail(f: &mut Frame, app: &mut App, snaps: &[Snapshot], area: Rect) {
let content: Vec<Line> = if let Some(snap) = app.selected_snap(snaps) {
let mut lines = vec![
Line::from(vec![
Span::styled("label: ", Style::default().fg(SUBTEXT)),
Span::styled(&snap.label, Style::default().fg(ACCENT).add_modifier(Modifier::BOLD)),
]),
Line::from(vec![
Span::styled("file: ", Style::default().fg(SUBTEXT)),
Span::styled(format!("{}:{}", snap.file, snap.line), Style::default().fg(BLUE)),
]),
Line::from(""),
];
if app.expanded {
lines.extend(highlight_debug(&snap.debug_repr));
} else {
lines.push(Line::from(Span::styled(
" (press Enter to expand)",
Style::default().fg(SUBTEXT),
)));
}
lines
} else {
vec![Line::from(Span::styled(
" No snapshot selected",
Style::default().fg(SUBTEXT),
))]
};
let block = Block::default()
.borders(Borders::ALL)
.title(" Detail (Enter expand/collapse PgUp/PgDn scroll) ")
.border_style(Style::default().fg(ACCENT));
let para = Paragraph::new(content)
.block(block)
.wrap(Wrap { trim: false })
.scroll((app.scroll, 0));
f.render_widget(para, area);
}
fn render_diff_list(f: &mut Frame, app: &mut App, diffs: &[DiffEvent], area: Rect) {
let items: Vec<ListItem> = diffs
.iter()
.enumerate()
.map(|(i, d)| ListItem::new(format!(" #{i} {} → {}", d.old.label, d.new.label)))
.collect();
let list = List::new(items)
.block(Block::default().borders(Borders::ALL).title(" Diffs ").border_style(Style::default().fg(CYAN)))
.highlight_style(Style::default().bg(SURFACE).fg(CYAN).add_modifier(Modifier::BOLD))
.highlight_symbol("▶ ");
f.render_stateful_widget(list, area, &mut app.diff_state);
}
fn render_diff_detail(f: &mut Frame, app: &mut App, diffs: &[DiffEvent], area: Rect) {
let content: Vec<Line> = if let Some(diff) = app.selected_diff(diffs) {
let mut lines = vec![
Line::from(vec![
Span::styled("old: ", Style::default().fg(SUBTEXT)),
Span::styled(&diff.old.label, Style::default().fg(RED)),
Span::raw(" → "),
Span::styled("new: ", Style::default().fg(SUBTEXT)),
Span::styled(&diff.new.label, Style::default().fg(GREEN)),
]),
Line::from(""),
];
for chunk in &diff.chunks {
let (prefix, style) = match chunk.tag {
DiffTag::Insert => ("+ ", Style::default().fg(GREEN)),
DiffTag::Delete => ("- ", Style::default().fg(RED)),
DiffTag::Equal => (" ", Style::default().fg(SUBTEXT)),
};
for text_line in chunk.content.lines() {
lines.push(Line::from(Span::styled(
format!("{prefix}{text_line}"),
style,
)));
}
}
lines
} else {
vec![Line::from(Span::styled(
" No diff selected",
Style::default().fg(SUBTEXT),
))]
};
let block = Block::default()
.borders(Borders::ALL)
.title(" Diff Detail (PgUp/PgDn scroll) ")
.border_style(Style::default().fg(CYAN));
let para = Paragraph::new(content)
.block(block)
.wrap(Wrap { trim: false })
.scroll((app.scroll, 0));
f.render_widget(para, area);
}
fn highlight_debug(src: &str) -> Vec<Line<'static>> {
src.lines()
.map(|line| {
let mut spans = Vec::new();
let mut rest = line.to_owned();
let indent_len = rest.len() - rest.trim_start().len();
if indent_len > 0 {
spans.push(Span::raw(rest[..indent_len].to_owned()));
rest = rest[indent_len..].to_owned();
}
if let Some(colon) = rest.find(':') {
let key = &rest[..colon];
let value = rest[colon + 1..].trim().to_owned();
spans.push(Span::styled(key.to_owned(), Style::default().fg(BLUE)));
spans.push(Span::raw(": "));
color_value(&mut spans, &value);
} else {
color_value(&mut spans, &rest);
}
Line::from(spans)
})
.collect()
}
fn color_value(spans: &mut Vec<Span<'static>>, v: &str) {
if v == "true" || v == "false" {
spans.push(Span::styled(v.to_owned(), Style::default().fg(RED)));
} else if v.starts_with('"') {
spans.push(Span::styled(v.to_owned(), Style::default().fg(GREEN)));
} else if v
.chars()
.next()
.map(|c| c.is_ascii_digit() || c == '-')
.unwrap_or(false)
{
spans.push(Span::styled(v.to_owned(), Style::default().fg(Color::Rgb(250, 179, 135))));
} else if v.chars().next().map(|c| c.is_uppercase()).unwrap_or(false) {
let rest = if let Some(_) = v.strip_suffix('{') {
spans.push(Span::styled(
v[..v.len() - 1].trim_end().to_owned(),
Style::default().fg(CYAN).add_modifier(Modifier::BOLD),
));
Some("{")
} else {
spans.push(Span::styled(v.to_owned(), Style::default().fg(CYAN).add_modifier(Modifier::BOLD)));
None
};
if let Some(r) = rest {
spans.push(Span::styled(r.to_owned(), Style::default().fg(SUBTEXT)));
}
} else {
spans.push(Span::raw(v.to_owned()));
}
}