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 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(entry.text.clone(), Style::default().fg(theme.text)),
])));
}
if items.is_empty() {
frame.render_widget(
Paragraph::new(vec![
Line::from(Span::styled(
"Nothing has happened",
Style::default().fg(theme.text).add_modifier(Modifier::BOLD),
)),
Line::from(Span::styled(
format!("since {}.", started(log.since())),
Style::default().fg(theme.muted),
)),
]),
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}"
);
}
}
#[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"
);
}
}
}