Add the Tracker, Days, Insights and Settings screens
- Tracker: big running timer, pomodoro line, clickable buttons, today's progress and timeline, this week. - Days: work days grouped by week, the selected day's details, timeline and segments to edit, add, delete and copy. - Insights: overview cards and charts, calendar heatmap, weekday x hour rhythm, session lengths and breaks. - Settings: generated from the settings table; expert settings are folded away (za). Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
255dcd1392
commit
57ea9403b6
6 files changed
+1663
No files matched your search
@@ -14,6 +14,7 @@
|
||||
pub mod component;
|
||||
pub mod keymap;
|
||||
pub mod popups;
|
||||
pub mod screens;
|
||||
pub mod terminal;
|
||||
pub mod theme;
|
||||
pub mod widgets;
|
||||
|
||||
@@ -0,0 +1,406 @@
|
||||
//! The Days screen: every work day (newest first, grouped by week) on the
|
||||
//! left; the selected day's details and segments on the right. This is
|
||||
//! where you fix, add, delete and copy.
|
||||
|
||||
use jiff::SignedDuration;
|
||||
use jiff::civil::Date;
|
||||
use ratatui::Frame;
|
||||
use ratatui::crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
|
||||
use ratatui::layout::{Constraint, Layout, Position, Rect};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, BorderType, List, ListItem, ListState, Paragraph, Row, Table, TableState};
|
||||
|
||||
use super::copy_action;
|
||||
use crate::action::Action;
|
||||
use crate::domain::{Segment, ShiftState, WorkDay};
|
||||
use crate::time::{DateRange, DurationStyle};
|
||||
use crate::ui::keymap::{self, Binding, Command};
|
||||
use crate::ui::popups::copy_menu::CopyMenu;
|
||||
use crate::ui::popups::segment_editor::{SegmentEditor, Target};
|
||||
use crate::ui::widgets::timeline::{self, Timeline, window_for};
|
||||
use crate::ui::{Component, Context, popup};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Pane {
|
||||
Days,
|
||||
Segments,
|
||||
}
|
||||
|
||||
/// A row of the day list: a week heading, or a day.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Entry {
|
||||
Week { first: Date, worked: SignedDuration },
|
||||
Day(Date),
|
||||
}
|
||||
|
||||
pub struct Days {
|
||||
/// The selected work day (a date, so it survives changes to the list).
|
||||
selected: Option<Date>,
|
||||
/// The selected segment of that day.
|
||||
segment: usize,
|
||||
pane: Pane,
|
||||
list_state: ListState,
|
||||
table_state: TableState,
|
||||
/// What was drawn where, for the mouse and for half-page moves.
|
||||
entries: Vec<Entry>,
|
||||
list_area: Rect,
|
||||
table_area: Rect,
|
||||
}
|
||||
|
||||
impl Days {
|
||||
pub fn new() -> Days {
|
||||
Days {
|
||||
selected: None,
|
||||
segment: 0,
|
||||
pane: Pane::Days,
|
||||
list_state: ListState::default(),
|
||||
table_state: TableState::default(),
|
||||
entries: Vec::new(),
|
||||
list_area: Rect::default(),
|
||||
table_area: Rect::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn select(&mut self, date: Date) {
|
||||
self.selected = Some(date);
|
||||
self.segment = 0;
|
||||
}
|
||||
|
||||
/// The work days to list, newest first.
|
||||
fn dates(ctx: &Context) -> Vec<Date> {
|
||||
let open_day = ctx.journal.state().open_day();
|
||||
ctx.days
|
||||
.iter()
|
||||
.rev()
|
||||
.filter(|day| !day.is_empty() || Some(day.date()) == open_day)
|
||||
.map(WorkDay::date)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The rows of the list: each day, with a heading where a week starts.
|
||||
fn entries(dates: &[Date], ctx: &Context) -> Vec<Entry> {
|
||||
let mut entries = Vec::new();
|
||||
for date in dates {
|
||||
let week = DateRange::week_of(*date, ctx.settings.week_start);
|
||||
let same_week = matches!(entries.last(), Some(Entry::Day(previous)) if week.contains(*previous));
|
||||
if !same_week {
|
||||
let worked =
|
||||
ctx.days.iter().filter(|day| week.contains(day.date())).map(WorkDay::worked).sum();
|
||||
entries.push(Entry::Week { first: week.first(), worked });
|
||||
}
|
||||
entries.push(Entry::Day(*date));
|
||||
}
|
||||
entries
|
||||
}
|
||||
|
||||
/// The selected day, falling back to the newest one.
|
||||
fn current(&self, dates: &[Date]) -> Option<(usize, Date)> {
|
||||
let index = self.selected.and_then(|date| dates.iter().position(|d| *d == date)).unwrap_or(0);
|
||||
dates.get(index).map(|date| (index, *date))
|
||||
}
|
||||
|
||||
/// Moves the selection: `to` gets the current index and the last index.
|
||||
fn move_day(&mut self, dates: &[Date], to: impl FnOnce(usize, usize) -> usize) {
|
||||
if let Some((index, _)) = self.current(dates) {
|
||||
let last = dates.len() - 1;
|
||||
self.select(dates[to(index, last).min(last)]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Jumps to the first day of the week below (`down`) or above.
|
||||
fn move_week(&mut self, dates: &[Date], down: bool, ctx: &Context) {
|
||||
let Some((index, date)) = self.current(dates) else { return };
|
||||
let week_of = |date: Date| DateRange::week_of(date, ctx.settings.week_start);
|
||||
// The index of the first listed day of the week containing dates[at].
|
||||
let top_of_week = |at: usize| {
|
||||
let week = week_of(dates[at]);
|
||||
dates[..at].iter().rposition(|d| week_of(*d) != week).map_or(0, |i| i + 1)
|
||||
};
|
||||
let target = if down {
|
||||
let week = week_of(date);
|
||||
dates.iter().skip(index).position(|d| week_of(*d) != week).map(|offset| index + offset)
|
||||
} else if top_of_week(index) < index {
|
||||
Some(top_of_week(index))
|
||||
} else {
|
||||
index.checked_sub(1).map(top_of_week)
|
||||
};
|
||||
if let Some(target) = target {
|
||||
self.select(dates[target]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `segment` of `day` is the session in progress.
|
||||
fn is_running(day: Date, segment: &Segment, ctx: &Context) -> bool {
|
||||
matches!(ctx.journal.state(), ShiftState::Working { day: open, since } if open == day && segment.start() == since)
|
||||
}
|
||||
|
||||
fn edit(&self, day: &WorkDay, ctx: &Context) -> Action {
|
||||
let Some(segment) = day.segments().get(self.segment) else {
|
||||
return Action::Notify("This day has no segments (o adds one)".into());
|
||||
};
|
||||
let target = if Days::is_running(day.date(), segment, ctx) {
|
||||
Target::Running { day: day.date(), since: segment.start() }
|
||||
} else {
|
||||
Target::Existing { day: day.date(), index: self.segment, original: *segment }
|
||||
};
|
||||
popup(SegmentEditor::new(target, ctx))
|
||||
}
|
||||
|
||||
fn delete(&self, day: &WorkDay, ctx: &Context) -> Action {
|
||||
match day.segments().get(self.segment) {
|
||||
None => Action::Notify("Nothing to delete here".into()),
|
||||
Some(segment) if Days::is_running(day.date(), segment, ctx) => {
|
||||
Action::Notify("That's the session in progress: pause first (p), or fix it (i)".into())
|
||||
}
|
||||
Some(_) => Action::DeleteSegment { day: day.date(), index: self.segment },
|
||||
}
|
||||
}
|
||||
|
||||
fn render_list(&mut self, frame: &mut Frame, area: Rect, dates: &[Date], ctx: &Context) {
|
||||
let fmt = &ctx.fmt;
|
||||
self.entries = Days::entries(dates, ctx);
|
||||
let items: Vec<ListItem> = self
|
||||
.entries
|
||||
.iter()
|
||||
.map(|entry| match entry {
|
||||
Entry::Week { first, worked } => ListItem::new(Line::from(vec![
|
||||
Span::styled(
|
||||
format!("Week of {:<18}", fmt.date_short(*first)),
|
||||
ctx.theme.muted().add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(format!("{:>9}", fmt.duration(*worked)), ctx.theme.muted()),
|
||||
])),
|
||||
Entry::Day(date) => {
|
||||
let day = ctx.day(*date);
|
||||
let span = match (day.and_then(WorkDay::first_start), day.and_then(WorkDay::last_end)) {
|
||||
(Some(start), Some(end)) => format!("{}-{}", ctx.time(start), ctx.time(end)),
|
||||
_ => String::new(),
|
||||
};
|
||||
let worked = day.map_or(SignedDuration::ZERO, WorkDay::worked);
|
||||
ListItem::new(format!(
|
||||
" {:<11} {:<17} {:>8}",
|
||||
fmt.date_short(*date),
|
||||
span,
|
||||
fmt.duration(worked)
|
||||
))
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let selected_row = self
|
||||
.current(dates)
|
||||
.and_then(|(_, date)| self.entries.iter().position(|entry| *entry == Entry::Day(date)));
|
||||
self.list_state.select(selected_row);
|
||||
|
||||
let focused = self.pane == Pane::Days;
|
||||
let list = List::new(items)
|
||||
.block(
|
||||
Block::bordered()
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(ctx.theme.border(focused))
|
||||
.title(Line::styled(" Work days ", ctx.theme.title())),
|
||||
)
|
||||
.highlight_style(ctx.theme.selected(focused));
|
||||
self.list_area = area;
|
||||
frame.render_stateful_widget(list, area, &mut self.list_state);
|
||||
}
|
||||
|
||||
fn render_details(&mut self, frame: &mut Frame, area: Rect, day: Option<&WorkDay>, ctx: &Context) {
|
||||
let focused = self.pane == Pane::Segments;
|
||||
let title = day.map_or("No work recorded yet".to_string(), |day| ctx.fmt.date_long(day.date()));
|
||||
let block = Block::bordered()
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(ctx.theme.border(focused))
|
||||
.title(Line::styled(format!(" {title} "), ctx.theme.title()))
|
||||
.title_bottom(
|
||||
Line::styled(
|
||||
" i edit · o add · dd delete · yy copy · <Space>c copy as... ",
|
||||
ctx.theme.muted(),
|
||||
)
|
||||
.right_aligned(),
|
||||
);
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
let Some(day) = day else {
|
||||
let hint = " Press s to start your first shift, or o to add work you already did.";
|
||||
frame.render_widget(Paragraph::new(Span::styled(hint, ctx.theme.muted())), inner);
|
||||
return;
|
||||
};
|
||||
|
||||
let [summary, _, timeline_area, _, table_area] = Layout::vertical([
|
||||
Constraint::Length(2),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(timeline::HEIGHT),
|
||||
Constraint::Length(1),
|
||||
Constraint::Fill(1),
|
||||
])
|
||||
.areas(inner);
|
||||
|
||||
let fmt = &ctx.fmt;
|
||||
let date = day.date();
|
||||
let bold = Style::new().add_modifier(Modifier::BOLD);
|
||||
let working_on_it =
|
||||
matches!(ctx.journal.state(), ShiftState::Working { day: open, .. } if open == date);
|
||||
let out = match day.last_end() {
|
||||
_ if working_on_it => "now".to_string(),
|
||||
Some(end) => ctx.time_on(end, date),
|
||||
None => String::new(),
|
||||
};
|
||||
let summary_lines = vec![
|
||||
Line::from(vec![
|
||||
Span::styled(" In ", ctx.theme.muted()),
|
||||
Span::styled(
|
||||
day.first_start().map(|start| ctx.time_on(start, date)).unwrap_or_default(),
|
||||
bold,
|
||||
),
|
||||
Span::styled(" Out ", ctx.theme.muted()),
|
||||
Span::styled(out, bold),
|
||||
Span::styled(" Break ", ctx.theme.muted()),
|
||||
Span::styled(fmt.duration(day.break_time()), bold),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::styled(" Worked ", ctx.theme.muted()),
|
||||
Span::styled(fmt.duration(day.worked()), bold),
|
||||
Span::styled(
|
||||
format!(" {} h", fmt.duration_as(DurationStyle::Decimal, day.worked())),
|
||||
ctx.theme.muted(),
|
||||
),
|
||||
]),
|
||||
];
|
||||
frame.render_widget(Paragraph::new(summary_lines), summary);
|
||||
|
||||
let pieces: Vec<_> = day.segments().iter().map(|segment| (segment.start(), segment.end())).collect();
|
||||
let state = ctx.journal.state();
|
||||
let now = (state.open_day() == Some(date)).then(|| ctx.now.timestamp());
|
||||
let running_since = match state {
|
||||
ShiftState::Working { day: open, since } if open == date => Some(since),
|
||||
_ => None,
|
||||
};
|
||||
let inset =
|
||||
Rect { x: timeline_area.x + 1, width: timeline_area.width.saturating_sub(2), ..timeline_area };
|
||||
frame.render_widget(
|
||||
Timeline {
|
||||
pieces: &pieces,
|
||||
running_since,
|
||||
now,
|
||||
window: window_for(&pieces, now, ctx.clock.zone(), 6),
|
||||
zone: ctx.clock.zone(),
|
||||
fmt,
|
||||
theme: ctx.theme,
|
||||
},
|
||||
inset,
|
||||
);
|
||||
|
||||
let rows: Vec<Row> = day
|
||||
.segments()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, segment)| {
|
||||
let end = if Days::is_running(date, segment, ctx) {
|
||||
Span::styled("now ●", ctx.theme.accent())
|
||||
} else {
|
||||
Span::raw(ctx.time_on(segment.end(), date))
|
||||
};
|
||||
Row::new(vec![
|
||||
Span::raw(format!(" {}", index + 1)),
|
||||
Span::raw(ctx.time_on(segment.start(), date)),
|
||||
end,
|
||||
Span::raw(fmt.duration(segment.duration())),
|
||||
])
|
||||
})
|
||||
.collect();
|
||||
self.segment = self.segment.min(rows.len().saturating_sub(1));
|
||||
self.table_state.select((!rows.is_empty()).then_some(self.segment));
|
||||
let widths =
|
||||
[Constraint::Length(4), Constraint::Length(13), Constraint::Length(13), Constraint::Length(10)];
|
||||
let table = Table::new(rows, widths)
|
||||
.header(Row::new([" #", "Start", "End", "Length"]).style(ctx.theme.muted()))
|
||||
.row_highlight_style(if focused { ctx.theme.selected(true) } else { bold });
|
||||
self.table_area = table_area;
|
||||
frame.render_stateful_widget(table, table_area, &mut self.table_state);
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for Days {
|
||||
fn keys(&self) -> &'static [&'static [Binding]] {
|
||||
&[keymap::DAYS, keymap::LIST]
|
||||
}
|
||||
|
||||
fn render(&mut self, frame: &mut Frame, area: Rect, ctx: &Context) {
|
||||
let dates = Days::dates(ctx);
|
||||
let [list_area, details_area] =
|
||||
Layout::horizontal([Constraint::Length(44), Constraint::Fill(1)]).areas(area);
|
||||
self.render_list(frame, list_area, &dates, ctx);
|
||||
let day = self.current(&dates).and_then(|(_, date)| ctx.day(date));
|
||||
self.render_details(frame, details_area, day, ctx);
|
||||
}
|
||||
|
||||
fn command(&mut self, command: Command, count: Option<usize>, ctx: &Context) -> Option<Action> {
|
||||
let dates = Days::dates(ctx);
|
||||
let steps = count.unwrap_or(1);
|
||||
let half_page = usize::from(self.list_area.height.saturating_sub(2) / 2).max(1);
|
||||
let day = self.current(&dates).and_then(|(_, date)| ctx.day(date));
|
||||
let segments = day.map_or(0, |day| day.segments().len());
|
||||
|
||||
match (command, self.pane) {
|
||||
(Command::Down, Pane::Days) => self.move_day(&dates, |index, _| index + steps),
|
||||
(Command::Up, Pane::Days) => self.move_day(&dates, |index, _| index.saturating_sub(steps)),
|
||||
(Command::HalfPageDown, _) => self.move_day(&dates, |index, _| index + half_page),
|
||||
(Command::HalfPageUp, _) => self.move_day(&dates, |index, _| index.saturating_sub(half_page)),
|
||||
(Command::Top, Pane::Days) => self.move_day(&dates, |_, _| 0),
|
||||
(Command::Bottom, Pane::Days) => self.move_day(&dates, |_, last| last),
|
||||
(Command::Down, Pane::Segments) => {
|
||||
self.segment = (self.segment + steps).min(segments.saturating_sub(1))
|
||||
}
|
||||
(Command::Up, Pane::Segments) => self.segment = self.segment.saturating_sub(steps),
|
||||
(Command::Top, Pane::Segments) => self.segment = 0,
|
||||
(Command::Bottom, Pane::Segments) => self.segment = segments.saturating_sub(1),
|
||||
(Command::NextWeek, _) => self.move_week(&dates, true, ctx),
|
||||
(Command::PreviousWeek, _) => self.move_week(&dates, false, ctx),
|
||||
(Command::Right, _) if segments > 0 => self.pane = Pane::Segments,
|
||||
(Command::Left, _) => self.pane = Pane::Days,
|
||||
(Command::Edit, Pane::Days) if segments > 0 => self.pane = Pane::Segments,
|
||||
(Command::Edit, _) => return day.map(|day| self.edit(day, ctx)),
|
||||
(Command::Delete, Pane::Segments) => return day.map(|day| self.delete(day, ctx)),
|
||||
(Command::Delete, Pane::Days) => {
|
||||
return Some(Action::Notify("Choose a segment first (l), then dd deletes it".into()));
|
||||
}
|
||||
(Command::AddAfter | Command::AddBefore, _) => {
|
||||
let date = day.map_or(ctx.current_day(), WorkDay::date);
|
||||
let after = command == Command::AddAfter;
|
||||
return Some(popup(SegmentEditor::new(Target::New { day: date, after }, ctx)));
|
||||
}
|
||||
(Command::Copy(target, preset), _) => {
|
||||
return day.map(|day| copy_action(target, preset, day.date(), ctx));
|
||||
}
|
||||
(Command::CopyMenu, _) => return day.map(|day| popup(CopyMenu::new(day.date(), ctx))),
|
||||
_ => {}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn mouse(&mut self, event: MouseEvent, ctx: &Context) -> Option<Action> {
|
||||
let position = Position::new(event.column, event.row);
|
||||
match event.kind {
|
||||
MouseEventKind::ScrollDown => return self.command(Command::Down, None, ctx),
|
||||
MouseEventKind::ScrollUp => return self.command(Command::Up, None, ctx),
|
||||
MouseEventKind::Down(MouseButton::Left) => {}
|
||||
_ => return None,
|
||||
}
|
||||
// Rows start below the top border (and below the header row for the
|
||||
// table); both may be scrolled.
|
||||
if self.list_area.contains(position) {
|
||||
let row = usize::from(position.y.saturating_sub(self.list_area.y + 1)) + self.list_state.offset();
|
||||
if let Some(Entry::Day(date)) = self.entries.get(row) {
|
||||
self.select(*date);
|
||||
self.pane = Pane::Days;
|
||||
}
|
||||
} else if self.table_area.contains(position) {
|
||||
self.segment =
|
||||
usize::from(position.y.saturating_sub(self.table_area.y + 1)) + self.table_state.offset();
|
||||
self.pane = Pane::Segments;
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,529 @@
|
||||
//! The Insights screen: four ways to look at your work.
|
||||
//!
|
||||
//! - Overview: this week and month against your targets, your balance,
|
||||
//! weekly totals, and when your days start and end.
|
||||
//! - Calendar: every day as a colored square, like a wall calendar.
|
||||
//! - Rhythm: which hours of which weekdays you actually work.
|
||||
//! - Focus: how long you stay on, and how you take breaks.
|
||||
//!
|
||||
//! These are patterns to notice, not grades: no streaks and no scores.
|
||||
|
||||
use jiff::civil::{Date, Weekday};
|
||||
use jiff::{SignedDuration, ToSpan};
|
||||
use ratatui::Frame;
|
||||
use ratatui::crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
|
||||
use ratatui::layout::{Constraint, Layout, Position, Rect};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::symbols::Marker;
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Axis, Bar, BarChart, Block, BorderType, Chart, Dataset, GraphType, Paragraph};
|
||||
|
||||
use crate::action::Action;
|
||||
use crate::analysis::calendar::calendar;
|
||||
use crate::analysis::focus::{SESSION_BUCKETS, focus};
|
||||
use crate::analysis::overview::{Targets, Totals, overview};
|
||||
use crate::analysis::rhythm::rhythm;
|
||||
use crate::analysis::{Period, days_in};
|
||||
use crate::domain::WorkDay;
|
||||
use crate::ui::keymap::{self, Binding, Command};
|
||||
use crate::ui::widgets::heatmap::Heatmap;
|
||||
use crate::ui::widgets::progress_bar;
|
||||
use crate::ui::{Component, Context};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum View {
|
||||
Overview,
|
||||
Calendar,
|
||||
Rhythm,
|
||||
Focus,
|
||||
}
|
||||
|
||||
impl View {
|
||||
const ALL: [View; 4] = [View::Overview, View::Calendar, View::Rhythm, View::Focus];
|
||||
|
||||
fn title(self) -> &'static str {
|
||||
match self {
|
||||
View::Overview => "Overview",
|
||||
View::Calendar => "Calendar",
|
||||
View::Rhythm => "Rhythm",
|
||||
View::Focus => "Focus",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Insights {
|
||||
view: View,
|
||||
period: Period,
|
||||
/// Where each view's tab was drawn, for the mouse.
|
||||
view_tabs: Vec<(View, Rect)>,
|
||||
}
|
||||
|
||||
impl Insights {
|
||||
pub fn new() -> Insights {
|
||||
Insights { view: View::Overview, period: Period::default(), view_tabs: Vec::new() }
|
||||
}
|
||||
|
||||
fn render_tabs(&mut self, frame: &mut Frame, area: Rect, ctx: &Context) {
|
||||
self.view_tabs.clear();
|
||||
let mut x = area.x + 1;
|
||||
for view in View::ALL {
|
||||
let label = format!(" {} ", view.title());
|
||||
let width = label.chars().count() as u16;
|
||||
let style = if view == self.view { ctx.theme.selected(true) } else { ctx.theme.muted() };
|
||||
let rect = Rect { x, y: area.y, width, height: 1 }.intersection(area);
|
||||
frame.render_widget(Paragraph::new(Span::styled(label, style)), rect);
|
||||
self.view_tabs.push((view, rect));
|
||||
x += width + 1;
|
||||
}
|
||||
let period = Line::from(vec![
|
||||
Span::styled("period ", ctx.theme.muted()),
|
||||
Span::styled(self.period.label(), ctx.theme.title()),
|
||||
Span::styled(" < shorter · > longer · h/l views ", ctx.theme.muted()),
|
||||
]);
|
||||
frame.render_widget(Paragraph::new(period).right_aligned(), area);
|
||||
}
|
||||
|
||||
fn render_overview(&self, frame: &mut Frame, area: Rect, ctx: &Context) {
|
||||
let (fmt, settings, today) = (&ctx.fmt, ctx.settings, ctx.now.date());
|
||||
let range = self.period.range(today, ctx.days);
|
||||
let targets = Targets { daily: settings.daily_target, week_start: settings.week_start };
|
||||
let week_count = usize::from(area.width.saturating_sub(4) / 8).clamp(4, 12);
|
||||
let data = overview(ctx.days, today, range, targets, ctx.clock.zone(), week_count);
|
||||
|
||||
let [cards, weekly, spans] =
|
||||
Layout::vertical([Constraint::Length(4), Constraint::Fill(1), Constraint::Fill(1)]).areas(area);
|
||||
let [this_week, last_week, this_month, balance] =
|
||||
Layout::horizontal([Constraint::Fill(1); 4]).areas(cards);
|
||||
|
||||
let days_line = |totals: Totals| {
|
||||
let average = totals.average_day().map(|average| format!(" · {} a day", fmt.duration(average)));
|
||||
Line::styled(
|
||||
format!("{} days{}", totals.days_worked, average.unwrap_or_default()),
|
||||
ctx.theme.muted(),
|
||||
)
|
||||
};
|
||||
let week_goal = if settings.weekly_target > SignedDuration::ZERO {
|
||||
let ratio = data.this_week.worked.as_secs_f64() / settings.weekly_target.as_secs_f64();
|
||||
let width = this_week.width.saturating_sub(10);
|
||||
let mut spans =
|
||||
progress_bar(width, ratio, Style::new().fg(ctx.theme.working), ctx.theme.muted()).to_vec();
|
||||
spans.push(Span::styled(format!(" {:.0}%", ratio * 100.0), ctx.theme.muted()));
|
||||
Line::from(spans)
|
||||
} else {
|
||||
days_line(data.this_week)
|
||||
};
|
||||
card(frame, this_week, "This week", fmt.duration(data.this_week.worked), week_goal, ctx);
|
||||
card(
|
||||
frame,
|
||||
last_week,
|
||||
"Last week",
|
||||
fmt.duration(data.last_week.worked),
|
||||
days_line(data.last_week),
|
||||
ctx,
|
||||
);
|
||||
card(
|
||||
frame,
|
||||
this_month,
|
||||
"This month",
|
||||
fmt.duration(data.this_month.worked),
|
||||
days_line(data.this_month),
|
||||
ctx,
|
||||
);
|
||||
let (balance_text, balance_note) = if settings.daily_target > SignedDuration::ZERO {
|
||||
let sign = if data.balance.is_negative() { "" } else { "+" };
|
||||
(
|
||||
format!("{sign}{}", fmt.duration(data.balance)),
|
||||
format!("vs {} per day worked", fmt.duration(settings.daily_target)),
|
||||
)
|
||||
} else {
|
||||
("-".to_string(), "set a daily target to see it".to_string())
|
||||
};
|
||||
let balance_title = format!("Balance · {}", self.period.label());
|
||||
card(
|
||||
frame,
|
||||
balance,
|
||||
&balance_title,
|
||||
balance_text,
|
||||
Line::styled(balance_note, ctx.theme.muted()),
|
||||
ctx,
|
||||
);
|
||||
|
||||
// Hours per week.
|
||||
let bars: Vec<Bar> = data
|
||||
.weeks
|
||||
.iter()
|
||||
.map(|(first, worked)| {
|
||||
let reached =
|
||||
settings.weekly_target > SignedDuration::ZERO && *worked >= settings.weekly_target;
|
||||
let color = if reached { ctx.theme.accent } else { ctx.theme.working };
|
||||
Bar::with_label(first.strftime("%b %-d").to_string(), worked.as_mins().max(0) as u64)
|
||||
.text_value(format!("{}h", worked.as_hours()))
|
||||
.style(Style::new().fg(color))
|
||||
})
|
||||
.collect();
|
||||
let bar_width =
|
||||
(weekly.width.saturating_sub(2) / data.weeks.len().max(1) as u16).saturating_sub(1).clamp(3, 8);
|
||||
let title = match settings.weekly_target {
|
||||
target if target > SignedDuration::ZERO => {
|
||||
format!(" Hours per week · target {} ", fmt.duration(target))
|
||||
}
|
||||
_ => " Hours per week ".to_string(),
|
||||
};
|
||||
frame.render_widget(
|
||||
BarChart::new(bars)
|
||||
.block(section(&title, ctx))
|
||||
.bar_width(bar_width)
|
||||
.bar_gap(1)
|
||||
.value_style(Style::new().fg(ratatui::style::Color::Black).bg(ctx.theme.working)),
|
||||
weekly,
|
||||
);
|
||||
|
||||
// When days start and end.
|
||||
if data.spans.is_empty() {
|
||||
frame.render_widget(empty(" When your days start and end ", self.period, ctx), spans);
|
||||
return;
|
||||
}
|
||||
let x_of = |date: Date| date.duration_since(range.first()).as_hours() as f64 / 24.0;
|
||||
let starts: Vec<(f64, f64)> =
|
||||
data.spans.iter().map(|span| (x_of(span.date), span.start_minute as f64 / 60.0)).collect();
|
||||
let ends: Vec<(f64, f64)> =
|
||||
data.spans.iter().map(|span| (x_of(span.date), span.end_minute as f64 / 60.0)).collect();
|
||||
let low = data.spans.iter().map(|span| span.start_minute).min().unwrap_or(0).div_euclid(60);
|
||||
let high = (data.spans.iter().map(|span| span.end_minute).max().unwrap_or(0) + 59)
|
||||
.div_euclid(60)
|
||||
.max(low + 1);
|
||||
let step = ((high - low) / 4).max(1);
|
||||
let y_labels: Vec<String> =
|
||||
(low..=high).step_by(step as usize).map(|hour| hour_label(hour, ctx)).collect();
|
||||
let x_labels = vec![fmt.date_short(range.first()), fmt.date_short(range.last())];
|
||||
let datasets = vec![
|
||||
Dataset::default()
|
||||
.name("start")
|
||||
.marker(Marker::Dot)
|
||||
.graph_type(GraphType::Scatter)
|
||||
.style(Style::new().fg(ctx.theme.working))
|
||||
.data(&starts),
|
||||
Dataset::default()
|
||||
.name("end")
|
||||
.marker(Marker::Dot)
|
||||
.graph_type(GraphType::Scatter)
|
||||
.style(Style::new().fg(ctx.theme.accent))
|
||||
.data(&ends),
|
||||
];
|
||||
let chart = Chart::new(datasets)
|
||||
.block(section(" When your days start and end ", ctx))
|
||||
.x_axis(
|
||||
Axis::default()
|
||||
.bounds([0.0, f64::from(range.day_count() - 1).max(1.0)])
|
||||
.labels(x_labels)
|
||||
.style(ctx.theme.muted()),
|
||||
)
|
||||
.y_axis(
|
||||
Axis::default().bounds([low as f64, high as f64]).labels(y_labels).style(ctx.theme.muted()),
|
||||
);
|
||||
frame.render_widget(chart, spans);
|
||||
}
|
||||
|
||||
fn render_calendar(&self, frame: &mut Frame, area: Rect, ctx: &Context) {
|
||||
let (settings, today) = (ctx.settings, ctx.now.date());
|
||||
let block = section(" Every day, like a wall calendar ", ctx);
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
let weeks = usize::from(inner.width.saturating_sub(6) / 2).clamp(1, 53);
|
||||
let heat = calendar(ctx.days, today, settings.week_start, weeks);
|
||||
let full = if settings.daily_target > SignedDuration::ZERO {
|
||||
settings.daily_target
|
||||
} else {
|
||||
SignedDuration::from_hours(8)
|
||||
};
|
||||
let cells: Vec<Vec<Option<f64>>> = (0..7)
|
||||
.map(|row| {
|
||||
heat.weeks
|
||||
.iter()
|
||||
.map(|week| week[row].map(|(_, worked)| worked.as_secs_f64() / full.as_secs_f64()))
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
let row_labels: Vec<String> =
|
||||
(0..7).map(|offset| weekday_name(settings.week_start.wrapping_add(offset))).collect();
|
||||
// A month's name above the week where it begins.
|
||||
let column_labels: Vec<(usize, String)> = heat
|
||||
.weeks
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(column, week)| {
|
||||
let first = week.iter().flatten().find(|(date, _)| date.day() == 1 || column == 0)?;
|
||||
Some((column, first.0.strftime("%b").to_string()))
|
||||
})
|
||||
.collect();
|
||||
let today_row = today.weekday().since(settings.week_start) as usize;
|
||||
let heatmap = Heatmap {
|
||||
cells: &cells,
|
||||
row_labels: &row_labels,
|
||||
column_labels: &column_labels,
|
||||
cell: "■ ",
|
||||
highlight: Some((today_row, heat.weeks.len().saturating_sub(1))),
|
||||
theme: ctx.theme,
|
||||
};
|
||||
let (_, height) = heatmap.size();
|
||||
let [grid, _, legend] =
|
||||
Layout::vertical([Constraint::Length(height), Constraint::Length(1), Constraint::Length(2)])
|
||||
.areas(inner);
|
||||
frame.render_widget(heatmap, grid);
|
||||
|
||||
let shown: Vec<&(Date, SignedDuration)> = heat.weeks.iter().flatten().flatten().collect();
|
||||
let worked_days = shown.iter().filter(|(_, worked)| *worked > SignedDuration::ZERO).count();
|
||||
let mut scale = vec![Span::styled(" less ", ctx.theme.muted())];
|
||||
for level in ctx.theme.heat {
|
||||
scale.push(Span::styled("■ ", Style::new().fg(level)));
|
||||
}
|
||||
scale.push(Span::styled(
|
||||
format!("more · a full square is {}", ctx.fmt.duration(full)),
|
||||
ctx.theme.muted(),
|
||||
));
|
||||
let summary = format!(" Worked on {worked_days} of the last {} days.", shown.len());
|
||||
frame.render_widget(
|
||||
Paragraph::new(vec![Line::from(scale), Line::styled(summary, ctx.theme.muted())]),
|
||||
legend,
|
||||
);
|
||||
}
|
||||
|
||||
fn render_rhythm(&self, frame: &mut Frame, area: Rect, ctx: &Context) {
|
||||
let today = ctx.now.date();
|
||||
let range = self.period.range(today, ctx.days);
|
||||
let data = rhythm(ctx.days, range, ctx.settings.week_start, ctx.clock.zone());
|
||||
let block = section(" When you work: weekday × hour of the day ", ctx);
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
if data.busiest_cell() == 0 {
|
||||
frame.render_widget(empty("", self.period, ctx), inner);
|
||||
return;
|
||||
}
|
||||
|
||||
let busiest = data.busiest_cell() as f64;
|
||||
let cells: Vec<Vec<Option<f64>>> = data
|
||||
.seconds
|
||||
.iter()
|
||||
.map(|row| row.iter().map(|seconds| Some(*seconds as f64 / busiest)).collect())
|
||||
.collect();
|
||||
let row_labels: Vec<String> = data.weekdays.iter().map(|weekday| weekday_name(*weekday)).collect();
|
||||
let cell = if inner.width >= 4 + 24 * 3 { "██ " } else { "██" };
|
||||
let column_labels: Vec<(usize, String)> =
|
||||
(0..24).step_by(3).map(|hour| (hour, ctx.fmt.hour_mark(hour as i8))).collect();
|
||||
let heatmap = Heatmap {
|
||||
cells: &cells,
|
||||
row_labels: &row_labels,
|
||||
column_labels: &column_labels,
|
||||
cell,
|
||||
highlight: None,
|
||||
theme: ctx.theme,
|
||||
};
|
||||
let (_, height) = heatmap.size();
|
||||
let [grid, _, notes] =
|
||||
Layout::vertical([Constraint::Length(height), Constraint::Length(1), Constraint::Fill(1)])
|
||||
.areas(inner);
|
||||
frame.render_widget(heatmap, grid);
|
||||
|
||||
let peaks: Vec<String> = data
|
||||
.peak_hours(3)
|
||||
.into_iter()
|
||||
.map(|hour| format!("{}-{}", ctx.fmt.hour_mark(hour), ctx.fmt.hour_mark((hour + 1) % 24)))
|
||||
.collect();
|
||||
let total: SignedDuration = days_in(ctx.days, range).map(WorkDay::worked).sum();
|
||||
let per_week = SignedDuration::from_secs((total.as_secs() as f64 / data.weeks.max(1.0)) as i64);
|
||||
let lines = vec![
|
||||
Line::from(vec![
|
||||
Span::styled(" Busiest hours: ", ctx.theme.muted()),
|
||||
Span::styled(peaks.join(", "), ctx.theme.title()),
|
||||
]),
|
||||
Line::styled(
|
||||
format!(
|
||||
" Over {}: {} worked, about {} a week.",
|
||||
self.period.label(),
|
||||
ctx.fmt.duration(total),
|
||||
ctx.fmt.duration(per_week)
|
||||
),
|
||||
ctx.theme.muted(),
|
||||
),
|
||||
];
|
||||
frame.render_widget(Paragraph::new(lines), notes);
|
||||
}
|
||||
|
||||
fn render_focus(&self, frame: &mut Frame, area: Rect, ctx: &Context) {
|
||||
let (fmt, settings, today) = (&ctx.fmt, ctx.settings, ctx.now.date());
|
||||
let range = self.period.range(today, ctx.days);
|
||||
let data = focus(ctx.days, range, ctx.clock.zone(), settings.long_session);
|
||||
if data.sessions == 0 {
|
||||
frame.render_widget(empty(" Focus ", self.period, ctx), area);
|
||||
return;
|
||||
}
|
||||
|
||||
let wide = area.width >= 100;
|
||||
let [chart_area, stats_area] = if wide {
|
||||
Layout::horizontal([Constraint::Fill(1), Constraint::Length(52)]).areas(area)
|
||||
} else {
|
||||
Layout::vertical([Constraint::Fill(1), Constraint::Length(10)]).areas(area)
|
||||
};
|
||||
|
||||
let bars: Vec<Bar> = SESSION_BUCKETS
|
||||
.iter()
|
||||
.zip(data.session_counts)
|
||||
.map(|((label, _), count)| {
|
||||
Bar::with_label(*label, count as u64)
|
||||
.text_value(count.to_string())
|
||||
.style(Style::new().fg(ctx.theme.working))
|
||||
})
|
||||
.collect();
|
||||
let bar_width = (chart_area.width.saturating_sub(2) / SESSION_BUCKETS.len() as u16)
|
||||
.saturating_sub(2)
|
||||
.clamp(3, 12);
|
||||
frame.render_widget(
|
||||
BarChart::new(bars)
|
||||
.block(section(" How long your sessions last ", ctx))
|
||||
.bar_width(bar_width)
|
||||
.bar_gap(2)
|
||||
.value_style(Style::new().fg(ratatui::style::Color::Black).bg(ctx.theme.working)),
|
||||
chart_area,
|
||||
);
|
||||
|
||||
let row = |label: &str, value: String| {
|
||||
Line::from(vec![
|
||||
Span::styled(format!(" {label:<17}"), ctx.theme.muted()),
|
||||
Span::styled(value, Style::new()),
|
||||
])
|
||||
};
|
||||
let typical_bucket = data
|
||||
.session_counts
|
||||
.iter()
|
||||
.enumerate()
|
||||
.max_by_key(|(_, count)| **count)
|
||||
.map(|(index, _)| SESSION_BUCKETS[index].0)
|
||||
.unwrap_or("");
|
||||
let mut lines = vec![
|
||||
row("Sessions", format!("{} · {:.1} a day", data.sessions, data.sessions_per_day())),
|
||||
row(
|
||||
"Typical session",
|
||||
data.median_session
|
||||
.map(|median| format!("{} (half are shorter)", fmt.duration(median)))
|
||||
.unwrap_or_default(),
|
||||
),
|
||||
row(
|
||||
"Longest session",
|
||||
data.longest_session
|
||||
.map(|(date, length)| format!("{} on {}", fmt.duration(length), fmt.date_short(date)))
|
||||
.unwrap_or_default(),
|
||||
),
|
||||
row(
|
||||
"Long sessions",
|
||||
format!("{} of {} or more", data.long_sessions, fmt.duration(settings.long_session)),
|
||||
),
|
||||
row(
|
||||
"Breaks",
|
||||
match data.median_break {
|
||||
Some(median) => format!("{} · typically {}", data.breaks, fmt.duration(median)),
|
||||
None => "none between sessions".to_string(),
|
||||
},
|
||||
),
|
||||
row(
|
||||
"Past midnight",
|
||||
format!("{} work day{}", data.past_midnight, if data.past_midnight == 1 { "" } else { "s" }),
|
||||
),
|
||||
Line::default(),
|
||||
];
|
||||
lines.push(Line::styled(
|
||||
format!(" Your sessions most often last {typical_bucket}."),
|
||||
ctx.theme.title(),
|
||||
));
|
||||
frame.render_widget(
|
||||
Paragraph::new(lines).block(section(&format!(" {} ", self.period.label()), ctx)),
|
||||
stats_area,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for Insights {
|
||||
fn keys(&self) -> &'static [&'static [Binding]] {
|
||||
&[keymap::INSIGHTS]
|
||||
}
|
||||
|
||||
fn render(&mut self, frame: &mut Frame, area: Rect, ctx: &Context) {
|
||||
let block = Block::bordered()
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(ctx.theme.border(false))
|
||||
.title(Line::styled(" Insights ", ctx.theme.title()));
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
let [tabs, _, body] =
|
||||
Layout::vertical([Constraint::Length(1), Constraint::Length(1), Constraint::Fill(1)])
|
||||
.areas(inner);
|
||||
self.render_tabs(frame, tabs, ctx);
|
||||
match self.view {
|
||||
View::Overview => self.render_overview(frame, body, ctx),
|
||||
View::Calendar => self.render_calendar(frame, body, ctx),
|
||||
View::Rhythm => self.render_rhythm(frame, body, ctx),
|
||||
View::Focus => self.render_focus(frame, body, ctx),
|
||||
}
|
||||
}
|
||||
|
||||
fn command(&mut self, command: Command, _count: Option<usize>, _ctx: &Context) -> Option<Action> {
|
||||
let index = View::ALL.iter().position(|view| *view == self.view).unwrap_or(0);
|
||||
match command {
|
||||
Command::Right => self.view = View::ALL[(index + 1) % View::ALL.len()],
|
||||
Command::Left => self.view = View::ALL[(index + View::ALL.len() - 1) % View::ALL.len()],
|
||||
Command::LongerPeriod => self.period = self.period.longer(),
|
||||
Command::ShorterPeriod => self.period = self.period.shorter(),
|
||||
_ => {}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn mouse(&mut self, event: MouseEvent, _ctx: &Context) -> Option<Action> {
|
||||
if event.kind == MouseEventKind::Down(MouseButton::Left) {
|
||||
let position = Position::new(event.column, event.row);
|
||||
if let Some((view, _)) = self.view_tabs.iter().find(|(_, rect)| rect.contains(position)) {
|
||||
self.view = *view;
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// A small box with a title, a big value and one line below it.
|
||||
fn card(frame: &mut Frame, area: Rect, title: &str, value: String, detail: Line<'static>, ctx: &Context) {
|
||||
let block = section(&format!(" {title} "), ctx);
|
||||
let value = Line::styled(format!(" {value}"), Style::new().add_modifier(Modifier::BOLD));
|
||||
let detail = Line::from([vec![Span::raw(" ")], detail.spans].concat());
|
||||
frame.render_widget(Paragraph::new(vec![value, detail]).block(block), area);
|
||||
}
|
||||
|
||||
/// The frame of a section: a rounded, muted border with a title.
|
||||
fn section<'a>(title: &str, ctx: &Context) -> Block<'a> {
|
||||
Block::bordered()
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(ctx.theme.muted())
|
||||
.title(Line::styled(title.to_string(), Style::new().add_modifier(Modifier::BOLD)))
|
||||
}
|
||||
|
||||
/// Shown when there's nothing to analyze yet.
|
||||
fn empty<'a>(title: &str, period: Period, ctx: &Context) -> Paragraph<'a> {
|
||||
let text =
|
||||
format!(" Nothing recorded over {} yet: this fills up as you track your work.", period.label());
|
||||
let paragraph = Paragraph::new(Span::styled(text, ctx.theme.muted()));
|
||||
if title.is_empty() { paragraph } else { paragraph.block(section(title, ctx)) }
|
||||
}
|
||||
|
||||
/// An hour on the chart's axis: hours past 24 are the next morning ("02+1").
|
||||
fn hour_label(hour: i64, ctx: &Context) -> String {
|
||||
let mark = ctx.fmt.hour_mark(hour.rem_euclid(24) as i8);
|
||||
match hour.div_euclid(24) {
|
||||
0 => mark,
|
||||
days => format!("{mark}+{days}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn weekday_name(weekday: Weekday) -> String {
|
||||
// Any date with that weekday gives its short name.
|
||||
let monday = jiff::civil::date(2024, 1, 1);
|
||||
monday.saturating_add((weekday.to_monday_zero_offset() as i64).days()).strftime("%a").to_string()
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
//! The four screens, one per tab.
|
||||
|
||||
pub mod days;
|
||||
pub mod insights;
|
||||
pub mod settings;
|
||||
pub mod tracker;
|
||||
|
||||
use jiff::civil::Date;
|
||||
|
||||
use super::Context;
|
||||
use super::keymap::CopyTarget;
|
||||
use crate::action::Action;
|
||||
use crate::export::timesheet::CopyPreset;
|
||||
|
||||
/// Copying from a screen: the day in focus, or its whole week. Without a
|
||||
/// preset, the one chosen in the settings is used.
|
||||
pub fn copy_action(target: CopyTarget, preset: Option<CopyPreset>, day: Date, ctx: &Context) -> Action {
|
||||
let days = match target {
|
||||
CopyTarget::Day => vec![day],
|
||||
CopyTarget::Week => ctx.week_days(day),
|
||||
};
|
||||
Action::Copy { days, preset: preset.unwrap_or(ctx.settings.copy_preset) }
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
//! The Settings screen, built from the settings table (`crate::settings::FIELDS`).
|
||||
//! Choices and on/off settings change with h/l; the others are typed (i).
|
||||
//! Expert settings are folded away until you open them with `za`.
|
||||
|
||||
use ratatui::Frame;
|
||||
use ratatui::crossterm::event::{KeyEvent, MouseButton, MouseEvent, MouseEventKind};
|
||||
use ratatui::layout::{Constraint, Layout, Position, Rect};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, BorderType, List, ListItem, ListState, Paragraph, Wrap};
|
||||
|
||||
use crate::action::Action;
|
||||
use crate::settings::{FIELDS, Field, Kind, SECTIONS};
|
||||
use crate::ui::keymap::{self, Binding, Command};
|
||||
use crate::ui::widgets::text_input::{InputEvent, TextInput};
|
||||
use crate::ui::{Component, Context};
|
||||
|
||||
/// A row of the list: a section heading, or a setting (by its index in FIELDS).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Entry {
|
||||
Section(&'static str),
|
||||
Setting(usize),
|
||||
}
|
||||
|
||||
pub struct SettingsScreen {
|
||||
/// Index in FIELDS of the selected setting.
|
||||
selected: usize,
|
||||
show_expert: bool,
|
||||
/// The value being typed, while editing (vim insert mode).
|
||||
editing: Option<TextInput>,
|
||||
error: Option<String>,
|
||||
list_state: ListState,
|
||||
entries: Vec<Entry>,
|
||||
list_area: Rect,
|
||||
}
|
||||
|
||||
impl SettingsScreen {
|
||||
pub fn new() -> SettingsScreen {
|
||||
SettingsScreen {
|
||||
selected: 0,
|
||||
show_expert: false,
|
||||
editing: None,
|
||||
error: None,
|
||||
list_state: ListState::default(),
|
||||
entries: Vec::new(),
|
||||
list_area: Rect::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The settings shown, as indexes in FIELDS, in section order.
|
||||
fn visible(&self) -> Vec<usize> {
|
||||
SECTIONS
|
||||
.iter()
|
||||
.flat_map(|(section, _)| {
|
||||
FIELDS
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(move |(_, field)| {
|
||||
field.section == *section && (self.show_expert || !field.expert)
|
||||
})
|
||||
.map(|(index, _)| index)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn entries(&self) -> Vec<Entry> {
|
||||
let mut entries = Vec::new();
|
||||
for index in self.visible() {
|
||||
let section = FIELDS[index].section;
|
||||
let description =
|
||||
SECTIONS.iter().find(|(name, _)| *name == section).map_or(section, |(_, text)| *text);
|
||||
if !entries.contains(&Entry::Section(description)) {
|
||||
entries.push(Entry::Section(description));
|
||||
}
|
||||
entries.push(Entry::Setting(index));
|
||||
}
|
||||
entries
|
||||
}
|
||||
|
||||
fn field(&self) -> &'static Field {
|
||||
&FIELDS[self.selected]
|
||||
}
|
||||
|
||||
/// Moves the selection by `steps` settings (negative is up).
|
||||
fn move_by(&mut self, steps: isize) {
|
||||
let visible = self.visible();
|
||||
let position = visible.iter().position(|index| *index == self.selected).unwrap_or(0);
|
||||
let target = position.saturating_add_signed(steps).min(visible.len().saturating_sub(1));
|
||||
self.selected = visible[target];
|
||||
self.error = None;
|
||||
}
|
||||
|
||||
/// The next (or previous) value of a choice or on/off setting.
|
||||
fn cycle(&self, forward: bool, ctx: &Context) -> Option<Action> {
|
||||
let field = self.field();
|
||||
let current = (field.get)(ctx.settings);
|
||||
let value = match &field.kind {
|
||||
Kind::Toggle => (current != "true").to_string(),
|
||||
Kind::Choice(names) => {
|
||||
let names = names();
|
||||
let index = names.iter().position(|name| *name == current).unwrap_or(0);
|
||||
let next =
|
||||
if forward { (index + 1) % names.len() } else { (index + names.len() - 1) % names.len() };
|
||||
names[next].to_string()
|
||||
}
|
||||
_ => return Some(Action::Notify("Press i to type a new value".into())),
|
||||
};
|
||||
Some(Action::SetSetting { key: field.key, value })
|
||||
}
|
||||
|
||||
/// Starts typing a new value (`keep` keeps the current one to edit).
|
||||
fn start_editing(&mut self, keep: bool, ctx: &Context) -> Option<Action> {
|
||||
match self.field().kind {
|
||||
Kind::Toggle | Kind::Choice(_) => self.cycle(true, ctx),
|
||||
_ => {
|
||||
let current = if keep { (self.field().get)(ctx.settings) } else { String::new() };
|
||||
self.editing = Some(TextInput::new(current));
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_list(&mut self, frame: &mut Frame, area: Rect, ctx: &Context) {
|
||||
self.entries = self.entries();
|
||||
let items: Vec<ListItem> = self
|
||||
.entries
|
||||
.iter()
|
||||
.map(|entry| match entry {
|
||||
Entry::Section(description) => {
|
||||
ListItem::new(Line::styled(format!(" {description}"), ctx.theme.title()))
|
||||
}
|
||||
Entry::Setting(index) => {
|
||||
let field = &FIELDS[*index];
|
||||
let label = format!(" {:<28}", field.label);
|
||||
let value = match &self.editing {
|
||||
Some(input) if *index == self.selected => input.line(true, Style::new()),
|
||||
_ => Line::styled(display_value(field, ctx), ctx.theme.accent()),
|
||||
};
|
||||
let mut spans = vec![Span::raw(label)];
|
||||
spans.extend(value.spans);
|
||||
if field.expert {
|
||||
spans.push(Span::styled(" expert", ctx.theme.muted()));
|
||||
}
|
||||
ListItem::new(Line::from(spans))
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let selected_row = self.entries.iter().position(|entry| *entry == Entry::Setting(self.selected));
|
||||
self.list_state.select(selected_row);
|
||||
|
||||
let fold = if self.show_expert { "za hide expert settings" } else { "za show expert settings" };
|
||||
let list = List::new(items)
|
||||
.block(
|
||||
Block::bordered()
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(ctx.theme.border(true))
|
||||
.title(Line::styled(" Settings ", ctx.theme.title()))
|
||||
.title_bottom(
|
||||
Line::styled(
|
||||
format!(" h/l change · i type · x default · {fold} "),
|
||||
ctx.theme.muted(),
|
||||
)
|
||||
.right_aligned(),
|
||||
),
|
||||
)
|
||||
.highlight_style(if self.editing.is_some() {
|
||||
Style::new().add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
ctx.theme.selected(true)
|
||||
});
|
||||
self.list_area = area;
|
||||
frame.render_stateful_widget(list, area, &mut self.list_state);
|
||||
}
|
||||
|
||||
fn render_help(&self, frame: &mut Frame, area: Rect, ctx: &Context) {
|
||||
let field = self.field();
|
||||
let mut lines = vec![Line::from(field.help)];
|
||||
if let Some(error) = &self.error {
|
||||
lines.push(Line::styled(error.clone(), Style::new().fg(ctx.theme.error)));
|
||||
} else if self.editing.is_some() {
|
||||
lines.push(Line::styled("Enter to save · Esc to cancel", ctx.theme.muted()));
|
||||
}
|
||||
let block = Block::bordered()
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(ctx.theme.muted())
|
||||
.title(Line::styled(format!(" {} ", field.label), Style::new().add_modifier(Modifier::BOLD)))
|
||||
.title_bottom(
|
||||
Line::styled(format!(" [{}] {} ", field.section, field.key), ctx.theme.muted())
|
||||
.right_aligned(),
|
||||
);
|
||||
frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: true }).block(block), area);
|
||||
}
|
||||
}
|
||||
|
||||
/// How a value is shown: empty paths say what empty means.
|
||||
/// How a value is shown: on/off instead of true/false, and empty paths say
|
||||
/// what empty means.
|
||||
fn display_value(field: &Field, ctx: &Context) -> String {
|
||||
let value = (field.get)(ctx.settings);
|
||||
match (field.key, value.as_str()) {
|
||||
(_, "true") if matches!(field.kind, Kind::Toggle) => "on".to_string(),
|
||||
(_, "false") if matches!(field.kind, Kind::Toggle) => "off".to_string(),
|
||||
("folder", "") => format!("(Documents) {}", ctx.export_folder.display()),
|
||||
("data_file", "") => "(default place)".to_string(),
|
||||
("clipboard_command", "") => "(none)".to_string(),
|
||||
_ => value,
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for SettingsScreen {
|
||||
fn keys(&self) -> &'static [&'static [Binding]] {
|
||||
&[keymap::SETTINGS, keymap::LIST]
|
||||
}
|
||||
|
||||
fn render(&mut self, frame: &mut Frame, area: Rect, ctx: &Context) {
|
||||
let [list, help] = Layout::vertical([Constraint::Fill(1), Constraint::Length(5)]).areas(area);
|
||||
self.render_list(frame, list, ctx);
|
||||
self.render_help(frame, help, ctx);
|
||||
}
|
||||
|
||||
fn command(&mut self, command: Command, count: Option<usize>, ctx: &Context) -> Option<Action> {
|
||||
let steps = count.unwrap_or(1) as isize;
|
||||
let half_page = (self.list_area.height / 2).max(1) as isize;
|
||||
match command {
|
||||
Command::Down => self.move_by(steps),
|
||||
Command::Up => self.move_by(-steps),
|
||||
Command::HalfPageDown => self.move_by(half_page),
|
||||
Command::HalfPageUp => self.move_by(-half_page),
|
||||
Command::Top => self.move_by(isize::MIN / 2),
|
||||
Command::Bottom => self.move_by(isize::MAX / 2),
|
||||
Command::Left => return self.cycle(false, ctx),
|
||||
Command::Right => return self.cycle(true, ctx),
|
||||
Command::Edit | Command::Confirm => return self.start_editing(true, ctx),
|
||||
Command::Retype => return self.start_editing(false, ctx),
|
||||
Command::ResetSetting => return Some(Action::ResetSetting { key: self.field().key }),
|
||||
Command::ToggleExpert => {
|
||||
self.show_expert = !self.show_expert;
|
||||
// If the selected setting was just hidden, select a visible one.
|
||||
self.move_by(0);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn mouse(&mut self, event: MouseEvent, ctx: &Context) -> Option<Action> {
|
||||
let position = Position::new(event.column, event.row);
|
||||
match event.kind {
|
||||
MouseEventKind::ScrollDown => self.move_by(1),
|
||||
MouseEventKind::ScrollUp => self.move_by(-1),
|
||||
MouseEventKind::Down(MouseButton::Left) if self.list_area.contains(position) => {
|
||||
let row =
|
||||
usize::from(position.y.saturating_sub(self.list_area.y + 1)) + self.list_state.offset();
|
||||
if let Some(Entry::Setting(index)) = self.entries.get(row) {
|
||||
if *index == self.selected {
|
||||
return self.cycle(true, ctx);
|
||||
}
|
||||
self.selected = *index;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn inserting(&self) -> bool {
|
||||
self.editing.is_some()
|
||||
}
|
||||
|
||||
fn insert_key(&mut self, key: KeyEvent, ctx: &Context) -> Option<Action> {
|
||||
let input = self.editing.as_mut()?;
|
||||
match input.insert_key(key) {
|
||||
InputEvent::Submit => {
|
||||
let value = input.text().to_string();
|
||||
// Check it now, to keep the field open while it's wrong.
|
||||
let mut trial = ctx.settings.clone();
|
||||
match (self.field().set)(&mut trial, &value) {
|
||||
Ok(()) => {
|
||||
self.editing = None;
|
||||
self.error = None;
|
||||
Some(Action::SetSetting { key: self.field().key, value })
|
||||
}
|
||||
Err(error) => {
|
||||
self.error = Some(error);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
InputEvent::Exit => {
|
||||
self.editing = None;
|
||||
self.error = None;
|
||||
None
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
//! The Tracker screen: what's happening right now, big and clear.
|
||||
//!
|
||||
//! ```text
|
||||
//! ● Working since 21:02 · work day Thu Sep 24
|
||||
//!
|
||||
//! █ ███ ███ ███ ███ ███
|
||||
//! ... (the running timer)
|
||||
//!
|
||||
//! Focus 18m / 25m ●●○○
|
||||
//!
|
||||
//! ╭───────────╮ ╭───────────╮ ╭─────────────╮
|
||||
//! │ ▶ Start s │ │ ‖ Pause p │ │ ■ End day e │
|
||||
//! ╰───────────╯ ╰───────────╯ ╰─────────────╯
|
||||
//!
|
||||
//! Today 5h 40m ██████████████░░░░░░ 71% of 8h
|
||||
//! In 22:00 · 1 break (30m) · 2 sessions
|
||||
//! 22 00 02 04
|
||||
//! ██████████████░░██████▌
|
||||
//!
|
||||
//! This week 32h 10m ████████████████░░░░ 80% of 40h
|
||||
//! ```
|
||||
|
||||
use jiff::SignedDuration;
|
||||
use ratatui::Frame;
|
||||
use ratatui::crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
|
||||
use ratatui::layout::{Constraint, Flex, Layout, Position, Rect};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, BorderType, Paragraph};
|
||||
|
||||
use super::copy_action;
|
||||
use crate::action::Action;
|
||||
use crate::domain::{ShiftState, WorkDay};
|
||||
use crate::reminders::Phase;
|
||||
use crate::time::DateRange;
|
||||
use crate::ui::keymap::{self, Binding, Command};
|
||||
use crate::ui::popups::copy_menu::CopyMenu;
|
||||
use crate::ui::popups::segment_editor::{SegmentEditor, Target};
|
||||
use crate::ui::widgets::big_digits::{self, BigDigits};
|
||||
use crate::ui::widgets::button::{self, Button, Look};
|
||||
use crate::ui::widgets::timeline::{self, Timeline, window_for};
|
||||
use crate::ui::widgets::{centered, progress_bar};
|
||||
use crate::ui::{Component, Context, popup};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Tracker {
|
||||
/// Where each clickable button was drawn, for the mouse.
|
||||
buttons: Vec<(Rect, ButtonAction)>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum ButtonAction {
|
||||
Start,
|
||||
Pause,
|
||||
EndDay,
|
||||
}
|
||||
|
||||
impl Tracker {
|
||||
pub fn new() -> Tracker {
|
||||
Tracker::default()
|
||||
}
|
||||
|
||||
/// `i`/Enter: fix the session in progress, or else today's last segment.
|
||||
fn edit(&self, ctx: &Context) -> Action {
|
||||
if let ShiftState::Working { day, since } = ctx.journal.state() {
|
||||
return popup(SegmentEditor::new(Target::Running { day, since }, ctx));
|
||||
}
|
||||
let day = ctx.current_day();
|
||||
let last = ctx.journal.day(day).and_then(|work_day| {
|
||||
let index = work_day.segments().len().checked_sub(1)?;
|
||||
Some((index, work_day.segments()[index]))
|
||||
});
|
||||
match last {
|
||||
Some((index, original)) => {
|
||||
popup(SegmentEditor::new(Target::Existing { day, index, original }, ctx))
|
||||
}
|
||||
None => Action::Notify("Nothing recorded today yet (o adds work you forgot to track)".into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_status(&self, frame: &mut Frame, area: Rect, ctx: &Context) {
|
||||
let state = ctx.journal.state();
|
||||
let (symbol, text) = match state {
|
||||
ShiftState::Working { day, since } => {
|
||||
("●", format!("Working since {} · work day {}", ctx.time(since), ctx.fmt.date_short(day)))
|
||||
}
|
||||
ShiftState::OnBreak { day } => {
|
||||
("◐", format!("On a break · work day {}", ctx.fmt.date_short(day)))
|
||||
}
|
||||
ShiftState::Idle => ("○", "Not working · press s to start".to_string()),
|
||||
};
|
||||
let color = ctx.theme.state(state);
|
||||
let line = Line::from(vec![
|
||||
Span::styled(format!("{symbol} "), Style::new().fg(color)),
|
||||
Span::styled(text, Style::new().add_modifier(Modifier::BOLD)),
|
||||
]);
|
||||
frame.render_widget(Paragraph::new(line).centered(), area);
|
||||
}
|
||||
|
||||
/// The big number: the running session, the break, or the time of day.
|
||||
fn render_timer(&self, frame: &mut Frame, area: Rect, big: bool, ctx: &Context) {
|
||||
let now = ctx.now.timestamp();
|
||||
let show_seconds = ctx.settings.show_seconds;
|
||||
let state = ctx.journal.state();
|
||||
let (text, color) = match state {
|
||||
ShiftState::Working { .. } => {
|
||||
(ctx.fmt.stopwatch(ctx.journal.running_for(now), show_seconds), ctx.theme.working)
|
||||
}
|
||||
ShiftState::OnBreak { .. } => {
|
||||
let on_break = ctx.journal.on_break_for(now).unwrap_or_default();
|
||||
(ctx.fmt.stopwatch(on_break, show_seconds), ctx.theme.on_break)
|
||||
}
|
||||
ShiftState::Idle => {
|
||||
let clock = if show_seconds {
|
||||
ctx.fmt.time_with_seconds(ctx.now.time())
|
||||
} else {
|
||||
ctx.fmt.time(ctx.now.time())
|
||||
};
|
||||
(clock, ctx.theme.idle)
|
||||
}
|
||||
};
|
||||
let style = Style::new().fg(color).add_modifier(Modifier::BOLD);
|
||||
// Big digits only know digits and colons; "10:05 PM" keeps its "PM"
|
||||
// as small text below.
|
||||
let (digits, suffix) = match text.split_once(' ') {
|
||||
Some((digits, suffix)) => (digits.to_string(), suffix.to_string()),
|
||||
None => (text, String::new()),
|
||||
};
|
||||
if big {
|
||||
let big_digits = BigDigits::new(&digits, style);
|
||||
let width = big_digits.width();
|
||||
frame.render_widget(big_digits, area);
|
||||
if !suffix.is_empty() {
|
||||
// "PM" in small letters after the last digit, on the bottom row.
|
||||
let x = area.x + area.width.saturating_sub(width) / 2 + width + 1;
|
||||
let label =
|
||||
Rect { x, y: area.bottom().saturating_sub(1), width: 2, height: 1 }.intersection(area);
|
||||
frame.render_widget(Paragraph::new(Span::styled(suffix, style)), label);
|
||||
}
|
||||
} else {
|
||||
let text = format!("{digits} {suffix}");
|
||||
frame.render_widget(
|
||||
Paragraph::new(Span::styled(text.trim_end().to_string(), style)).centered(),
|
||||
area,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The pomodoro line: how far into focus or break you are, or a nudge.
|
||||
fn render_rhythm(&self, frame: &mut Frame, area: Rect, ctx: &Context) {
|
||||
let pomodoro = &ctx.pomodoro;
|
||||
let fmt = &ctx.fmt;
|
||||
let dots: String = (0..pomodoro.long_break_every)
|
||||
.map(|index| if index < pomodoro.cycle_position() { '●' } else { '○' })
|
||||
.collect();
|
||||
let snoozed = ctx.snoozed_until.filter(|until| *until > ctx.now.timestamp());
|
||||
let settings = ctx.settings;
|
||||
|
||||
let line = match pomodoro.phase {
|
||||
Phase::Off => Line::default(),
|
||||
Phase::Focus { focused, goal, .. } if focused < goal => Line::from(vec![
|
||||
Span::styled("Focus ", ctx.theme.muted()),
|
||||
Span::raw(format!("{} / {} ", fmt.duration(focused), fmt.duration(goal))),
|
||||
Span::styled(dots, ctx.theme.accent()),
|
||||
]),
|
||||
Phase::Focus { focused, .. } => match snoozed {
|
||||
Some(until) => Line::styled(
|
||||
format!("Break suggestion snoozed until {}", ctx.time(until)),
|
||||
ctx.theme.muted(),
|
||||
),
|
||||
None => {
|
||||
let every = pomodoro.long_break_every.max(1);
|
||||
let long = pomodoro.completed.is_multiple_of(every);
|
||||
let length = if long { settings.long_break } else { settings.short_break };
|
||||
Line::from(vec![
|
||||
Span::styled(
|
||||
format!(
|
||||
"Break time: {} of focus. A {} break would fit.",
|
||||
fmt.duration(focused),
|
||||
fmt.duration(length)
|
||||
),
|
||||
Style::new().fg(ctx.theme.warning).add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(" p pause · zz snooze", ctx.theme.muted()),
|
||||
])
|
||||
}
|
||||
},
|
||||
Phase::Break { taken, goal, long, .. } if taken < goal => Line::from(vec![
|
||||
Span::styled(if long { "Long break " } else { "Break " }, ctx.theme.muted()),
|
||||
Span::raw(format!("{} / {} ", fmt.duration(taken), fmt.duration(goal))),
|
||||
Span::styled(dots, ctx.theme.accent()),
|
||||
]),
|
||||
Phase::Break { goal, .. } => Line::from(vec![
|
||||
Span::styled(
|
||||
format!("Break's over ({}). Back when you're ready.", fmt.duration(goal)),
|
||||
ctx.theme.title(),
|
||||
),
|
||||
Span::styled(" s start", ctx.theme.muted()),
|
||||
]),
|
||||
};
|
||||
frame.render_widget(Paragraph::new(line).centered(), area);
|
||||
}
|
||||
|
||||
fn render_buttons(&mut self, frame: &mut Frame, area: Rect, ctx: &Context) {
|
||||
let state = ctx.journal.state();
|
||||
let working = state.is_working();
|
||||
let start_label =
|
||||
if matches!(state, ShiftState::OnBreak { .. }) { "▶ Resume" } else { "▶ Start" };
|
||||
let buttons = [
|
||||
(
|
||||
Button::new(
|
||||
start_label,
|
||||
"s",
|
||||
if working { Look::Disabled } else { Look::Primary },
|
||||
ctx.theme,
|
||||
),
|
||||
ButtonAction::Start,
|
||||
),
|
||||
(
|
||||
Button::new("‖ Pause", "p", if working { Look::Primary } else { Look::Disabled }, ctx.theme),
|
||||
ButtonAction::Pause,
|
||||
),
|
||||
(
|
||||
Button::new(
|
||||
"■ End day",
|
||||
"e",
|
||||
if state.open_day().is_some() { Look::Normal } else { Look::Disabled },
|
||||
ctx.theme,
|
||||
),
|
||||
ButtonAction::EndDay,
|
||||
),
|
||||
];
|
||||
let gap = 2;
|
||||
let total: u16 = buttons.iter().map(|(button, _)| button.width() + gap).sum::<u16>() - gap;
|
||||
let mut x = area.x + area.width.saturating_sub(total) / 2;
|
||||
|
||||
self.buttons.clear();
|
||||
for (button, action) in buttons {
|
||||
let rect =
|
||||
Rect { x, y: area.y, width: button.width(), height: button::HEIGHT }.intersection(area);
|
||||
x += button.width() + gap;
|
||||
if button.look() != Look::Disabled {
|
||||
self.buttons.push((rect, action));
|
||||
}
|
||||
frame.render_widget(button, rect);
|
||||
}
|
||||
}
|
||||
|
||||
fn render_today(&self, frame: &mut Frame, areas: [Rect; 3], ctx: &Context) {
|
||||
let [progress, details, timeline_area] = areas;
|
||||
let fmt = &ctx.fmt;
|
||||
let day = ctx.day(ctx.current_day());
|
||||
let worked = day.map_or(SignedDuration::ZERO, WorkDay::worked);
|
||||
frame.render_widget(
|
||||
Paragraph::new(goal_line("Today", worked, ctx.settings.daily_target, progress.width, ctx)),
|
||||
progress,
|
||||
);
|
||||
|
||||
let details_text = match day.filter(|day| !day.is_empty()) {
|
||||
None => "Nothing recorded yet today.".to_string(),
|
||||
Some(day) => {
|
||||
let sessions = day.segments().len();
|
||||
let breaks = match day.breaks().count() {
|
||||
0 => "no breaks yet".to_string(),
|
||||
1 => format!("1 break ({})", fmt.duration(day.break_time())),
|
||||
count => format!("{count} breaks ({})", fmt.duration(day.break_time())),
|
||||
};
|
||||
format!(
|
||||
"In {} · {breaks} · {sessions} session{}",
|
||||
day.first_start().map(|start| ctx.time(start)).unwrap_or_default(),
|
||||
if sessions == 1 { "" } else { "s" },
|
||||
)
|
||||
}
|
||||
};
|
||||
frame.render_widget(Paragraph::new(Span::styled(details_text, ctx.theme.muted())), details);
|
||||
|
||||
let pieces: Vec<_> =
|
||||
day.map(|day| day.segments().iter().map(|s| (s.start(), s.end())).collect()).unwrap_or_default();
|
||||
let state = ctx.journal.state();
|
||||
let now = (state.open_day() == Some(ctx.current_day())).then(|| ctx.now.timestamp());
|
||||
let running_since = match state {
|
||||
ShiftState::Working { since, .. } => Some(since),
|
||||
_ => None,
|
||||
};
|
||||
frame.render_widget(
|
||||
Timeline {
|
||||
pieces: &pieces,
|
||||
running_since,
|
||||
now,
|
||||
window: window_for(&pieces, now, ctx.clock.zone(), 8),
|
||||
zone: ctx.clock.zone(),
|
||||
fmt: &ctx.fmt,
|
||||
theme: ctx.theme,
|
||||
},
|
||||
timeline_area,
|
||||
);
|
||||
}
|
||||
|
||||
fn render_week(&self, frame: &mut Frame, area: Rect, ctx: &Context) {
|
||||
let week = DateRange::week_of(ctx.now.date(), ctx.settings.week_start);
|
||||
let worked: SignedDuration =
|
||||
ctx.days.iter().filter(|day| week.contains(day.date())).map(WorkDay::worked).sum();
|
||||
frame.render_widget(
|
||||
Paragraph::new(goal_line("This week", worked, ctx.settings.weekly_target, area.width, ctx)),
|
||||
area,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for Tracker {
|
||||
fn keys(&self) -> &'static [&'static [Binding]] {
|
||||
&[keymap::TRACKER]
|
||||
}
|
||||
|
||||
fn render(&mut self, frame: &mut Frame, area: Rect, ctx: &Context) {
|
||||
let block = Block::bordered().border_type(BorderType::Rounded).border_style(ctx.theme.border(false));
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
let column = centered(inner, inner.width.min(76), inner.height);
|
||||
|
||||
let big = ctx.settings.big_timer && column.height >= 24;
|
||||
let space = u16::from(column.height >= 20);
|
||||
let [status, timer, rhythm, buttons, progress, details, timeline_area, week] = Layout::vertical([
|
||||
Constraint::Length(1 + space),
|
||||
Constraint::Length(if big { big_digits::HEIGHT + 2 * space } else { 1 + space }),
|
||||
Constraint::Length(1 + space),
|
||||
Constraint::Length(button::HEIGHT + space),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1 + space),
|
||||
Constraint::Length(timeline::HEIGHT + space),
|
||||
Constraint::Length(1),
|
||||
])
|
||||
.flex(Flex::Center)
|
||||
.areas(column);
|
||||
|
||||
let inset = |area: Rect| Rect { x: area.x + 2, width: area.width.saturating_sub(4), ..area };
|
||||
let first_row = |area: Rect, height: u16| Rect { height: height.min(area.height), ..area };
|
||||
|
||||
self.render_status(frame, first_row(status, 1), ctx);
|
||||
let timer_area = if big {
|
||||
Rect { y: timer.y + space, height: big_digits::HEIGHT, ..timer }
|
||||
} else {
|
||||
first_row(timer, 1)
|
||||
};
|
||||
self.render_timer(frame, timer_area, big, ctx);
|
||||
self.render_rhythm(frame, first_row(rhythm, 1), ctx);
|
||||
self.render_buttons(frame, first_row(buttons, button::HEIGHT), ctx);
|
||||
self.render_today(
|
||||
frame,
|
||||
[
|
||||
inset(progress),
|
||||
inset(first_row(details, 1)),
|
||||
inset(first_row(timeline_area, timeline::HEIGHT)),
|
||||
],
|
||||
ctx,
|
||||
);
|
||||
self.render_week(frame, inset(week), ctx);
|
||||
}
|
||||
|
||||
fn command(&mut self, command: Command, _count: Option<usize>, ctx: &Context) -> Option<Action> {
|
||||
let day = ctx.current_day();
|
||||
match command {
|
||||
Command::Edit => Some(self.edit(ctx)),
|
||||
Command::AddAfter | Command::AddBefore => {
|
||||
Some(popup(SegmentEditor::new(Target::New { day, after: true }, ctx)))
|
||||
}
|
||||
Command::Copy(target, preset) => Some(copy_action(target, preset, day, ctx)),
|
||||
Command::CopyMenu => Some(popup(CopyMenu::new(day, ctx))),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn mouse(&mut self, event: MouseEvent, _ctx: &Context) -> Option<Action> {
|
||||
if event.kind != MouseEventKind::Down(MouseButton::Left) {
|
||||
return None;
|
||||
}
|
||||
let position = Position::new(event.column, event.row);
|
||||
let (_, action) = self.buttons.iter().find(|(rect, _)| rect.contains(position))?;
|
||||
Some(match action {
|
||||
ButtonAction::Start => Action::Start,
|
||||
ButtonAction::Pause => Action::Pause,
|
||||
ButtonAction::EndDay => Action::EndDay,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// "Today 5h 40m ██████░░░░ 71% of 8h", or just "Today 5h 40m" without a target.
|
||||
fn goal_line(
|
||||
label: &str,
|
||||
worked: SignedDuration,
|
||||
target: SignedDuration,
|
||||
width: u16,
|
||||
ctx: &Context,
|
||||
) -> Line<'static> {
|
||||
let mut spans = vec![
|
||||
Span::styled(format!("{label:<10}"), ctx.theme.muted()),
|
||||
Span::styled(format!("{:<9}", ctx.fmt.duration(worked)), Style::new().add_modifier(Modifier::BOLD)),
|
||||
];
|
||||
if target > SignedDuration::ZERO {
|
||||
let ratio = worked.as_secs_f64() / target.as_secs_f64();
|
||||
let tail = format!(" {:>3.0}% of {}", ratio * 100.0, ctx.fmt.duration(target));
|
||||
let bar_width = width.saturating_sub(20 + tail.chars().count() as u16).min(40);
|
||||
spans.extend(progress_bar(bar_width, ratio, Style::new().fg(ctx.theme.working), ctx.theme.muted()));
|
||||
spans.push(Span::styled(tail, ctx.theme.muted()));
|
||||
}
|
||||
Line::from(spans)
|
||||
}
|
||||
Reference in new issue
Block a user