use jiff::Zoned;
use ratatui::Frame;
use ratatui::crossterm::event::{KeyCode, KeyEvent, MouseEvent, MouseEventKind};
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{List, ListItem, ListState, Paragraph};
use crate::frame::Binding;
use crate::panel::{KeyOutcome, Panel, RenderContext};
const BINDINGS: &[Binding] = &[
Binding::extra("↑ / ↓", "scroll"),
Binding::extra("j / k", "scroll"),
Binding::extra("g / G", "first / last"),
];
const USEFUL_WIDTH: u16 = 56;
pub struct WatchLogPanel {
scroll: ListState,
drawn: usize,
}
impl WatchLogPanel {
pub fn new() -> Self {
Self {
scroll: ListState::default(),
drawn: 0,
}
}
}
impl Default for WatchLogPanel {
fn default() -> Self {
Self::new()
}
}
impl Panel for WatchLogPanel {
fn title(&self) -> String {
"Watch log".into()
}
fn bindings(&self) -> &'static [Binding] {
BINDINGS
}
fn max_width(&self) -> Option<u16> {
Some(USEFUL_WIDTH + crate::frame::FRAME_WIDTH)
}
fn max_height(&self) -> Option<u16> {
None
}
fn handle_key(&mut self, key: KeyEvent) -> KeyOutcome {
match key.code {
KeyCode::Down | KeyCode::Char('j') => {
crate::selection::down(&mut self.scroll, 1, self.drawn);
}
KeyCode::Up | KeyCode::Char('k') => {
crate::selection::up(&mut self.scroll, 1, self.drawn);
}
KeyCode::PageDown => crate::selection::down(&mut self.scroll, 10, self.drawn),
KeyCode::PageUp => crate::selection::up(&mut self.scroll, 10, self.drawn),
KeyCode::Char('G') | KeyCode::End => {
crate::selection::down(&mut self.scroll, usize::MAX, self.drawn);
}
KeyCode::Char('g') | KeyCode::Home => {
crate::selection::up(&mut self.scroll, usize::MAX, self.drawn);
}
_ => return KeyOutcome::Ignored,
}
KeyOutcome::Consumed
}
fn handle_mouse(&mut self, event: MouseEvent, _area: Rect) -> KeyOutcome {
match event.kind {
MouseEventKind::ScrollDown => crate::selection::down(&mut self.scroll, 1, self.drawn),
MouseEventKind::ScrollUp => crate::selection::up(&mut self.scroll, 1, self.drawn),
_ => 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;
}
let log = ctx.watch;
let unseen = log.unseen();
let mut items: Vec<ListItem> = Vec::new();
for (index, entry) in log.entries().enumerate() {
if unseen == Some(index) {
items.push(ListItem::new(rule_line(
area.width,
"since you were here",
theme,
)));
}
items.push(ListItem::new(Line::from(vec![
Span::styled(
format!("{} ", entry.at.strftime("%H:%M")),
Style::default().fg(theme.muted),
),
Span::styled(
crate::grid::truncate(&entry.text, usize::from(area.width).saturating_sub(6)),
Style::default().fg(theme.text),
),
])));
}
if items.is_empty() {
let width = usize::from(area.width);
let mut lines: Vec<Line<'static>> = crate::grid::wrap("Nothing has happened", width)
.into_iter()
.map(|row| {
Line::from(Span::styled(
row,
Style::default().fg(theme.text).add_modifier(Modifier::BOLD),
))
})
.collect();
lines.extend(
crate::grid::wrap(&format!("since {}.", started(log.since())), width)
.into_iter()
.map(|row| Line::from(Span::styled(row, Style::default().fg(theme.muted)))),
);
lines.push(Line::from(""));
let explain = |lines: &mut Vec<Line<'static>>, text: &str, colour| {
for line in crate::grid::wrap(text, width) {
lines.push(Line::from(Span::styled(line, Style::default().fg(colour))));
}
};
explain(
&mut lines,
"Watching for things you did not do yourself: the day turning, \
a task falling overdue, an entry appearing in your calendar.",
theme.muted,
);
lines.push(Line::from(""));
explain(
&mut lines,
"Calendar entries come from [agenda].file, which f on the \
agenda panel sets.",
theme.muted,
);
frame.render_widget(Paragraph::new(lines), area);
self.drawn = 0;
return;
}
items.push(ListItem::new(Line::from(Span::styled(
format!("watching from {}", started(log.since())),
Style::default().fg(theme.muted),
))));
self.drawn = items.len();
frame.render_stateful_widget(List::new(items), area, &mut self.scroll);
}
}
fn started(since: &Zoned) -> String {
if since.date() == Zoned::now().date() {
since.strftime("%H:%M").to_string()
} else {
since.strftime("%a %H:%M").to_string()
}
}
fn rule_line(width: u16, label: &str, theme: &crate::theme::Theme) -> Line<'static> {
let style = Style::default().fg(theme.rule);
let label_width = crate::grid::display_width(label) + 2;
let dashes = usize::from(width).saturating_sub(label_width);
let left = dashes.saturating_sub(dashes / 3);
Line::from(vec![
Span::styled("─".repeat(left), style),
Span::styled(format!(" {label} "), Style::default().fg(theme.muted)),
Span::styled("─".repeat(dashes - left), style),
])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_rule_line_fills_its_width_exactly() {
let theme = crate::theme::Theme::default();
for width in 24..90u16 {
let line = rule_line(width, "since you were here", &theme);
let drawn: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
assert_eq!(
crate::grid::display_width(&drawn),
usize::from(width),
"at {width}"
);
}
}
fn empty_panel_text(config: &crate::config::Config) -> String {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let gradients = config.theme.gradients();
let mut panel = WatchLogPanel::new();
let (w, h) = (60u16, 14u16);
let mut terminal = Terminal::new(TestBackend::new(w, h)).unwrap();
terminal
.draw(|frame| {
panel.render(
frame,
frame.area(),
RenderContext {
theme: &config.theme,
gradients: &gradients,
focused: false,
watch: &crate::watch::WatchLog::default(),
},
);
})
.unwrap();
let buffer = terminal.backend().buffer().clone();
(0..h)
.map(|y| {
(0..w)
.filter_map(|x| buffer.cell((x, y)).map(|c| c.symbol().to_string()))
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn the_empty_panel_still_says_where_calendar_entries_come_from() {
let text = empty_panel_text(&crate::config::Config::default());
assert!(
text.contains("[agenda].file"),
"the empty log should name the setting; got:\n{text}"
);
assert!(
!text.contains("No calendar set"),
"the empty log must not assert whether a calendar is set; got:\n{text}"
);
}
#[test]
fn the_panel_never_offers_a_counter() {
assert_eq!(WatchLogPanel::new().counter(), None);
}
#[test]
fn nothing_dismisses_acknowledges_or_clears_an_entry() {
let mut panel = WatchLogPanel::new();
panel.drawn = 5;
for code in [
KeyCode::Char('d'),
KeyCode::Char('c'),
KeyCode::Char('x'),
KeyCode::Delete,
KeyCode::Backspace,
KeyCode::Enter,
KeyCode::Char(' '),
] {
assert_eq!(
panel.handle_key(KeyEvent::from(code)),
KeyOutcome::Ignored,
"{code:?} must not be a way to act on an entry"
);
}
}
}