//! The terminal user interface. //! //! - [`keymap`]: every key sequence, vim-style, turned into [`keymap::Command`]s. //! - [`component`]: what screens and popups have in common. //! - [`screens`]: Tracker, Days, Insights, Settings (one per tab). //! - [`popups`]: small windows for one task (editing a segment, copying...). //! - [`widgets`]: building blocks (big digits, buttons, timeline, heatmap...). //! - [`theme`]: the colors. //! - [`terminal`]: switching the terminal into and out of full-screen mode. //! //! This file draws what surrounds every screen: the tabs at the top, the //! status line at the bottom, and the which-key hints. pub mod component; pub mod keymap; pub mod popups; pub mod terminal; pub mod theme; pub mod widgets; use std::time::Instant; use ratatui::Frame; use ratatui::layout::{Constraint, Layout, Rect}; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, BorderType, Clear, Paragraph}; pub use component::{Component, Context}; use crate::action::Action; use crate::domain::ShiftState; /// The screens, in tab order. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Tab { Tracker, Days, Insights, Settings, } impl Tab { pub const ALL: [Tab; 4] = [Tab::Tracker, Tab::Days, Tab::Insights, Tab::Settings]; pub fn title(self) -> &'static str { match self { Tab::Tracker => "Tracker", Tab::Days => "Days", Tab::Insights => "Insights", Tab::Settings => "Settings", } } pub fn next(self) -> Tab { Tab::ALL[(self.index() + 1) % Tab::ALL.len()] } pub fn previous(self) -> Tab { Tab::ALL[(self.index() + Tab::ALL.len() - 1) % Tab::ALL.len()] } fn index(self) -> usize { Tab::ALL.iter().position(|tab| *tab == self).unwrap_or(0) } } /// Opens a popup (a shortcut used by the screens). pub fn popup(component: impl Component + 'static) -> Action { Action::OpenPopup(Box::new(component)) } /// A message in the status line. #[derive(Debug, Clone)] pub struct Message { pub text: String, pub tone: Tone, pub shown_at: Instant, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Tone { Info, Good, Nudge, Problem, } /// Splits the window: tabs on top, the screen, the status line. pub fn layout(area: Rect) -> [Rect; 3] { Layout::vertical([Constraint::Length(1), Constraint::Fill(1), Constraint::Length(1)]).areas(area) } /// The top line: the app's name, the tabs, and the date and time. Returns /// where each tab was drawn (for mouse clicks). pub fn draw_header(frame: &mut Frame, area: Rect, current: Tab, ctx: &Context) -> Vec<(Tab, Rect)> { let mut x = area.x; let mut put = |frame: &mut Frame, text: String, style: Style| { let width = text.chars().count() as u16; let rect = Rect { x, y: area.y, width, height: 1 }.intersection(area); frame.render_widget(Paragraph::new(Span::styled(text, style)), rect); x += width; rect }; put( frame, " skyshift ".into(), Style::new().fg(Color::Black).bg(ctx.theme.accent).add_modifier(Modifier::BOLD), ); put(frame, " ".into(), Style::new()); let mut tabs = Vec::new(); for tab in Tab::ALL { let style = if tab == current { ctx.theme.title().add_modifier(Modifier::UNDERLINED) } else { ctx.theme.muted() }; tabs.push((tab, put(frame, format!(" {} ", tab.title()), style))); } let time = if ctx.settings.show_seconds { ctx.fmt.time_with_seconds(ctx.now.time()) } else { ctx.fmt.time(ctx.now.time()) }; let clock = Line::from(vec![ Span::styled(ctx.fmt.date_short(ctx.now.date()), ctx.theme.muted()), Span::raw(" "), Span::styled(time, Style::new().add_modifier(Modifier::BOLD)), Span::raw(" "), ]); frame.render_widget(Paragraph::new(clock).right_aligned(), area); tabs } /// The bottom line: what you're doing on the left, then a message (or key /// hints), and the keys typed so far on the right, like vim's "showcmd". pub fn draw_footer(frame: &mut Frame, area: Rect, ctx: &Context, message: Option<&Message>, typed: &str) { let state = ctx.journal.state(); let now = ctx.now.timestamp(); let pill_text = match state { ShiftState::Working { .. } => format!(" ● {} ", ctx.fmt.duration(ctx.journal.running_for(now))), ShiftState::OnBreak { .. } => { format!(" ◐ break {} ", ctx.fmt.duration(ctx.journal.on_break_for(now).unwrap_or_default())) } ShiftState::Idle => " ○ off ".to_string(), }; let pill = Span::styled( pill_text, Style::new().fg(Color::Black).bg(ctx.theme.state(state)).add_modifier(Modifier::BOLD), ); let middle = match message { Some(message) => { let color = match message.tone { Tone::Info => Color::Reset, Tone::Good => ctx.theme.working, Tone::Nudge => ctx.theme.warning, Tone::Problem => ctx.theme.error, }; Span::styled(format!(" {}", message.text), Style::new().fg(color)) } None => Span::styled( " s start · p pause · e end day · yy copy · actions · ? keys · q quit", ctx.theme.muted(), ), }; frame.render_widget(Paragraph::new(Line::from(vec![pill, middle])), area); frame.render_widget( Paragraph::new(Span::styled(format!("{typed} "), ctx.theme.title())).right_aligned(), area, ); } /// The which-key popup: the keys that can follow what you typed, in the /// bottom right corner of `area`. pub fn draw_which_key(frame: &mut Frame, area: Rect, title: &str, hints: &[(String, String)], ctx: &Context) { if hints.is_empty() { return; } let key_width = hints.iter().map(|(key, _)| key.chars().count()).max().unwrap_or(1); let entry_width = hints.iter().map(|(_, help)| help.chars().count()).max().unwrap_or(1) + key_width + 5; let max_rows = usize::from(area.height.saturating_sub(4)).max(1); let columns = hints.len().div_ceil(max_rows).max(1); let rows = hints.len().div_ceil(columns); let width = ((entry_width * columns) as u16 + 2).min(area.width); let height = (rows as u16 + 2).min(area.height); let rect = Rect { // Inside the screen's border. x: area.right().saturating_sub(width + 2), y: area.bottom().saturating_sub(height + 1), width, height, }; let lines: Vec = (0..rows) .map(|row| { let mut spans = Vec::new(); for column in 0..columns { if let Some((key, help)) = hints.get(column * rows + row) { spans.push(Span::styled(format!(" {key:>key_width$} "), ctx.theme.title())); let color = if help.starts_with('+') { ctx.theme.accent } else { Color::Reset }; spans.push(Span::styled( format!("{help: