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};
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 LIST_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 TITLE_EDIT_BINDINGS: &[Binding] = &[
Binding::primary("Tab", "body"),
Binding::primary("Ctrl+S", "save"),
Binding::primary("Esc", "cancel"),
];
const BODY_EDIT_BINDINGS: &[Binding] = &[
Binding::primary("Shift+←↑→↓", "select"),
Binding::primary("Ctrl+V", "paste"),
Binding::primary("Ctrl+S", "save"),
Binding::extra("Ctrl+A", "select all"),
Binding::extra("Ctrl+C", "copy selection"),
Binding::extra("Tab", "change field"),
Binding::extra("Esc", "cancel"),
];
const SELECTION_BINDINGS: &[Binding] = &[
Binding::primary("Ctrl+C", "copy"),
Binding::primary("Ctrl+V", "paste"),
Binding::primary("Ctrl+S", "save"),
Binding::extra("Shift+←↑→↓", "adjust selection"),
Binding::extra("Ctrl+A", "select all"),
Binding::extra("Tab", "change field"),
Binding::extra("Esc", "cancel"),
];
pub(crate) 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 },
}
#[derive(Debug)]
struct WrappedBody {
source: String,
width: u16,
rows: Vec<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>,
wrapped_body: Option<WrappedBody>,
clipboard: Option<String>,
}
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,
wrapped_body: None,
clipboard: 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 wrapped_body(&mut self, body: &str, width: u16) -> &[String] {
let fresh = self
.wrapped_body
.as_ref()
.is_some_and(|cache| cache.width == width && cache.source == body);
if !fresh {
self.wrapped_body = Some(WrappedBody {
source: body.to_string(),
width,
rows: crate::grid::wrap(body, usize::from(width)),
});
}
self.wrapped_body
.as_ref()
.map_or(&[], |cache| cache.rows.as_slice())
}
fn wrapped_rows(&self, body: &str, width: u16) -> Option<u16> {
self.wrapped_body
.as_ref()
.filter(|cache| cache.width == width && cache.source == body)
.map(|cache| u16::try_from(cache.rows.len()).unwrap_or(u16::MAX))
}
fn scroll_body(&mut self, delta: i16) {
let area = self.body_area;
let cached = self
.selected()
.zip(area)
.and_then(|(note, area)| self.wrapped_rows(¬e.body, area.width));
let height = cached.unwrap_or_else(|| {
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 copy_body_selection_with(
&mut self,
copy: impl FnOnce(&str) -> std::io::Result<()>,
) -> KeyOutcome {
let selected = match &self.mode {
Mode::Edit(form) => form.body.selected_text(),
_ => None,
};
let Some(text) = selected else {
return KeyOutcome::Ignored;
};
let count = text.chars().count();
self.clipboard = Some(text.clone());
self.status = Some(match copy(&text) {
Ok(()) => (format!("sent {count} chars — Ctrl+V pastes here"), false),
Err(error) => (
format!("ready to paste; terminal clipboard failed: {error}"),
true,
),
});
if let Mode::Edit(form) = &mut self.mode {
form.body.clear_selection();
}
KeyOutcome::Consumed
}
fn paste_body_clipboard(&mut self) -> KeyOutcome {
let Mode::Edit(form) = &mut self.mode else {
return KeyOutcome::Ignored;
};
if form.field != Field::Body {
self.set_status("Tab to the body to paste copied text");
return KeyOutcome::Consumed;
}
let Some(text) = self.clipboard.clone() else {
self.set_status("nothing copied here yet; terminal paste still works");
return KeyOutcome::Consumed;
};
let count = text.chars().count();
form.body.insert_text(&text);
form.error = None;
self.set_status(format!("pasted {count} chars"));
KeyOutcome::Consumed
}
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 {
if key.modifiers.contains(KeyModifiers::CONTROL) && matches!(key.code, KeyCode::Char('v')) {
return self.paste_body_clipboard();
}
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);
}
}
form.error = None;
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(crate::grid::wrapped(¬e.title, rows[0].width)).style(
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD),
),
rows[0],
);
let mut dates = vec![vec![Span::styled(
format!("written {}", note.created.strftime("%d %b %Y")),
Style::default().fg(theme.muted),
)]];
if let Some(updated) = note.updated {
dates.push(vec![Span::styled(
format!(" edited {}", updated.strftime("%d %b %Y")),
Style::default().fg(theme.muted),
)]);
}
frame.render_widget(
Paragraph::new(crate::grid::assemble(dates, rows[1].width)),
rows[1],
);
if rows[2].height == 0 {
return;
}
let body_text = note.body.clone();
let body = if note.body.trim().is_empty() {
Paragraph::new(Span::styled("(no body)", Style::default().fg(theme.muted)))
} else {
let scroll = usize::from(self.body_scroll);
let height = usize::from(rows[2].height);
let wrapped = self.wrapped_body(&body_text, rows[2].width);
let visible: Vec<Line<'static>> = wrapped
.iter()
.skip(scroll.min(wrapped.len()))
.take(height)
.map(|row| Line::from(row.clone()))
.collect();
Paragraph::new(visible).style(Style::default().fg(theme.text))
};
self.body_area = Some(rows[2]);
frame.render_widget(body, rows[2]);
}
fn summary_line(&self, theme: &Theme) -> Option<Line<'static>> {
let total = self.store.notes().len();
let mut spans = Vec::new();
if total == 0 || self.store.last_error.is_some() {
spans.push(Span::styled(
match total {
0 => "no notes".to_string(),
1 => "1 note".to_string(),
n => format!("{n} notes"),
},
Style::default().fg(theme.muted),
));
}
if !self.filter.is_empty() {
let gap = if spans.is_empty() { "" } else { " " };
spans.push(Span::styled(
format!("{gap}search: {}", self.filter),
Style::default().fg(theme.label),
));
}
(!spans.is_empty()).then(|| Line::from(spans))
}
fn status_line(&self, theme: &Theme, width: u16) -> Line<'static> {
crate::grid::assemble(vec![self.status_text(theme).spans], width)
}
fn status_text(&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 editor_line(
text: &str,
caret: Option<usize>,
selection: Option<std::ops::Range<usize>>,
theme: &Theme,
) -> Line<'static> {
let selection = selection.filter(|range| range.start < range.end);
let mut cuts = vec![0, text.len()];
if let Some(range) = &selection {
cuts.extend([range.start, range.end]);
}
if let Some(at) = caret {
cuts.push(at);
}
cuts.sort_unstable();
cuts.dedup();
let mut spans = Vec::new();
for pair in cuts.windows(2) {
let (start, end) = (pair[0], pair[1]);
if caret == Some(start) {
spans.push(Span::styled("▏", Style::default().fg(theme.accent)));
}
if start == end {
continue;
}
let selected = selection
.as_ref()
.is_some_and(|range| start >= range.start && end <= range.end);
let style = if selected {
Style::default()
.fg(theme.text)
.add_modifier(Modifier::REVERSED)
} else {
Style::default().fg(theme.text)
};
spans.push(Span::styled(text[start..end].to_string(), style));
}
if caret == Some(text.len()) {
spans.push(Span::styled("▏", Style::default().fg(theme.accent)));
}
Line::from(spans)
}
fn render_form(
frame: &mut Frame,
area: Rect,
theme: &Theme,
form: &EditForm,
status: Option<(&str, bool)>,
) {
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 width = usize::from(rows[3].width);
let offset = form.body.scroll_offset(height);
let editing = form.field == Field::Body;
let last = form.body.lines().len().min(offset + height);
let lines: Vec<Line> = (offset..last)
.map(|index| {
let (text, caret, selection) = form.body.visible_with_selection(index, width);
Self::editor_line(&text, caret.filter(|_| editing), selection, theme)
})
.collect();
frame.render_widget(Paragraph::new(lines), rows[3]);
}
let footer = match (&form.error, status) {
(Some(message), _) => Span::styled(message.clone(), Style::default().fg(theme.error)),
(None, Some((message, is_error))) => Span::styled(
message.to_string(),
Style::default().fg(if is_error { theme.error } else { theme.muted }),
),
(None, None) if form.body.has_selection() => Span::styled(
"Ctrl+C copy Ctrl+V replace Ctrl+S save",
Style::default().fg(theme.muted),
),
(None, None) if form.field == Field::Body => Span::styled(
"Shift+arrows select Ctrl+A all Ctrl+V paste",
Style::default().fg(theme.muted),
),
(None, None) => Span::styled(
"Tab body 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] {
match &self.mode {
Mode::Edit(form) if form.body.has_selection() => SELECTION_BINDINGS,
Mode::Edit(form) if form.field == Field::Body => BODY_EDIT_BINDINGS,
Mode::Edit(_) => TITLE_EDIT_BINDINGS,
_ => LIST_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 alert(&self) -> Option<crate::panel::Alert> {
self.store
.last_error
.as_ref()
.map(|why| crate::panel::Alert::failing(format!("Notes could not be saved — {why}")))
}
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 copy_selection(&mut self) -> KeyOutcome {
self.copy_body_selection_with(crate::clipboard::copy)
}
fn handle_paste(&mut self, text: &str) -> KeyOutcome {
self.status = None;
let Mode::Edit(form) = &mut self.mode else {
return KeyOutcome::Ignored;
};
let text = text.replace("\r\n", "\n").replace('\r', "\n");
let count = text.chars().count();
match form.field {
Field::Body => form.body.insert_text(&text),
Field::Title => {
for character in text.chars() {
match character {
'\n' | '\t' => form.title.insert(' '),
c if !c.is_control() => form.title.insert(c),
_ => {}
}
}
}
}
form.error = None;
self.set_status(format!("pasted {count} chars from terminal"));
KeyOutcome::Consumed
}
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 {
let status = self
.status
.as_ref()
.map(|(message, is_error)| (message.as_str(), *is_error));
Self::render_form(frame, area, theme, form, status);
return;
}
let summary = self.summary_line(theme);
let rows = Layout::vertical([
Constraint::Length(u16::from(summary.is_some())), Constraint::Min(1), Constraint::Length(1), ])
.split(area);
if let Some(summary) = summary {
frame.render_widget(Paragraph::new(summary), 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].width)),
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 rows_of(p: &mut NotesPanel, width: u16, height: u16) -> Vec<String> {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let config = crate::config::Config::default();
let gradients = config.theme.gradients();
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
terminal
.draw(|frame| {
p.render(
frame,
frame.area(),
crate::panel::RenderContext {
theme: &config.theme,
gradients: &gradients,
focused: true,
watch: &crate::watch::WatchLog::default(),
},
);
})
.unwrap();
let buffer = terminal.backend().buffer().clone();
(0..height)
.map(|y| (0..width).map(|x| buffer[(x, y)].symbol()).collect())
.collect()
}
#[test]
fn the_note_form_draws_its_heading_and_puts_the_caret_in_the_active_field() {
let (mut p, _g) = panel("form-draw");
press(&mut p, KeyCode::Char('a'));
let rows = rows_of(&mut p, 60, 12);
assert!(rows[0].contains("NEW NOTE"), "{rows:?}");
assert!(rows[1].starts_with("title"), "{rows:?}");
assert!(
rows[1].contains('▏'),
"the caret starts in the title: {rows:?}"
);
assert!(rows[2].starts_with("body"), "{rows:?}");
press(&mut p, KeyCode::Tab);
let rows = rows_of(&mut p, 60, 12);
assert!(
!rows[1].contains('▏'),
"after Tab the title has no caret: {rows:?}"
);
press(&mut p, KeyCode::Esc);
add_note(&mut p, "Release checklist", "");
press(&mut p, KeyCode::Enter);
let rows = rows_of(&mut p, 60, 12);
assert!(
rows[0].contains("EDIT NOTE"),
"opening an existing note says so: {rows:?}"
);
assert!(
rows[1].contains("Release checklist"),
"and shows its title: {rows:?}"
);
}
fn press(p: &mut NotesPanel, code: KeyCode) {
p.handle_key(KeyEvent::new(code, KeyModifiers::NONE));
}
fn chord(p: &mut NotesPanel, code: KeyCode, modifiers: KeyModifiers) {
p.handle_key(KeyEvent::new(code, modifiers));
}
fn type_str(p: &mut NotesPanel, text: &str) {
for c in text.chars() {
press(p, KeyCode::Char(c));
}
}
fn draw(p: &mut NotesPanel, width: u16, height: u16) -> String {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let config = crate::config::Config::default();
let gradients = config.theme.gradients();
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
terminal
.draw(|frame| {
p.render(
frame,
frame.area(),
RenderContext {
theme: &config.theme,
gradients: &gradients,
focused: true,
watch: &crate::watch::WatchLog::default(),
},
);
})
.unwrap();
let buffer = terminal.backend().buffer().clone();
(0..height)
.map(|y| {
(0..width)
.map(|x| buffer[(x, y)].symbol().to_string())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n")
}
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 the_note_count_is_shown_once_and_by_the_border() {
let (mut p, _g) = panel("count-once");
let theme = Theme::default();
let text = |p: &NotesPanel| -> Option<String> {
p.summary_line(&theme)
.map(|line| line.spans.iter().map(|s| s.content.to_string()).collect())
};
assert_eq!(p.counter(), None, "an empty panel has no counter");
assert_eq!(text(&p).as_deref(), Some("no notes"));
add_note(&mut p, "Release checklist", "Bump the version");
assert_eq!(
p.counter().as_deref(),
Some("1"),
"the border carries the count"
);
assert_eq!(
text(&p),
None,
"so the summary row is not drawn at all: {:?}",
text(&p)
);
p.filter = "release".to_string();
let searching = text(&p).expect("a search shows");
assert!(searching.starts_with("search: "), "got {searching:?}");
assert!(
!searching.contains("1 note"),
"and still does not repeat the count: {searching:?}"
);
p.filter.clear();
p.store.last_error = Some("read-only file system".to_string());
assert_eq!(p.counter().as_deref(), Some("unsaved!"));
assert_eq!(text(&p).as_deref(), Some("1 note"));
}
#[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 an_unchanged_note_is_not_rewrapped_on_every_frame() {
let (mut p, _g) = panel("rewrap");
add_note(&mut p, "Long", &"lorem ipsum dolor sit amet ".repeat(40));
draw(&mut p, 40, 20);
let first = p
.wrapped_body
.as_ref()
.expect("the first draw fills the cache")
.rows
.as_ptr();
draw(&mut p, 40, 20);
let second = p.wrapped_body.as_ref().unwrap().rows.as_ptr();
assert_eq!(
first, second,
"the second draw rebuilt the wrap instead of reusing it"
);
}
#[test]
fn editing_a_note_rewraps_it() {
let (mut p, _g) = panel("rewrap-edit");
add_note(&mut p, "Long", "one two three");
draw(&mut p, 40, 20);
let before = p.wrapped_body.as_ref().unwrap().rows.clone();
add_note(&mut p, "Other", "wholly different prose here");
draw(&mut p, 40, 20);
let after = p.wrapped_body.as_ref().unwrap().rows.clone();
assert_ne!(before, after, "a different body must be rewrapped");
}
#[test]
fn resizing_rewraps_the_body() {
let (mut p, _g) = panel("rewrap-resize");
add_note(&mut p, "Long", &"lorem ipsum dolor sit amet ".repeat(20));
draw(&mut p, 40, 20);
let wide = p.wrapped_body.as_ref().unwrap().rows.len();
draw(&mut p, 20, 20);
let narrow = p.wrapped_body.as_ref().unwrap().rows.len();
assert!(
narrow > wide,
"a narrower pane needs more rows: {wide} then {narrow}"
);
}
#[test]
fn the_visible_rows_follow_the_scroll_offset() {
let (mut p, _g) = panel("rewrap-scroll");
let body = (0..60)
.map(|n| format!("line{n}"))
.collect::<Vec<_>>()
.join("\n");
add_note(&mut p, "Long", &body);
let top = draw(&mut p, 40, 20);
assert!(top.contains("line0"), "the top of the body:\n{top}");
p.scroll_body(5);
let scrolled = draw(&mut p, 40, 20);
assert!(
!scrolled.contains("line0") && scrolled.contains("line5"),
"scrolling by five should start at line5:\n{scrolled}"
);
}
#[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 selected_body_text_can_be_copied_and_reused() {
let (mut p, _g) = panel("copy-paste");
add_note(&mut p, "Scratch", "red blue");
press(&mut p, KeyCode::Enter);
assert_eq!(p.bindings()[0].key, "Tab", "the title points to the body");
press(&mut p, KeyCode::Tab);
assert_eq!(p.bindings()[0].action, "select");
for _ in 0..4 {
chord(&mut p, KeyCode::Left, KeyModifiers::SHIFT);
}
assert_eq!(p.bindings()[0].key, "Ctrl+C");
assert_eq!(
p.copy_body_selection_with(|text| {
assert_eq!(text, "blue");
Ok(())
}),
KeyOutcome::Consumed
);
assert_eq!(p.clipboard.as_deref(), Some("blue"));
let Mode::Edit(form) = &p.mode else {
unreachable!()
};
assert!(!form.body.has_selection(), "copy collapses the selection");
assert_eq!(
p.copy_body_selection_with(|_| panic!("a second Ctrl+C must fall through")),
KeyOutcome::Ignored
);
press(&mut p, KeyCode::Home);
for _ in 0..3 {
chord(&mut p, KeyCode::Right, KeyModifiers::SHIFT);
}
chord(&mut p, KeyCode::Char('v'), KeyModifiers::CONTROL);
let Mode::Edit(form) = &p.mode else {
unreachable!()
};
assert_eq!(form.body.value(), "blue blue");
assert!(!form.body.has_selection(), "paste consumes the selection");
assert_eq!(
p.status.as_ref().map(|s| s.0.as_str()),
Some("pasted 4 chars")
);
}
#[test]
fn terminal_paste_preserves_multiline_scratchpad_text() {
let (mut p, _g) = panel("terminal-paste");
add_note(&mut p, "Scratch", "red blue");
press(&mut p, KeyCode::Enter);
press(&mut p, KeyCode::Tab);
press(&mut p, KeyCode::Home);
for _ in 0..3 {
chord(&mut p, KeyCode::Right, KeyModifiers::SHIFT);
}
assert_eq!(p.handle_paste("one\r\ntwo\t"), KeyOutcome::Consumed);
let Mode::Edit(form) = &p.mode else {
unreachable!()
};
assert_eq!(form.body.value(), "one\ntwo\t blue");
assert!(!form.body.has_selection());
assert_eq!(
p.status.as_ref().map(|s| s.0.as_str()),
Some("pasted 8 chars from terminal")
);
}
#[test]
fn ctrl_c_remains_quit_when_the_editor_has_nothing_to_copy() {
let (mut p, _g) = panel("copy-empty");
press(&mut p, KeyCode::Char('a'));
press(&mut p, KeyCode::Tab);
assert_eq!(
p.copy_body_selection_with(|_| panic!("copy must not be called")),
KeyOutcome::Ignored
);
}
#[test]
fn a_body_selection_is_visibly_reversed() {
let theme = Theme::default();
let line = NotesPanel::editor_line("abcd", Some(3), Some(1..3), &theme);
let selected: String = line
.spans
.iter()
.filter(|span| span.style.add_modifier.contains(Modifier::REVERSED))
.map(|span| span.content.as_ref())
.collect();
assert_eq!(selected, "bc");
assert!(line.spans.iter().any(|span| span.content == "▏"));
}
#[test]
fn copy_and_paste_both_reach_the_border_at_the_default_width() {
let line = crate::frame::hint_line(
SELECTION_BINDINGS,
&Theme::default(),
28, )
.expect("the selected-text actions should fit");
let text: String = line
.spans
.iter()
.map(|span| span.content.as_ref())
.collect();
assert!(text.contains("Ctrl+C copy · Ctrl+V paste"), "got {text:?}");
}
#[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!(
LIST_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 a_note_full_of_wide_glyphs_draws_at_every_width() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let config = crate::config::Config::default();
let gradients = config.theme.gradients();
let (mut p, _g) = panel("wide-glyphs");
add_note(
&mut p,
"a\u{1F31E}b \u{4E2D}\u{6587}\u{6807}\u{9898}",
"\u{0301} \u{1F31E}\u{65E5}\u{65E5}\u{65E5}\u{1F31E}\u{1F31E}a\n\
prose with a \u{1F31E} in it and a \u{65E5}\u{672C}\u{8A9E} word",
);
for width in 1..24u16 {
for height in 1..40u16 {
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
terminal
.draw(|frame| {
p.render(
frame,
frame.area(),
RenderContext {
theme: &config.theme,
gradients: &gradients,
focused: true,
watch: &crate::watch::WatchLog::default(),
},
);
})
.unwrap();
}
}
}
#[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,
watch: &crate::watch::WatchLog::default(),
},
);
})
.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:?}");
}
}