use crate::args::ReadArgs;
use crate::store::LocalStore;
use anyhow::Result;
use chrono::{DateTime, Datelike, Duration, Local, NaiveDate};
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers};
use crossterm::execute;
use crossterm::terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
};
use note_to_self_lib::{Entry, EntryKind};
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span, Text};
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap};
use ratatui::{Frame, Terminal};
use std::collections::{BTreeMap, BTreeSet};
use std::io::{self, Stdout};
use std::time::{Duration as StdDuration, Instant};
use uuid::Uuid;
pub fn run<F>(
store: &mut LocalStore,
args: &ReadArgs,
config: &crate::config::Config,
keys: ¬e_to_self_lib::DerivedKeys,
mut edit_text: F,
) -> Result<bool>
where
F: FnMut(&str) -> Result<String>,
{
let start_day = args.date.unwrap_or_else(|| Local::now().date_naive());
let entries_by_day = entries_by_day(store.entries.values());
let mut app = ReaderApp::new(store.journal().to_string(), entries_by_day, start_day);
let mut terminal = TerminalSession::enter()?;
let mut mutated = false;
let mut last_mutation: Option<Instant> = None;
loop {
terminal.draw(|frame| app.render(frame))?;
if !event::poll(StdDuration::from_millis(50))? {
if let Some(t) = last_mutation {
if t.elapsed() >= StdDuration::from_secs(2) {
if try_sync(store, config, keys) {
sync_refresh(&mut app, store);
}
last_mutation = None;
}
}
continue;
}
let Event::Key(key) = event::read()? else {
continue;
};
match app.handle_key(key) {
ReaderAction::Continue => {}
ReaderAction::Quit => break,
ReaderAction::New => {
let changed = create_entry(store, &mut app, &mut terminal, &mut edit_text)?;
if changed {
mutated = true;
last_mutation = Some(Instant::now());
}
}
ReaderAction::Edit => {
let changed = edit_entry(store, &mut app, &mut terminal, &mut edit_text)?;
if changed {
mutated = true;
last_mutation = Some(Instant::now());
}
}
}
}
Ok(mutated && last_mutation.is_some())
}
fn create_entry<F>(
store: &mut LocalStore,
app: &mut ReaderApp,
terminal: &mut TerminalSession,
edit_text: &mut F,
) -> Result<bool>
where
F: FnMut(&str) -> Result<String>,
{
let body = edit_with_terminal(terminal, edit_text, "")?;
if body.trim().is_empty() {
app.message = Some("new entry cancelled".to_string());
return Ok(false);
}
let id = store.add_journal_entry(body, &[], false)?;
if let Some(entry) = store.entries.get(&id) {
app.current_day = entry.created_at.with_timezone(&Local).date_naive();
}
refresh_from_store(app, store, Some(id));
app.message = Some(format!("created {}", short_id(id)));
Ok(true)
}
fn edit_entry<F>(
store: &mut LocalStore,
app: &mut ReaderApp,
terminal: &mut TerminalSession,
edit_text: &mut F,
) -> Result<bool>
where
F: FnMut(&str) -> Result<String>,
{
let Some(entry) = app.selected_entry().cloned() else {
app.message = Some("no entry selected".to_string());
return Ok(false);
};
let body = edit_with_terminal(terminal, edit_text, &entry.body)?;
if body.trim().is_empty() {
app.message = Some("edit cancelled".to_string());
return Ok(false);
}
if body == entry.body {
app.message = Some("entry unchanged".to_string());
return Ok(false);
}
let id = store.edit_body(&entry.id, body)?;
refresh_from_store(app, store, Some(id));
app.message = Some(format!("updated {}", short_id(id)));
Ok(true)
}
fn edit_with_terminal<F>(
terminal: &mut TerminalSession,
edit_text: &mut F,
initial: &str,
) -> Result<String>
where
F: FnMut(&str) -> Result<String>,
{
terminal.suspend()?;
let edited = edit_text(initial);
let resume = terminal.resume();
match (edited, resume) {
(Ok(body), Ok(())) => Ok(body),
(Err(error), _) => Err(error),
(_, Err(error)) => Err(error),
}
}
fn refresh_from_store(app: &mut ReaderApp, store: &LocalStore, selected: Option<Uuid>) {
app.replace_entries(entries_by_day(store.entries.values()));
if let Some(id) = selected {
app.select_entry(&id.to_string());
}
}
fn try_sync(
store: &mut LocalStore,
config: &crate::config::Config,
keys: ¬e_to_self_lib::DerivedKeys,
) -> bool {
if config.sync.is_none() {
return false;
}
tokio::task::block_in_place(|| {
tokio::runtime::Handle::current()
.block_on(crate::sync_client::sync_once(store, config, keys))
.is_ok()
})
}
fn sync_refresh(app: &mut ReaderApp, store: &LocalStore) {
let selected_id = app.selected_entry().map(|e| e.id.clone());
app.replace_entries(entries_by_day(store.entries.values()));
if let Some(id) = selected_id {
app.select_entry(&id);
}
}
struct TerminalSession {
terminal: Terminal<CrosstermBackend<Stdout>>,
active: bool,
}
impl TerminalSession {
fn enter() -> Result<Self> {
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let terminal = Terminal::new(backend)?;
Ok(Self {
terminal,
active: true,
})
}
fn draw<F>(&mut self, render: F) -> Result<()>
where
F: FnOnce(&mut Frame),
{
self.terminal.draw(render)?;
Ok(())
}
fn suspend(&mut self) -> Result<()> {
if self.active {
disable_raw_mode()?;
execute!(self.terminal.backend_mut(), LeaveAlternateScreen)?;
self.terminal.show_cursor()?;
self.active = false;
}
Ok(())
}
fn resume(&mut self) -> Result<()> {
if !self.active {
enable_raw_mode()?;
execute!(self.terminal.backend_mut(), EnterAlternateScreen)?;
self.terminal.clear()?;
self.active = true;
}
Ok(())
}
}
impl Drop for TerminalSession {
fn drop(&mut self) {
if self.active {
let _ = disable_raw_mode();
let _ = execute!(self.terminal.backend_mut(), LeaveAlternateScreen);
let _ = self.terminal.show_cursor();
self.active = false;
}
}
}
#[derive(Debug, Clone)]
struct ReaderEntry {
id: String,
created_at: DateTime<Local>,
body: String,
preview: String,
image_labels: Vec<String>,
tags: Vec<String>,
starred: bool,
}
impl ReaderEntry {
fn short_id(&self) -> String {
self.id.chars().take(8).collect()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Mode {
Reading,
Calendar,
Help,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ReaderAction {
Continue,
Quit,
New,
Edit,
}
struct ReaderApp {
journal: String,
entries_by_day: BTreeMap<NaiveDate, Vec<ReaderEntry>>,
entry_days: BTreeSet<NaiveDate>,
current_day: NaiveDate,
selected: usize,
detail_scroll: u16,
mode: Mode,
calendar_cursor: NaiveDate,
message: Option<String>,
}
impl ReaderApp {
fn new(
journal: String,
entries_by_day: BTreeMap<NaiveDate, Vec<ReaderEntry>>,
current_day: NaiveDate,
) -> Self {
let entry_days = entries_by_day.keys().copied().collect();
Self {
journal,
entries_by_day,
entry_days,
current_day,
selected: 0,
detail_scroll: 0,
mode: Mode::Reading,
calendar_cursor: current_day,
message: None,
}
}
fn render(&mut self, frame: &mut Frame) {
let area = frame.area();
let content = centered_content(area);
let entry_count = self.day_entries().len();
let list_h = (entry_count as u16).max(1).min(10);
let has_entries = entry_count > 0;
let chunks = if has_entries {
Layout::vertical([
Constraint::Fill(1),
Constraint::Length(3),
Constraint::Length(list_h),
Constraint::Length(1),
Constraint::Fill(4),
Constraint::Length(2),
Constraint::Fill(1),
])
.split(content)
} else {
Layout::vertical([
Constraint::Fill(1),
Constraint::Length(3),
Constraint::Length(1),
Constraint::Length(0),
Constraint::Fill(0),
Constraint::Length(2),
Constraint::Fill(3),
])
.split(content)
};
self.render_title(frame, chunks[1]);
self.render_entry_list(frame, chunks[2]);
if has_entries {
self.render_separator(frame, chunks[3]);
self.render_detail(frame, chunks[4]);
}
self.render_footer(frame, chunks[5]);
match self.mode {
Mode::Reading => {}
Mode::Calendar => self.render_calendar(frame, area),
Mode::Help => self.render_help(frame, area),
}
}
fn render_title(&self, frame: &mut Frame, area: Rect) {
let count = self.day_entries().len();
let date_line = Line::styled(
self.current_day.format("%A, %B %-d, %Y").to_string(),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
);
let info_line = Line::styled(
format!("{} \u{00b7} {}", self.journal, plural(count, "entry")),
Style::default().fg(Color::DarkGray),
);
let text = Text::from(vec![date_line, info_line]);
frame.render_widget(Paragraph::new(text).alignment(Alignment::Center), area);
}
fn render_entry_list(&mut self, frame: &mut Frame, area: Rect) {
let len = self.day_entries().len();
if len == 0 {
self.selected = 0;
} else {
self.selected = self.selected.min(len.saturating_sub(1));
}
let entries = self.day_entries();
let body_max = area.width.saturating_sub(14) as usize;
let items: Vec<ListItem> = if entries.is_empty() {
vec![ListItem::new(Line::styled(
"no entries on this day",
Style::default().fg(Color::DarkGray),
))]
} else {
entries
.iter()
.map(|entry| {
let star = if entry.starred { "*" } else { " " };
let tag_text = if entry.tags.is_empty() {
String::new()
} else {
format!(
" {}",
entry
.tags
.iter()
.map(|t| format!("#{t}"))
.collect::<Vec<_>>()
.join(" ")
)
};
ListItem::new(Line::from(vec![
Span::styled(
entry.created_at.format("%H:%M").to_string(),
Style::default().fg(Color::Cyan),
),
Span::raw(format!(" {star} ")),
Span::raw(one_line(&entry.preview, body_max)),
Span::styled(tag_text, Style::default().fg(Color::Yellow)),
]))
})
.collect()
};
let mut state = ListState::default();
if !entries.is_empty() {
state.select(Some(self.selected));
}
let list = List::new(items)
.highlight_style(Style::default().add_modifier(Modifier::BOLD))
.highlight_symbol("> ");
frame.render_stateful_widget(list, area, &mut state);
}
fn render_separator(&self, frame: &mut Frame, area: Rect) {
let rule = "\u{2500}".repeat(area.width.saturating_sub(2) as usize);
let line = Line::styled(rule, Style::default().fg(Color::DarkGray));
frame.render_widget(Paragraph::new(Text::from(line)), area);
}
fn render_detail(&self, frame: &mut Frame, area: Rect) {
let text = match self.selected_entry() {
Some(entry) => {
let mut lines: Vec<Line> = entry.body.lines().map(Line::raw).collect();
if !entry.image_labels.is_empty() {
lines.push(Line::raw(""));
for label in &entry.image_labels {
lines.push(Line::styled(
label.clone(),
Style::default().fg(Color::Cyan),
));
}
}
Text::from(lines)
}
None => Text::from(Line::styled(
"no entry selected",
Style::default().fg(Color::DarkGray),
)),
};
let paragraph = Paragraph::new(text)
.wrap(Wrap { trim: false })
.scroll((self.detail_scroll, 0));
frame.render_widget(paragraph, area);
}
fn render_footer(&self, frame: &mut Frame, area: Rect) {
if area.height < 2 {
return;
}
let line1 = if let Some(msg) = &self.message {
Line::styled(msg.clone(), Style::default().fg(Color::Yellow))
} else {
self.selected_info_line()
};
let hints = self.footer_hints(area.width);
let line2 = Line::styled(hints, Style::default().fg(Color::DarkGray));
let text = Text::from(vec![line1, line2]);
frame.render_widget(Paragraph::new(text), area);
}
fn selected_info_line(&self) -> Line<'static> {
let Some(entry) = self.selected_entry() else {
return Line::raw("");
};
let mut parts = vec![entry.short_id()];
parts.push(entry.created_at.format("%H:%M").to_string());
if !entry.tags.is_empty() {
parts.push(
entry
.tags
.iter()
.map(|t| format!("#{t}"))
.collect::<Vec<_>>()
.join(" "),
);
}
Line::styled(
parts.join(" \u{00b7} "),
Style::default().fg(Color::DarkGray),
)
}
fn footer_hints(&self, width: u16) -> &'static str {
match (self.mode, width) {
(Mode::Reading, 0..=80) => "h/l day \u{00b7} n new \u{00b7} e edit \u{00b7} c cal \u{00b7} ? help",
(Mode::Reading, _) => {
"h/l day \u{00b7} [/] entry day \u{00b7} n new \u{00b7} e edit \u{00b7} c calendar \u{00b7} ? help \u{00b7} q quit"
}
(Mode::Calendar, 0..=80) => {
"enter jump \u{00b7} esc close \u{00b7} h/l day \u{00b7} j/k week \u{00b7} H/L month"
}
(Mode::Calendar, _) => {
"enter jump \u{00b7} esc close \u{00b7} h/l day \u{00b7} j/k week \u{00b7} H/L month \u{00b7} t today"
}
(Mode::Help, _) => "esc close \u{00b7} q quit",
}
}
fn render_calendar(&self, frame: &mut Frame, area: Rect) {
let popup = centered_popup(area, 52, 17);
frame.render_widget(Clear, popup);
let block = Block::default()
.borders(Borders::ALL)
.title(" calendar ")
.border_style(Style::default().fg(Color::DarkGray));
let inner = block.inner(popup);
frame.render_widget(block, popup);
let first = first_of_month(self.calendar_cursor);
let today = Local::now().date_naive();
let mut lines = vec![
Line::styled(
self.calendar_cursor.format("%B %Y").to_string(),
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
),
Line::raw(""),
Line::styled(
" Mo Tu We Th Fr Sa Su ",
Style::default().fg(Color::DarkGray),
),
];
let leading = first.weekday().num_days_from_monday() as i64;
let mut day = first - Duration::days(leading);
for _ in 0..6 {
let mut spans = Vec::new();
for _ in 0..7 {
if day.month() == first.month() {
let has_entries = self.entry_days.contains(&day);
let is_cursor = day == self.calendar_cursor;
let is_today = day == today;
let cell = if is_cursor && has_entries {
format!("[{:02}]*", day.day())
} else if is_cursor {
format!("[{:02}] ", day.day())
} else if has_entries {
format!(" {:02}* ", day.day())
} else {
format!(" {:02} ", day.day())
};
let mut style = Style::default();
if has_entries {
style = style.fg(Color::Yellow);
}
if is_today {
style = style.add_modifier(Modifier::UNDERLINED);
}
if is_cursor {
style = style
.fg(Color::Black)
.bg(Color::Cyan)
.add_modifier(Modifier::BOLD);
}
spans.push(Span::styled(cell, style));
} else {
spans.push(Span::raw(" "));
}
day += Duration::days(1);
}
lines.push(Line::from(spans));
}
lines.push(Line::raw(""));
lines.push(Line::styled(
"* = entries",
Style::default().fg(Color::DarkGray),
));
let paragraph = Paragraph::new(Text::from(lines)).alignment(Alignment::Center);
frame.render_widget(paragraph, inner);
}
fn render_help(&self, frame: &mut Frame, area: Rect) {
let popup = centered_popup(area, 56, 20);
frame.render_widget(Clear, popup);
let lines = vec![
Line::styled(
" keybindings",
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
),
Line::raw(""),
Line::raw(" j/k next/previous entry"),
Line::raw(" h/l previous/next day"),
Line::raw(" [/] previous/next day with entries"),
Line::raw(" g today"),
Line::raw(" G latest day with entries"),
Line::raw(" n new entry"),
Line::raw(" e/enter edit selected entry"),
Line::raw(" J/K scroll entry text"),
Line::raw(" ctrl-d/u scroll entry text (page)"),
Line::raw(" c calendar"),
Line::raw(" ? close help"),
Line::raw(" q/esc quit"),
Line::raw(""),
Line::styled(
" calendar: h/l day, j/k week, H/L month, t today",
Style::default().fg(Color::DarkGray),
),
Line::styled(
" press ? or esc to close",
Style::default().fg(Color::DarkGray),
),
];
let help = Paragraph::new(Text::from(lines)).block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::DarkGray)),
);
frame.render_widget(help, popup);
}
fn handle_key(&mut self, key: KeyEvent) -> ReaderAction {
match self.mode {
Mode::Reading => self.handle_reading_key(key),
Mode::Calendar => self.handle_calendar_key(key),
Mode::Help => self.handle_help_key(key),
}
}
fn handle_reading_key(&mut self, key: KeyEvent) -> ReaderAction {
match key.code {
KeyCode::Char('q') | KeyCode::Esc => ReaderAction::Quit,
KeyCode::Char('n') => ReaderAction::New,
KeyCode::Char('e') | KeyCode::Enter => ReaderAction::Edit,
KeyCode::Char('j') | KeyCode::Down => {
self.move_selection(1);
ReaderAction::Continue
}
KeyCode::Char('k') | KeyCode::Up => {
self.move_selection(-1);
ReaderAction::Continue
}
KeyCode::Char('h') | KeyCode::Left => {
self.move_day(-1);
ReaderAction::Continue
}
KeyCode::Char('l') | KeyCode::Right => {
self.move_day(1);
ReaderAction::Continue
}
KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.scroll_detail(8);
ReaderAction::Continue
}
KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.scroll_detail(-8);
ReaderAction::Continue
}
KeyCode::Char('J') => {
self.scroll_detail(3);
ReaderAction::Continue
}
KeyCode::Char('K') => {
self.scroll_detail(-3);
ReaderAction::Continue
}
KeyCode::Char('[') => {
self.jump_entry_day(-1);
ReaderAction::Continue
}
KeyCode::Char(']') => {
self.jump_entry_day(1);
ReaderAction::Continue
}
KeyCode::Char('g') => {
self.jump_to(Local::now().date_naive());
ReaderAction::Continue
}
KeyCode::Char('G') => {
self.jump_latest_entry_day();
ReaderAction::Continue
}
KeyCode::Char('c') => {
self.calendar_cursor = self.current_day;
self.mode = Mode::Calendar;
self.message = None;
ReaderAction::Continue
}
KeyCode::Char('?') => {
self.mode = Mode::Help;
self.message = None;
ReaderAction::Continue
}
KeyCode::Home => {
self.move_selection_to(0);
ReaderAction::Continue
}
KeyCode::End => {
self.move_selection_to(usize::MAX);
ReaderAction::Continue
}
_ => ReaderAction::Continue,
}
}
fn handle_calendar_key(&mut self, key: KeyEvent) -> ReaderAction {
match key.code {
KeyCode::Char('q') => ReaderAction::Quit,
KeyCode::Esc | KeyCode::Char('c') => {
self.mode = Mode::Reading;
ReaderAction::Continue
}
KeyCode::Enter => {
self.jump_to(self.calendar_cursor);
self.mode = Mode::Reading;
ReaderAction::Continue
}
KeyCode::Char('h') | KeyCode::Left => {
self.move_calendar(Duration::days(-1));
ReaderAction::Continue
}
KeyCode::Char('l') | KeyCode::Right => {
self.move_calendar(Duration::days(1));
ReaderAction::Continue
}
KeyCode::Char('k') | KeyCode::Up => {
self.move_calendar(Duration::days(-7));
ReaderAction::Continue
}
KeyCode::Char('j') | KeyCode::Down => {
self.move_calendar(Duration::days(7));
ReaderAction::Continue
}
KeyCode::Char('H') => {
self.calendar_cursor = add_months(self.calendar_cursor, -1);
ReaderAction::Continue
}
KeyCode::Char('L') => {
self.calendar_cursor = add_months(self.calendar_cursor, 1);
ReaderAction::Continue
}
KeyCode::Char('t') | KeyCode::Char('g') => {
self.calendar_cursor = Local::now().date_naive();
ReaderAction::Continue
}
_ => ReaderAction::Continue,
}
}
fn handle_help_key(&mut self, key: KeyEvent) -> ReaderAction {
match key.code {
KeyCode::Char('q') => ReaderAction::Quit,
KeyCode::Esc | KeyCode::Char('?') => {
self.mode = Mode::Reading;
ReaderAction::Continue
}
_ => ReaderAction::Continue,
}
}
fn day_entries(&self) -> &[ReaderEntry] {
self.entries_by_day
.get(&self.current_day)
.map(Vec::as_slice)
.unwrap_or(&[])
}
fn selected_entry(&self) -> Option<&ReaderEntry> {
self.day_entries().get(self.selected)
}
fn replace_entries(&mut self, entries_by_day: BTreeMap<NaiveDate, Vec<ReaderEntry>>) {
self.entries_by_day = entries_by_day;
self.entry_days = self.entries_by_day.keys().copied().collect();
self.selected = self
.selected
.min(self.day_entries().len().saturating_sub(1));
self.detail_scroll = 0;
self.calendar_cursor = self.current_day;
}
fn select_entry(&mut self, id: &str) {
if let Some(pos) = self
.day_entries()
.iter()
.position(|e| e.id == id || e.short_id() == id)
{
self.selected = pos;
self.detail_scroll = 0;
}
}
fn move_selection(&mut self, delta: isize) {
let len = self.day_entries().len();
if len == 0 {
self.selected = 0;
return;
}
self.selected =
(self.selected as isize + delta).clamp(0, len.saturating_sub(1) as isize) as usize;
self.detail_scroll = 0;
self.message = None;
}
fn move_selection_to(&mut self, target: usize) {
let len = self.day_entries().len();
self.selected = if len == 0 {
0
} else {
target.min(len.saturating_sub(1))
};
self.detail_scroll = 0;
self.message = None;
}
fn move_day(&mut self, delta_days: i64) {
self.jump_to(self.current_day + Duration::days(delta_days));
}
fn jump_to(&mut self, day: NaiveDate) {
self.current_day = day;
self.selected = 0;
self.detail_scroll = 0;
self.message = None;
}
fn jump_entry_day(&mut self, direction: i8) {
let target = if direction < 0 {
self.entry_days
.iter()
.rev()
.find(|d| **d < self.current_day)
.copied()
} else {
self.entry_days
.iter()
.find(|d| **d > self.current_day)
.copied()
};
match target {
Some(day) => self.jump_to(day),
None => {
self.message = Some(if direction < 0 {
"no earlier day with entries".to_string()
} else {
"no later day with entries".to_string()
});
}
}
}
fn jump_latest_entry_day(&mut self) {
if let Some(day) = self.entry_days.iter().next_back().copied() {
self.jump_to(day);
} else {
self.message = Some("no entries in this journal".to_string());
}
}
fn scroll_detail(&mut self, delta: i16) {
if delta < 0 {
self.detail_scroll = self.detail_scroll.saturating_sub(delta.unsigned_abs());
} else {
self.detail_scroll = self.detail_scroll.saturating_add(delta as u16);
}
}
fn move_calendar(&mut self, delta: Duration) {
self.calendar_cursor += delta;
}
}
fn entries_by_day<'a>(
entries: impl Iterator<Item = &'a Entry>,
) -> BTreeMap<NaiveDate, Vec<ReaderEntry>> {
let mut by_day: BTreeMap<NaiveDate, Vec<ReaderEntry>> = BTreeMap::new();
for entry in entries {
if entry.deleted || !matches!(&entry.kind, EntryKind::Journal) {
continue;
}
let created_at = entry.created_at.with_timezone(&Local);
by_day
.entry(created_at.date_naive())
.or_default()
.push(ReaderEntry {
id: entry.id.to_string(),
created_at,
body: entry.body.clone(),
preview: crate::images::body_preview(entry, usize::MAX),
image_labels: crate::images::attachment_labels(entry),
tags: entry.tags.clone(),
starred: entry.starred,
});
}
for entries in by_day.values_mut() {
entries.sort_by_key(|e| e.created_at);
}
by_day
}
fn one_line(body: &str, max_chars: usize) -> String {
let line = body.lines().next().unwrap_or_default().trim();
if line.chars().count() > max_chars {
format!(
"{}...",
line.chars()
.take(max_chars.saturating_sub(3))
.collect::<String>()
)
} else {
line.to_string()
}
}
fn plural(count: usize, noun: &str) -> String {
if count == 1 {
format!("1 {noun}")
} else {
format!("{count} {noun}s")
}
}
fn centered_content(area: Rect) -> Rect {
let width = 76u16.min(area.width.saturating_sub(2));
Rect {
x: area.x + (area.width.saturating_sub(width)) / 2,
y: area.y,
width,
height: area.height,
}
}
fn centered_popup(area: Rect, width: u16, height: u16) -> Rect {
let width = width.min(area.width);
let height = height.min(area.height);
Rect {
x: area.x + area.width.saturating_sub(width) / 2,
y: area.y + area.height.saturating_sub(height) / 2,
width,
height,
}
}
fn short_id(id: Uuid) -> String {
id.to_string().chars().take(8).collect()
}
fn first_of_month(day: NaiveDate) -> NaiveDate {
NaiveDate::from_ymd_opt(day.year(), day.month(), 1).expect("valid month")
}
fn days_in_month(year: i32, month: u32) -> u32 {
let (next_year, next_month) = if month == 12 {
(year + 1, 1)
} else {
(year, month + 1)
};
let next_month_first = NaiveDate::from_ymd_opt(next_year, next_month, 1).expect("valid month");
(next_month_first - Duration::days(1)).day()
}
fn add_months(day: NaiveDate, delta: i32) -> NaiveDate {
let total_months = day.year() * 12 + day.month0() as i32 + delta;
let year = total_months.div_euclid(12);
let month0 = total_months.rem_euclid(12);
let month = month0 as u32 + 1;
let target_day = day.day().min(days_in_month(year, month));
NaiveDate::from_ymd_opt(year, month, target_day).expect("valid shifted month")
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{TimeZone, Utc};
use note_to_self_lib::{Entry, Hlc, Priority};
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
#[test]
fn month_math_handles_leap_years_and_short_months() {
assert_eq!(days_in_month(2024, 2), 29);
assert_eq!(days_in_month(2025, 2), 28);
assert_eq!(
add_months(NaiveDate::from_ymd_opt(2026, 3, 31).unwrap(), -1),
NaiveDate::from_ymd_opt(2026, 2, 28).unwrap()
);
}
#[test]
fn groups_only_live_journal_entries_by_local_day() {
let mut journal = Entry::new_journal("hello".to_string(), &[], false, Hlc::new(1));
journal.created_at = Utc.with_ymd_and_hms(2026, 5, 14, 12, 0, 0).unwrap();
let mut deleted = Entry::new_journal("deleted".to_string(), &[], false, Hlc::new(1));
deleted.created_at = journal.created_at;
deleted.deleted = true;
let todo = Entry::new_todo("todo".to_string(), &[], Priority::Medium, None, Hlc::new(1));
let entries = vec![journal.clone(), deleted, todo];
let grouped = entries_by_day(entries.iter());
assert_eq!(grouped.values().map(Vec::len).sum::<usize>(), 1);
assert_eq!(
grouped
.get(&journal.created_at.with_timezone(&Local).date_naive())
.unwrap()[0]
.body,
"hello"
);
}
#[test]
fn reading_keys_request_new_and_edit_actions() {
let today = Local::now().date_naive();
let mut app = ReaderApp::new("personal".to_string(), BTreeMap::new(), today);
assert_eq!(app.handle_key(key(KeyCode::Char('n'))), ReaderAction::New);
assert_eq!(app.handle_key(key(KeyCode::Char('e'))), ReaderAction::Edit);
assert_eq!(app.handle_key(key(KeyCode::Enter)), ReaderAction::Edit);
assert_eq!(app.handle_key(key(KeyCode::Char('q'))), ReaderAction::Quit);
}
#[test]
fn replacing_entries_can_select_by_full_or_short_id() {
let mut entry = Entry::new_journal("hello".to_string(), &[], false, Hlc::new(1));
entry.created_at = Utc.with_ymd_and_hms(2026, 5, 14, 12, 0, 0).unwrap();
let full_id = entry.id.to_string();
let short_id = entry.short_id();
let day = entry.created_at.with_timezone(&Local).date_naive();
let entries = vec![entry];
let mut app = ReaderApp::new("personal".to_string(), BTreeMap::new(), day);
app.replace_entries(entries_by_day(entries.iter()));
app.select_entry(&full_id);
assert_eq!(app.selected_entry().unwrap().body, "hello");
app.select_entry(&short_id);
assert_eq!(app.selected_entry().unwrap().body, "hello");
}
}