Add the UI foundation: vim keymap, widgets and the component contract
- keymap: every key sequence in vim notation (gg, yy, <C-a>, <Space>s), with counts (5j), per-screen overrides and which-key hints, all from the same tables. - Component: the one trait for screens and popups. They only look at the data through a Context and answer with Actions. - Action: everything a screen can ask the app to do. - widgets: big digits, buttons, a day timeline, a heatmap, a text field. - ui/mod.rs: tabs, status line and the which-key popup; terminal.rs sets the terminal up (and restores it after a crash). Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
eff7834838
commit
7c8a02275e
13 files changed
+1588
No files matched your search
+219
@@ -0,0 +1,219 @@
|
||||
//! 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 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);
|
||||
}
|
||||
Reference in new issue
Block a user