use ratatui::{
Frame,
layout::Rect,
style::Style,
text::{Line, Span},
widgets::{Block, Paragraph},
};
use crate::app::App;
use crate::ui::theme;
pub fn draw(frame: &mut Frame, app: &mut App, area: Rect) {
let t = theme::cur();
let bg = t.bg_darker;
frame.render_widget(Block::default().style(Style::default().bg(bg)), area);
if area.height < 2 || area.width < 8 {
return;
}
app.rects.findings_panel_files.clear();
app.rects.findings_panel_filter_input = None;
if !app.findings_panel_scanned_once {
app.findings_panel_refresh();
}
let all_files = app.findings_panel_files_cache.clone();
let root = findings_dir(&app.workspace);
let filter_lc = app.findings_panel_filter.to_ascii_lowercase();
let files: Vec<std::path::PathBuf> = all_files
.iter()
.filter(|p| {
if filter_lc.is_empty() {
return true;
}
let rel = p.strip_prefix(&root).unwrap_or(p);
let name = rel.with_extension("").to_string_lossy().into_owned();
name.to_ascii_lowercase().contains(&filter_lc)
})
.cloned()
.collect();
let subtitle = if filter_lc.is_empty() {
format!(" ({})", all_files.len())
} else {
format!(" ({} of {})", files.len(), all_files.len())
};
app.rects.findings_panel_refresh_chip = crate::ui::panel_chrome::draw_caps_header_with_refresh(
frame,
Rect {
x: area.x,
y: area.y,
width: area.width,
height: 1,
},
"FINDINGS",
Some(&subtitle),
bg,
&t,
app.config.ui.ascii_icons,
);
{
let y_filter = area.y + 1;
if y_filter < area.y + area.height {
let focused = app.findings_panel_filter_focused;
let bg_chip = crate::ui::panel_chrome::filter_chip_bg(&t);
let fg_chip = if app.findings_panel_filter.is_empty() && !focused {
t.comment
} else {
t.fg
};
let display = if app.findings_panel_filter.is_empty() {
crate::ui::filter_placeholder::for_state(focused).to_string()
} else {
app.findings_panel_filter.clone()
};
let cursor = if focused { "\u{258F}" } else { " " };
let pad = (area.width as usize).saturating_sub(3 + display.chars().count() + 1 + 1);
let line = Line::from(vec![
Span::styled(" ", Style::default().bg(bg)),
Span::styled(
format!("{} ", crate::ui::search_glyph::NERD),
Style::default().fg(t.comment).bg(bg_chip),
),
Span::styled(display, Style::default().fg(fg_chip).bg(bg_chip)),
Span::styled(cursor, Style::default().fg(t.cyan).bg(bg_chip)),
Span::styled(" ".repeat(pad), Style::default().bg(bg_chip)),
Span::styled(" ", Style::default().bg(bg)),
]);
let row_rect = Rect {
x: area.x,
y: y_filter,
width: area.width,
height: 1,
};
frame.render_widget(Paragraph::new(line), row_rect);
app.rects.findings_panel_filter_input = Some(row_rect);
}
}
let mut y = area.y + 3;
if files.is_empty() {
let empty_msg = if filter_lc.is_empty() {
"No findings yet.".to_string()
} else {
format!(
"No findings match /{} — {} in workspace",
app.findings_panel_filter,
all_files.len()
)
};
crate::ui::empty_state::draw(
frame,
Rect {
x: area.x,
y,
width: area.width,
height: area.height.saturating_sub(y - area.y),
},
&empty_msg,
Some("Stored under .mnml/findings/*.md"),
bg,
&t,
);
return;
}
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let clamped_cursor = app.findings_panel_cursor.min(files.len().saturating_sub(1));
app.findings_panel_cursor = clamped_cursor;
#[allow(clippy::explicit_counter_loop)]
for (row_i, path) in files
.iter()
.take(area.height.saturating_sub(3) as usize)
.enumerate()
{
if y >= area.y + area.height {
break;
}
let is_focused_row = row_i == clamped_cursor;
let row_bg = if is_focused_row { t.bg2 } else { bg };
let rel = path.strip_prefix(&root).unwrap_or(path);
let name = rel.with_extension("").to_string_lossy().into_owned();
let name = if name.is_empty() {
path.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("finding")
.to_string()
} else {
name
};
let icon = if app.config.ui.ascii_icons {
"◧"
} else {
"\u{F1623}"
};
let age_str: String = std::fs::metadata(path)
.and_then(|m| m.modified())
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| {
let secs = now.saturating_sub(d.as_secs() as i64);
crate::ui::git_graph_view::humanize_age(secs)
})
.unwrap_or_default();
let name_width = (area.width as usize)
.saturating_sub(4)
.saturating_sub(age_str.chars().count())
.saturating_sub(1);
let name_clipped: String = name.chars().take(name_width).collect();
let name_padded = format!("{name_clipped:<width$}", width = name_width);
let row_rect = Rect {
x: area.x,
y,
width: area.width,
height: 1,
};
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(" ", Style::default().bg(bg)),
Span::styled(
if is_focused_row { "▌" } else { " " },
Style::default().fg(t.blue).bg(row_bg),
),
Span::styled(format!("{icon} "), Style::default().fg(t.cyan).bg(row_bg)),
Span::styled(name_padded, Style::default().fg(t.fg).bg(row_bg)),
Span::styled(
format!(" {age_str}"),
Style::default().fg(t.comment).bg(row_bg),
),
])),
row_rect,
);
app.rects
.findings_panel_files
.push((row_rect, path.clone()));
y += 1;
}
}
pub fn findings_dir(workspace: &std::path::Path) -> std::path::PathBuf {
workspace.join(".mnml").join("findings")
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
fn app_with_findings(names: &[&str]) -> (tempfile::TempDir, crate::app::App) {
let d = tempfile::tempdir().unwrap();
let mut app =
crate::app::App::new(d.path().to_path_buf(), crate::config::Config::default()).unwrap();
app.config.ui.ascii_icons = true;
let fd = findings_dir(d.path());
std::fs::create_dir_all(&fd).unwrap();
for n in names {
std::fs::write(fd.join(format!("{n}.md")), "x").unwrap();
}
app.findings_panel_refresh();
(d, app)
}
fn render(app: &mut crate::app::App, w: u16) -> ratatui::buffer::Buffer {
let mut term = Terminal::new(TestBackend::new(w, 12)).unwrap();
term.draw(|f| {
draw(
f,
app,
Rect {
x: 0,
y: 0,
width: w,
height: 12,
},
)
})
.unwrap();
term.backend().buffer().clone()
}
#[test]
fn the_focused_row_is_inset_and_carries_the_blue_accent_bar() {
let (_d, mut app) = app_with_findings(&["alpha"]);
let t = theme::cur();
let buf = render(&mut app, 60);
let y = (0..12u16)
.find(|&y| (0..60).any(|x| buf[(x, y)].symbol() == "▌"))
.expect("no row carries an accent bar");
assert_eq!(
buf[(0, y)].bg,
t.bg_darker,
"column 0 is highlighted — the band is welded to the panel edge"
);
assert_eq!(
buf[(1, y)].bg,
t.bg_darker,
"column 1 is highlighted — the gutter should be 2 cells, as in TODOS"
);
assert_eq!(
buf[(2, y)].symbol(),
"▌",
"the accent bar is not at column 2"
);
assert_eq!(buf[(2, y)].fg, t.blue, "the accent bar is not blue");
assert_eq!(
buf[(3, y)].bg,
t.bg2,
"the focused row carries no highlight band"
);
}
#[test]
fn the_accent_bar_moves_with_the_cursor() {
let (_d, mut app) = app_with_findings(&["alpha", "beta"]);
let first = (0..12u16)
.find(|&y| {
let b = render(&mut app, 60);
(0..60).any(|x| b[(x, y)].symbol() == "▌")
})
.expect("no accent row before moving");
app.findings_panel_cursor_down();
let buf = render(&mut app, 60);
let second = (0..12u16)
.find(|&y| (0..60).any(|x| buf[(x, y)].symbol() == "▌"))
.expect("no accent row after moving");
assert_ne!(
first, second,
"the accent bar stayed on row {first} after the cursor moved down"
);
}
#[test]
fn a_stale_cursor_is_clamped_into_range() {
let (_d, mut app) = app_with_findings(&["alpha", "beta"]);
app.findings_panel_cursor = 99;
let buf = render(&mut app, 60);
assert!(
(0..12u16).any(|y| (0..60).any(|x| buf[(x, y)].symbol() == "▌")),
"a stale cursor left the panel with no highlighted row"
);
assert!(
app.findings_panel_cursor < 2,
"cursor {} was not clamped to the row count",
app.findings_panel_cursor
);
}
}