use jiff::civil::Date;
use ratatui::Frame;
use ratatui::crossterm::event::{
KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
};
use ratatui::layout::{Constraint, Layout, Position, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{List, ListItem, ListState, Paragraph, Wrap};
use crate::config::NotesConfig;
use crate::frame::Binding;
use crate::grid::{Column, Grid, wrapped_height};
use crate::note::{Note, NoteStore};
use crate::panel::{KeyOutcome, Panel, RenderContext};
use crate::textarea::TextArea;
use crate::textfield::TextField;
use crate::theme::Theme;
const BINDINGS: &[Binding] = &[
Binding::primary("a", "new"),
Binding::primary("↵", "edit"),
Binding::primary("d", "delete"),
Binding::extra("↑ / ↓", "move selection"),
Binding::extra("j / k", "move selection"),
Binding::extra("g / G", "first / last"),
Binding::extra("Home / End", "first / last"),
Binding::extra("PgUp / PgDn", "scroll the note"),
Binding::extra("e", "edit"),
Binding::extra("n", "new"),
Binding::extra("/", "search"),
Binding::extra("Esc", "clear search"),
Binding::extra("o", "show file path"),
];
const COLUMNS: &[Column] = &[
Column::flex("title", 1),
Column::fixed("date", 6).right().drops_below(24),
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Field {
Title,
Body,
}
#[derive(Debug)]
struct EditForm {
id: Option<u64>,
title: TextField,
body: TextArea,
field: Field,
error: Option<String>,
}
impl EditForm {
fn blank() -> Self {
Self {
id: None,
title: TextField::new(),
body: TextArea::new(),
field: Field::Title,
error: None,
}
}
fn from_note(note: &Note) -> Self {
Self {
id: Some(note.id),
title: TextField::with_value(note.title.clone()),
body: TextArea::with_value(¬e.body),
field: Field::Title,
error: None,
}
}
}
#[derive(Debug)]
enum Mode {
List,
Edit(Box<EditForm>),
Search(TextField),
ConfirmDelete { id: u64, title: String },
}
pub struct NotesPanel {
store: NoteStore,
config: NotesConfig,
filter: String,
mode: Mode,
view: Vec<u64>,
list_state: ListState,
body_scroll: u16,
status: Option<(String, bool)>,
today: Date,
list_area: Option<Rect>,
detail_area: Option<Rect>,
body_area: Option<Rect>,
}
impl NotesPanel {
pub fn new(config: NotesConfig, path: std::path::PathBuf) -> anyhow::Result<Self> {
let today = jiff::Zoned::now().date();
let store = NoteStore::load_or_seed(path, today)?;
let mut panel = Self {
store,
config,
filter: String::new(),
mode: Mode::List,
view: Vec::new(),
list_state: ListState::default(),
body_scroll: 0,
status: None,
today,
list_area: None,
detail_area: None,
body_area: None,
};
panel.refresh_view();
Ok(panel)
}
fn refresh_view(&mut self) {
let previous = self.selected_id();
self.view = self.store.view(&self.filter);
let index = previous
.and_then(|id| self.view.iter().position(|v| *v == id))
.or_else(|| (!self.view.is_empty()).then_some(0));
self.list_state
.select(index.filter(|_| !self.view.is_empty()));
if self.selected_id() != previous {
self.body_scroll = 0;
}
}
fn selected_id(&self) -> Option<u64> {
self.list_state
.selected()
.and_then(|index| self.view.get(index))
.copied()
}
fn selected(&self) -> Option<&Note> {
self.selected_id().and_then(|id| self.store.get(id))
}
fn select_to(&mut self, index: usize) {
let Some(last) = self.view.len().checked_sub(1) else {
return;
};
let index = index.min(last);
if self.list_state.selected() != Some(index) {
self.list_state.select(Some(index));
self.body_scroll = 0;
}
}
fn select_down(&mut self, n: usize) {
let current = self.list_state.selected().unwrap_or(0);
self.select_to(current.saturating_add(n));
}
fn select_up(&mut self, n: usize) {
let current = self.list_state.selected().unwrap_or(0);
self.select_to(current.saturating_sub(n));
}
fn scroll_body(&mut self, delta: i16) {
let area = self.body_area;
let height = self.selected().map_or(0, |note| match area {
Some(area) if area.width > 0 => wrapped_height(¬e.body, area.width),
_ => u16::try_from(note.body.lines().count()).unwrap_or(u16::MAX),
});
let visible = area.map_or(1, |a| a.height).max(1);
let max = i32::from(height.saturating_sub(visible));
let next = i32::from(self.body_scroll) + i32::from(delta);
self.body_scroll = u16::try_from(next.clamp(0, max.max(0))).unwrap_or(0);
}
fn persist(&mut self) {
self.store.save_reporting();
if let Some(err) = self.store.last_error.clone() {
self.status = Some((format!("save failed: {err}"), true));
}
}
fn set_status(&mut self, message: impl Into<String>) {
self.status = Some((message.into(), false));
}
fn commit_form(&mut self) -> Result<(), String> {
let Mode::Edit(form) = &self.mode else {
return Ok(());
};
if form.title.is_blank() {
return Err("a note needs a title".to_string());
}
let title = form.title.trimmed().to_string();
let body = form.body.value();
let id = form.id;
let Some(id) = id else {
let mut note = Note::new(0, title, self.today);
note.body = body;
let new_id = self.store.add(note);
self.mode = Mode::List;
self.refresh_view();
if let Some(index) = self.view.iter().position(|v| *v == new_id) {
self.list_state.select(Some(index));
self.body_scroll = 0;
}
self.set_status("added");
self.persist();
return Ok(());
};
self.store.with_note(id, self.today, |n| {
n.title = title;
n.body = body;
});
self.set_status("saved");
self.mode = Mode::List;
self.refresh_view();
self.persist();
Ok(())
}
fn handle_list_key(&mut self, key: KeyEvent) -> KeyOutcome {
match key.code {
KeyCode::Char('j') | KeyCode::Down => self.select_down(1),
KeyCode::Char('k') | KeyCode::Up => self.select_up(1),
KeyCode::Char('g') | KeyCode::Home => self.select_up(usize::MAX),
KeyCode::Char('G') | KeyCode::End => self.select_down(usize::MAX),
KeyCode::PageDown => self.scroll_body(5),
KeyCode::PageUp => self.scroll_body(-5),
KeyCode::Char('a' | 'n') => {
self.mode = Mode::Edit(Box::new(EditForm::blank()));
}
KeyCode::Enter | KeyCode::Char('e') => {
if let Some(note) = self.selected() {
self.mode = Mode::Edit(Box::new(EditForm::from_note(note)));
}
}
KeyCode::Char('d') => {
if let Some(note) = self.selected() {
self.mode = Mode::ConfirmDelete {
id: note.id,
title: note.title.clone(),
};
}
}
KeyCode::Char('/') => {
self.mode = Mode::Search(TextField::with_value(self.filter.clone()));
}
KeyCode::Char('o') => {
let path = self.store.path().display().to_string();
self.set_status(path);
}
KeyCode::Esc if !self.filter.is_empty() => {
self.filter.clear();
self.set_status("search cleared");
self.refresh_view();
}
_ => return KeyOutcome::Ignored,
}
KeyOutcome::Consumed
}
fn handle_edit_key(&mut self, key: KeyEvent) -> KeyOutcome {
let Mode::Edit(form) = &mut self.mode else {
return KeyOutcome::Ignored;
};
match key.code {
KeyCode::Esc => {
self.mode = Mode::List;
self.set_status("cancelled");
return KeyOutcome::Consumed;
}
KeyCode::Tab | KeyCode::BackTab => {
form.field = match form.field {
Field::Title => Field::Body,
Field::Body => Field::Title,
};
return KeyOutcome::Consumed;
}
_ => {}
}
let save = (key.modifiers.contains(KeyModifiers::CONTROL)
&& matches!(key.code, KeyCode::Char('s')))
|| (form.field == Field::Title && key.code == KeyCode::Enter);
if save {
if let Err(message) = self.commit_form()
&& let Mode::Edit(form) = &mut self.mode
{
form.error = Some(message);
}
return KeyOutcome::Consumed;
}
match form.field {
Field::Title => {
form.title.handle_key(key);
}
Field::Body => {
form.body.handle_key(key);
}
}
KeyOutcome::Consumed
}
fn handle_search_key(&mut self, key: KeyEvent) -> KeyOutcome {
let Mode::Search(field) = &mut self.mode else {
return KeyOutcome::Ignored;
};
match key.code {
KeyCode::Esc => {
self.mode = Mode::List;
}
KeyCode::Enter => {
self.filter = field.trimmed().to_string();
self.mode = Mode::List;
self.refresh_view();
}
_ => {
field.handle_key(key);
self.filter = field.trimmed().to_string();
self.refresh_view();
}
}
KeyOutcome::Consumed
}
fn handle_confirm_key(&mut self, key: KeyEvent) -> KeyOutcome {
let Mode::ConfirmDelete { id, .. } = &self.mode else {
return KeyOutcome::Ignored;
};
let id = *id;
if matches!(key.code, KeyCode::Char('y' | 'Y')) {
self.store.remove(id);
self.mode = Mode::List;
self.refresh_view();
self.set_status("deleted");
self.persist();
} else {
self.mode = Mode::List;
self.set_status("kept");
}
KeyOutcome::Consumed
}
fn note_line(&self, note: &Note, theme: &Theme, grid: &Grid) -> Line<'static> {
let date = note
.shown_date()
.strftime(&self.config.date_format)
.to_string();
let date = if note.updated.is_some() {
format!("·{date}")
} else {
date
};
grid.row(&[
Span::styled(note.title.clone(), Style::default().fg(theme.text)),
Span::styled(date, Style::default().fg(theme.muted)),
])
}
fn render_detail(&mut self, frame: &mut Frame, area: Rect, theme: &Theme) {
if area.width == 0 || area.height == 0 {
return;
}
let Some(note) = self.selected() else {
let message = if self.view.is_empty() && self.filter.is_empty() {
"No notes yet. Press `a` to write one."
} else {
"Nothing matches this search. Esc to clear."
};
frame.render_widget(
Paragraph::new(Span::styled(message, Style::default().fg(theme.muted))),
area,
);
return;
};
let rows = Layout::vertical([
Constraint::Length(1), Constraint::Length(1), Constraint::Min(0), ])
.split(area);
frame.render_widget(
Paragraph::new(Span::styled(
note.title.clone(),
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD),
))
.wrap(Wrap { trim: false }),
rows[0],
);
let mut dates = vec![Span::styled(
format!("written {}", note.created.strftime("%d %b %Y")),
Style::default().fg(theme.muted),
)];
if let Some(updated) = note.updated {
dates.push(Span::styled(
format!(" edited {}", updated.strftime("%d %b %Y")),
Style::default().fg(theme.muted),
));
}
frame.render_widget(Paragraph::new(Line::from(dates)), rows[1]);
if rows[2].height == 0 {
return;
}
let body = if note.body.trim().is_empty() {
Paragraph::new(Span::styled("(no body)", Style::default().fg(theme.muted)))
} else {
Paragraph::new(note.body.clone()).style(Style::default().fg(theme.text))
};
self.body_area = Some(rows[2]);
frame.render_widget(
body.wrap(Wrap { trim: false })
.scroll((self.body_scroll, 0)),
rows[2],
);
}
fn summary_line(&self, theme: &Theme) -> Line<'static> {
let mut spans = vec![Span::styled(
match self.store.notes().len() {
0 => "no notes".to_string(),
1 => "1 note".to_string(),
n => format!("{n} notes"),
},
Style::default().fg(theme.muted),
)];
if !self.filter.is_empty() {
spans.push(Span::styled(
format!(" search: {}", self.filter),
Style::default().fg(theme.label),
));
}
Line::from(spans)
}
fn status_line(&self, theme: &Theme) -> Line<'static> {
match (&self.mode, &self.status) {
(Mode::ConfirmDelete { title, .. }, _) => Line::from(Span::styled(
format!("delete \"{title}\"? y / n"),
Style::default()
.fg(theme.error)
.add_modifier(Modifier::BOLD),
)),
(Mode::Search(field), _) => Line::from(vec![
Span::styled("search ", Style::default().fg(theme.accent)),
Span::styled(field.value().to_string(), Style::default().fg(theme.text)),
Span::styled("▏", Style::default().fg(theme.accent)),
]),
(_, Some((message, is_error))) => Line::from(Span::styled(
message.clone(),
Style::default().fg(if *is_error { theme.error } else { theme.muted }),
)),
_ => Line::default(),
}
}
fn render_form(frame: &mut Frame, area: Rect, theme: &Theme, form: &EditForm) {
let rows = Layout::vertical([
Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Min(1), Constraint::Length(1), ])
.split(area);
let heading = if form.id.is_some() {
"EDIT NOTE"
} else {
"NEW NOTE"
};
frame.render_widget(
Paragraph::new(Span::styled(
heading,
Style::default()
.fg(theme.label)
.add_modifier(Modifier::BOLD),
)),
rows[0],
);
let active = |field: Field| {
if form.field == field {
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.muted)
}
};
let (title_text, title_cursor) =
form.title.visible(rows[1].width.saturating_sub(7) as usize);
let mut title_spans = vec![Span::styled("title ", active(Field::Title))];
title_spans.push(Span::styled(title_text, Style::default().fg(theme.text)));
if form.field == Field::Title {
title_spans.push(Span::styled("▏", Style::default().fg(theme.accent)));
let _ = title_cursor;
}
frame.render_widget(Paragraph::new(Line::from(title_spans)), rows[1]);
frame.render_widget(
Paragraph::new(Span::styled("body", active(Field::Body))),
rows[2],
);
if rows[3].height > 0 {
let height = usize::from(rows[3].height);
let offset = form.body.scroll_offset(height);
let (cursor_row, cursor_col) = form.body.cursor();
let lines: Vec<Line> = form
.body
.lines()
.iter()
.enumerate()
.skip(offset)
.take(height)
.map(|(index, line)| {
if form.field == Field::Body && index == cursor_row {
let split = line
.char_indices()
.nth(cursor_col)
.map_or(line.len(), |(byte, _)| byte);
Line::from(vec![
Span::styled(
line[..split].to_string(),
Style::default().fg(theme.text),
),
Span::styled("▏", Style::default().fg(theme.accent)),
Span::styled(
line[split..].to_string(),
Style::default().fg(theme.text),
),
])
} else {
Line::from(Span::styled(line.clone(), Style::default().fg(theme.text)))
}
})
.collect();
frame.render_widget(Paragraph::new(lines), rows[3]);
}
let footer = match &form.error {
Some(message) => Span::styled(message.clone(), Style::default().fg(theme.error)),
None => Span::styled(
"Tab field Ctrl+S save Esc cancel",
Style::default().fg(theme.muted),
),
};
frame.render_widget(Paragraph::new(footer), rows[4]);
}
}
impl Panel for NotesPanel {
fn title(&self) -> String {
"Notes".to_string()
}
fn counter(&self) -> Option<String> {
if self.store.last_error.is_some() {
return Some("unsaved!".into());
}
let total = self.store.notes().len();
if total == 0 {
return None;
}
if self.filter.is_empty() {
Some(format!("{total}"))
} else {
Some(format!("{}/{total}", self.view.len()))
}
}
fn bindings(&self) -> &'static [Binding] {
BINDINGS
}
fn refresh_interval(&self) -> std::time::Duration {
std::time::Duration::from_mins(1)
}
fn tick(&mut self) -> bool {
let today = jiff::Zoned::now().date();
let moved = today != self.today;
self.today = today;
moved
}
fn captures_input(&self) -> bool {
!matches!(self.mode, Mode::List)
}
fn handle_key(&mut self, key: KeyEvent) -> KeyOutcome {
self.status = None;
match &self.mode {
Mode::List => self.handle_list_key(key),
Mode::Edit(_) => self.handle_edit_key(key),
Mode::Search(_) => self.handle_search_key(key),
Mode::ConfirmDelete { .. } => self.handle_confirm_key(key),
}
}
fn handle_mouse(&mut self, event: MouseEvent, _area: Rect) -> KeyOutcome {
if !matches!(self.mode, Mode::List) {
return KeyOutcome::Ignored;
}
let at = Position::new(event.column, event.row);
let over_body = self.detail_area.is_some_and(|a| a.contains(at));
match event.kind {
MouseEventKind::ScrollDown if over_body => self.scroll_body(2),
MouseEventKind::ScrollUp if over_body => self.scroll_body(-2),
MouseEventKind::ScrollDown => self.select_down(1),
MouseEventKind::ScrollUp => self.select_up(1),
MouseEventKind::Down(MouseButton::Left) => {
let Some(area) = self.list_area else {
return KeyOutcome::Ignored;
};
let Some(index) =
crate::selection::row_at(&self.list_state, area, at, self.view.len())
else {
return KeyOutcome::Ignored;
};
self.status = None;
self.select_to(index);
}
_ => return KeyOutcome::Ignored,
}
KeyOutcome::Consumed
}
fn render(&mut self, frame: &mut Frame, area: Rect, ctx: RenderContext<'_>) {
let theme = ctx.theme;
if area.width == 0 || area.height == 0 {
return;
}
self.list_area = None;
self.detail_area = None;
if let Mode::Edit(form) = &self.mode {
Self::render_form(frame, area, theme, form);
return;
}
let rows = Layout::vertical([
Constraint::Length(1), Constraint::Min(1), Constraint::Length(1), ])
.split(area);
frame.render_widget(Paragraph::new(self.summary_line(theme)), rows[0]);
let body = rows[1];
let side_by_side = self.config.preview.eq_ignore_ascii_case("beside");
let (list_area, detail_area) = if side_by_side {
let parts = Layout::horizontal([
Constraint::Percentage(42),
Constraint::Length(3),
Constraint::Min(0),
])
.split(body);
for row in 0..parts[1].height {
frame.render_widget(
Paragraph::new(Span::styled("│", Style::default().fg(theme.rule))),
Rect::new(parts[1].x + 1, parts[1].y + row, 1, 1),
);
}
(parts[0], parts[2])
} else {
let parts = Layout::vertical([
Constraint::Percentage(45),
Constraint::Length(1),
Constraint::Min(0),
])
.split(body);
crate::frame::rule(frame, parts[1], theme, "");
(parts[0], parts[2])
};
if !self.view.is_empty() && list_area.height > 1 {
let marker = 2u16;
let grid = Grid::new(COLUMNS, list_area.width.saturating_sub(marker));
let header_area = Rect::new(
list_area.x + marker,
list_area.y,
list_area.width.saturating_sub(marker),
1,
);
frame.render_widget(Paragraph::new(grid.header(theme)), header_area);
let by_id: std::collections::HashMap<u64, &Note> =
self.store.notes().iter().map(|n| (n.id, n)).collect();
let visible: Vec<&Note> = self
.view
.iter()
.filter_map(|id| by_id.get(id).copied())
.collect();
let items: Vec<ListItem> = visible
.iter()
.map(|note| ListItem::new(self.note_line(note, theme, &grid)))
.collect();
let rows_area = Rect {
y: list_area.y + 1,
height: list_area.height - 1,
..list_area
};
self.list_area = Some(rows_area);
let list = List::new(items)
.highlight_symbol(if ctx.focused { "▸ " } else { " " })
.highlight_style(if ctx.focused {
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.muted)
});
frame.render_stateful_widget(list, rows_area, &mut self.list_state);
}
self.detail_area = Some(detail_area);
self.render_detail(frame, detail_area, theme);
frame.render_widget(Paragraph::new(self.status_line(theme)), rows[2]);
}
fn shutdown(&mut self) {
self.store.save_reporting();
}
}
#[cfg(test)]
mod tests {
use super::*;
struct TempDir(std::path::PathBuf);
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn panel(name: &str) -> (NotesPanel, TempDir) {
let dir = std::env::temp_dir().join(format!("mirador-notes-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("notes.toml");
std::fs::write(&path, "").unwrap();
let p = NotesPanel::new(NotesConfig::default(), path).unwrap();
(p, TempDir(dir))
}
fn press(p: &mut NotesPanel, code: KeyCode) {
p.handle_key(KeyEvent::new(code, KeyModifiers::NONE));
}
fn type_str(p: &mut NotesPanel, text: &str) {
for c in text.chars() {
press(p, KeyCode::Char(c));
}
}
fn add_note(p: &mut NotesPanel, title: &str, body: &str) {
press(p, KeyCode::Char('a'));
type_str(p, title);
if body.is_empty() {
press(p, KeyCode::Enter);
return;
}
press(p, KeyCode::Tab);
for c in body.chars() {
if c == '\n' {
press(p, KeyCode::Enter);
} else {
press(p, KeyCode::Char(c));
}
}
p.handle_key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL));
}
#[test]
fn a_note_can_be_written_and_survives_a_reload() {
let (mut p, guard) = panel("write");
add_note(&mut p, "Shopping", "milk\neggs");
assert!(matches!(p.mode, Mode::List), "the form must close on save");
assert_eq!(p.view.len(), 1);
let reloaded = NotesPanel::new(NotesConfig::default(), guard.0.join("notes.toml")).unwrap();
assert_eq!(reloaded.store.notes().len(), 1);
let note = &reloaded.store.notes()[0];
assert_eq!(note.title, "Shopping");
assert_eq!(note.body, "milk\neggs");
}
#[test]
fn ctrl_s_saves_from_the_title_field_and_not_only_the_body() {
let (mut p, _g) = panel("ctrl-s-title");
press(&mut p, KeyCode::Char('a'));
type_str(&mut p, "Saved from the title");
p.handle_key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL));
assert!(
matches!(p.mode, Mode::List),
"Ctrl+S in the title must close the form, not be swallowed"
);
assert_eq!(p.store.notes().len(), 1);
assert_eq!(p.store.notes()[0].title, "Saved from the title");
}
#[test]
fn a_single_paragraph_body_can_still_be_scrolled() {
let (mut p, _g) = panel("scroll-wrapped");
let long = "word ".repeat(400);
add_note(&mut p, "Wrapped", &long);
p.body_area = Some(Rect::new(0, 0, 40, 5));
p.scroll_body(3);
assert_eq!(p.body_scroll, 3, "a wrapped body must scroll");
p.scroll_body(i16::MAX);
let height = wrapped_height(&long, 40);
assert_eq!(p.body_scroll, height.saturating_sub(5));
}
#[test]
fn wrapped_height_measures_cells_not_characters() {
assert_eq!(wrapped_height("", 10), 1, "an empty body still has a row");
assert_eq!(wrapped_height("a\nb\nc", 10), 3);
assert_eq!(wrapped_height(&"日".repeat(10), 10), 2);
assert_eq!(wrapped_height("x", 0), 0);
}
#[test]
fn a_note_needs_a_title() {
let (mut p, _g) = panel("blank");
press(&mut p, KeyCode::Char('a'));
press(&mut p, KeyCode::Enter);
assert!(
matches!(p.mode, Mode::Edit(_)),
"an empty title must keep the form open"
);
let Mode::Edit(form) = &p.mode else {
unreachable!()
};
assert!(form.error.is_some(), "and say why");
assert!(p.store.notes().is_empty());
}
#[test]
fn enter_inside_the_body_makes_a_new_line_rather_than_saving() {
let (mut p, _g) = panel("newline");
press(&mut p, KeyCode::Char('a'));
type_str(&mut p, "Title");
press(&mut p, KeyCode::Tab);
type_str(&mut p, "one");
press(&mut p, KeyCode::Enter);
type_str(&mut p, "two");
assert!(
matches!(p.mode, Mode::Edit(_)),
"Enter in the body must not close the form"
);
let Mode::Edit(form) = &p.mode else {
unreachable!()
};
assert_eq!(form.body.value(), "one\ntwo");
}
#[test]
fn editing_an_existing_note_updates_it_in_place() {
let (mut p, _g) = panel("edit");
add_note(&mut p, "Original", "body");
assert_eq!(p.store.notes().len(), 1);
press(&mut p, KeyCode::Enter);
assert!(matches!(p.mode, Mode::Edit(_)), "Enter opens the note");
type_str(&mut p, " revised");
press(&mut p, KeyCode::Enter);
assert_eq!(p.store.notes().len(), 1, "editing must not duplicate");
assert_eq!(p.store.notes()[0].title, "Original revised");
}
#[test]
fn deleting_asks_first_and_keeps_the_note_on_any_other_key() {
let (mut p, _g) = panel("delete");
add_note(&mut p, "Keep me", "");
press(&mut p, KeyCode::Char('d'));
assert!(matches!(p.mode, Mode::ConfirmDelete { .. }));
press(&mut p, KeyCode::Char('n'));
assert_eq!(p.store.notes().len(), 1, "n must keep it");
press(&mut p, KeyCode::Char('d'));
press(&mut p, KeyCode::Char('y'));
assert!(p.store.notes().is_empty(), "y deletes");
}
#[test]
fn searching_filters_as_you_type_and_esc_restores_everything() {
let (mut p, _g) = panel("search");
add_note(&mut p, "Groceries", "milk");
add_note(&mut p, "Meeting", "invoice question");
assert_eq!(p.view.len(), 2);
press(&mut p, KeyCode::Char('/'));
type_str(&mut p, "invoice");
assert_eq!(p.view.len(), 1, "the body is searched, not just the title");
press(&mut p, KeyCode::Enter);
assert!(matches!(p.mode, Mode::List));
press(&mut p, KeyCode::Esc);
assert_eq!(p.view.len(), 2, "Esc clears the search");
}
#[test]
fn the_form_captures_input_so_typing_q_cannot_quit() {
let (mut p, _g) = panel("capture");
assert!(!p.captures_input(), "the list must not swallow global keys");
press(&mut p, KeyCode::Char('a'));
assert!(p.captures_input(), "an open form must swallow them");
}
#[test]
fn moving_the_selection_resets_the_body_scroll() {
let (mut p, _g) = panel("scroll-reset");
add_note(&mut p, "Long", &"line\n".repeat(40));
add_note(&mut p, "Short", "short");
press(&mut p, KeyCode::Char('j'));
assert_eq!(p.selected().unwrap().title, "Long");
p.scroll_body(10);
assert!(p.body_scroll > 0, "the long note scrolls");
press(&mut p, KeyCode::Char('k'));
assert_eq!(
p.body_scroll, 0,
"a different note must start at its own top"
);
}
#[test]
fn the_body_cannot_be_scrolled_past_its_own_end() {
let (mut p, _g) = panel("scroll-clamp");
add_note(&mut p, "Short", "one\ntwo\nthree");
p.scroll_body(500);
assert_eq!(p.body_scroll, 2, "clamped to the last line");
p.scroll_body(-500);
assert_eq!(p.body_scroll, 0, "and cannot go negative");
}
#[test]
fn the_counter_shows_the_match_count_only_while_searching() {
let (mut p, _g) = panel("counter");
assert_eq!(p.counter(), None, "no counter with nothing to count");
add_note(&mut p, "Groceries", "milk");
add_note(&mut p, "Meeting", "invoice");
assert_eq!(p.counter(), Some("2".to_string()));
press(&mut p, KeyCode::Char('/'));
type_str(&mut p, "milk");
assert_eq!(p.counter(), Some("1/2".to_string()));
}
const DOCUMENTED_LIST_KEYS: &[(KeyCode, &str)] = &[
(KeyCode::Char('a'), "a"),
(KeyCode::Char('n'), "n"),
(KeyCode::Enter, "↵"),
(KeyCode::Char('e'), "e"),
(KeyCode::Char('d'), "d"),
(KeyCode::Down, "↑ / ↓"),
(KeyCode::Up, "↑ / ↓"),
(KeyCode::Char('j'), "j / k"),
(KeyCode::Char('k'), "j / k"),
(KeyCode::Char('g'), "g / G"),
(KeyCode::Char('G'), "g / G"),
(KeyCode::Home, "Home / End"),
(KeyCode::End, "Home / End"),
(KeyCode::PageUp, "PgUp / PgDn"),
(KeyCode::PageDown, "PgUp / PgDn"),
(KeyCode::Char('/'), "/"),
(KeyCode::Char('o'), "o"),
];
#[test]
fn every_documented_key_works_and_every_working_key_is_documented() {
for (code, key) in DOCUMENTED_LIST_KEYS {
assert!(
BINDINGS.iter().any(|b| b.key == *key),
"`{key}` is handled but missing from BINDINGS, so nothing tells the user it exists"
);
let (mut p, _g) = panel("keymap");
add_note(&mut p, "a note", "body");
assert!(matches!(p.mode, Mode::List));
let outcome = p.handle_key(KeyEvent::new(*code, KeyModifiers::NONE));
assert_eq!(
outcome,
KeyOutcome::Consumed,
"`{key}` is documented but the list ignores it"
);
}
}
#[test]
fn the_preview_sits_below_the_list_by_default_and_beside_it_on_request() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let config = crate::config::Config::default();
let gradients = config.theme.gradients();
let draw = |p: &mut NotesPanel| {
let mut terminal = Terminal::new(TestBackend::new(100, 14)).unwrap();
terminal
.draw(|frame| {
p.render(
frame,
frame.area(),
RenderContext {
theme: &config.theme,
gradients: &gradients,
focused: true,
},
);
})
.unwrap();
(p.list_area.unwrap(), p.detail_area.unwrap())
};
let (mut p, _g) = panel("split-below");
add_note(&mut p, "Note", "body text");
let (list, detail) = draw(&mut p);
assert_eq!(detail.x, list.x, "stacked: {list:?} {detail:?}");
assert!(detail.y > list.y, "and the body is below");
assert_eq!(detail.width, list.width, "both get the full width");
p.config.preview = "beside".to_string();
let (list, detail) = draw(&mut p);
assert!(detail.x > list.x, "beside: {list:?} {detail:?}");
}
}