- segment_editor: type start/end times; the exact moments they mean are shown live, and invalid edits keep the popup open. - time_prompt: "I started / paused / ended the day at...". - copy_menu: every copy preset with a preview of what will be pasted. - export_dialog, still_working (for sessions left running very long), help (generated from the key tables) and the `:` command line. Co-Authored-By: Claude Opus 5.5 <[email protected]>
221 lines
7.5 KiB
Rust
221 lines
7.5 KiB
Rust
//! 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 · <Space> 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<Line> = (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:<width$}", width = entry_width - key_width - 3),
|
|
Style::new().fg(color),
|
|
));
|
|
}
|
|
}
|
|
Line::from(spans)
|
|
})
|
|
.collect();
|
|
|
|
frame.render_widget(Clear, rect);
|
|
let block = Block::bordered()
|
|
.border_type(BorderType::Rounded)
|
|
.border_style(ctx.theme.muted())
|
|
.title(Line::styled(format!(" {title} "), ctx.theme.title()));
|
|
frame.render_widget(Paragraph::new(lines).block(block), rect);
|
|
}
|