Files
skyshift/src/ui/mod.rs
T
skkeyeandClaude Opus 5.5 255dcd1392 Add popups: segment editor, copy menu, export, help and command line
- 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]>
2026-09-24 02:59:22 -04:00

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);
}