feat(footer): show local time (#42)

This commit is contained in:
Jens Krause 2025-01-06 18:31:22 +01:00 committed by GitHub
parent 66c6d7fc46
commit 94bdeeab11
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 261 additions and 24 deletions

64
Cargo.lock generated
View File

@ -296,6 +296,15 @@ dependencies = [
"syn",
]
[[package]]
name = "deranged"
version = "0.3.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4"
dependencies = [
"powerfmt",
]
[[package]]
name = "diff"
version = "0.1.13"
@ -649,6 +658,21 @@ dependencies = [
"winapi",
]
[[package]]
name = "num-conv"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
[[package]]
name = "num_threads"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9"
dependencies = [
"libc",
]
[[package]]
name = "object"
version = "0.32.2"
@ -723,6 +747,12 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
[[package]]
name = "powerfmt"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "pretty_assertions"
version = "1.4.1"
@ -1050,6 +1080,39 @@ dependencies = [
"once_cell",
]
[[package]]
name = "time"
version = "0.3.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35e7868883861bd0e56d9ac6efcaaca0d6d5d82a2a7ec8209ff492c07cf37b21"
dependencies = [
"deranged",
"itoa",
"libc",
"num-conv",
"num_threads",
"powerfmt",
"serde",
"time-core",
"time-macros",
]
[[package]]
name = "time-core"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3"
[[package]]
name = "time-macros"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2834e6017e3e5e4b9834939793b282bc03b37a3336245fa820e35e233e2a85de"
dependencies = [
"num-conv",
"time-core",
]
[[package]]
name = "timr-tui"
version = "0.9.0"
@ -1063,6 +1126,7 @@ dependencies = [
"serde",
"serde_json",
"strum",
"time",
"tokio",
"tokio-stream",
"tokio-util",

View File

@ -26,3 +26,4 @@ tracing = "0.1.41"
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
directories = "5.0.1"
clap = { version = "4.5.23", features = ["derive"] }
time = { version = "0.3.37", features = ["formatting", "local-offset"] }

BIN
demo/local-time.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

22
demo/local-time.tape Normal file
View File

@ -0,0 +1,22 @@
Output demo/local-time.gif
# https://github.com/charmbracelet/vhs/blob/main/THEMES.md
Set Theme "AtomOneLight"
Set FontSize 14
Set Width 800
Set Height 400
Set Padding 0
Set Margin 1
# --- START ---
Set LoopOffset 4
Hide
Type "cargo run -- -m c"
Enter
Sleep 0.2
Show
Sleep 1
# --- toggle local time ---
Type@1.5s ":::"
Sleep 1.5

View File

@ -63,3 +63,8 @@ alias dm := demo-menu
demo-menu:
vhs demo/menu.tape
alias dlt := demo-local-time
demo-local-time:
vhs demo/local-time.tape

View File

@ -1,6 +1,6 @@
use crate::{
args::Args,
common::{Content, Style},
common::{AppTime, AppTimeFormat, Content, Style},
constants::TICK_VALUE_MS,
events::{Event, EventHandler, Events},
storage::AppStorage,
@ -8,7 +8,7 @@ use crate::{
widgets::{
clock::{self, Clock, ClockArgs},
countdown::{Countdown, CountdownWidget},
footer::Footer,
footer::{Footer, FooterState},
header::Header,
pomodoro::{Mode as PomodoroMode, Pomodoro, PomodoroArgs, PomodoroWidget},
timer::{Timer, TimerWidget},
@ -22,6 +22,7 @@ use ratatui::{
widgets::{StatefulWidget, Widget},
};
use std::time::Duration;
use time::OffsetDateTime;
use tracing::debug;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@ -34,18 +35,20 @@ enum Mode {
pub struct App {
content: Content,
mode: Mode,
show_menu: bool,
app_time: AppTime,
countdown: Countdown,
timer: Timer,
pomodoro: Pomodoro,
style: Style,
with_decis: bool,
footer_state: FooterState,
}
pub struct AppArgs {
pub style: Style,
pub with_decis: bool,
pub show_menu: bool,
pub app_time_format: AppTimeFormat,
pub content: Content,
pub pomodoro_mode: PomodoroMode,
pub initial_value_work: Duration,
@ -64,6 +67,7 @@ impl From<(Args, AppStorage)> for AppArgs {
AppArgs {
with_decis: args.decis || stg.with_decis,
show_menu: args.menu || stg.show_menu,
app_time_format: stg.app_time_format,
content: args.mode.unwrap_or(stg.content),
style: args.style.unwrap_or(stg.style),
pomodoro_mode: stg.pomodoro_mode,
@ -81,11 +85,19 @@ impl From<(Args, AppStorage)> for AppArgs {
}
}
fn get_app_time() -> AppTime {
match OffsetDateTime::now_local() {
Ok(t) => AppTime::Local(t),
Err(_) => AppTime::Utc(OffsetDateTime::now_utc()),
}
}
impl App {
pub fn new(args: AppArgs) -> Self {
let AppArgs {
style,
show_menu,
app_time_format,
initial_value_work,
initial_value_pause,
initial_value_countdown,
@ -100,7 +112,7 @@ impl App {
Self {
mode: Mode::Running,
content,
show_menu,
app_time: get_app_time(),
style,
with_decis,
countdown: Countdown::new(Clock::<clock::Countdown>::new(ClockArgs {
@ -126,12 +138,17 @@ impl App {
style,
with_decis,
}),
footer_state: FooterState::new(show_menu, app_time_format),
}
}
pub async fn run(mut self, mut terminal: Terminal, mut events: Events) -> Result<Self> {
while self.is_running() {
if let Some(event) = events.next().await {
if matches!(event, Event::Tick) {
self.app_time = get_app_time();
}
// Pipe events into subviews and handle only 'unhandled' events afterwards
if let Some(unhandled) = match self.content {
Content::Countdown => self.countdown.update(event.clone()),
@ -186,7 +203,12 @@ impl App {
KeyCode::Char('c') => self.content = Content::Countdown,
KeyCode::Char('t') => self.content = Content::Timer,
KeyCode::Char('p') => self.content = Content::Pomodoro,
KeyCode::Char('m') => self.show_menu = !self.show_menu,
// toogle app time format
KeyCode::Char(':') => self.footer_state.toggle_app_time_format(),
// toogle menu
KeyCode::Char('m') => self
.footer_state
.set_show_menu(!self.footer_state.get_show_menu()),
KeyCode::Char(',') => {
self.style = self.style.next();
// update clocks
@ -201,8 +223,8 @@ impl App {
self.countdown.set_with_decis(self.with_decis);
self.pomodoro.set_with_decis(self.with_decis);
}
KeyCode::Up => self.show_menu = true,
KeyCode::Down => self.show_menu = false,
KeyCode::Up => self.footer_state.set_show_menu(true),
KeyCode::Down => self.footer_state.set_show_menu(false),
_ => {}
};
}
@ -217,7 +239,8 @@ impl App {
pub fn to_storage(&self) -> AppStorage {
AppStorage {
content: self.content,
show_menu: self.show_menu,
show_menu: self.footer_state.get_show_menu(),
app_time_format: *self.footer_state.app_time_format(),
style: self.style,
with_decis: self.with_decis,
pomodoro_mode: self.pomodoro.get_mode().clone(),
@ -256,7 +279,11 @@ impl StatefulWidget for AppWidget {
let [v0, v1, v2] = Layout::vertical([
Constraint::Length(1),
Constraint::Percentage(100),
Constraint::Length(if state.show_menu { 4 } else { 1 }),
Constraint::Length(if state.footer_state.get_show_menu() {
4
} else {
1
}),
])
.areas(area);
@ -268,12 +295,12 @@ impl StatefulWidget for AppWidget {
// content
self.render_content(v1, buf, state);
// footer
Footer {
show_menu: state.show_menu,
let footer = Footer {
running_clock: state.clock_is_running(),
selected_content: state.content,
edit_mode: state.is_edit_mode(),
}
.render(v2, buf);
app_time: state.app_time,
};
StatefulWidget::render(footer, v2, buf, &mut state.footer_state);
}
}

View File

@ -1,6 +1,8 @@
use clap::ValueEnum;
use ratatui::symbols::shade;
use serde::{Deserialize, Serialize};
use time::format_description;
use time::OffsetDateTime;
#[derive(
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, ValueEnum, Default, Serialize, Deserialize,
@ -62,3 +64,66 @@ impl Style {
}
}
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
pub enum AppTimeFormat {
/// `hh:mm:ss`
#[default]
HhMmSs,
/// `hh:mm`
HhMm,
/// `hh:mm AM` (or PM)
Hh12Mm,
/// `` (empty)
Hidden,
}
impl AppTimeFormat {
pub fn next(&self) -> Self {
match self {
AppTimeFormat::HhMmSs => AppTimeFormat::HhMm,
AppTimeFormat::HhMm => AppTimeFormat::Hh12Mm,
AppTimeFormat::Hh12Mm => AppTimeFormat::Hidden,
AppTimeFormat::Hidden => AppTimeFormat::HhMmSs,
}
}
}
#[derive(Debug, Clone, Copy)]
pub enum AppTime {
Local(OffsetDateTime),
Utc(OffsetDateTime),
}
impl From<AppTime> for OffsetDateTime {
fn from(app_time: AppTime) -> Self {
match app_time {
AppTime::Local(t) => t,
AppTime::Utc(t) => t,
}
}
}
impl AppTime {
pub fn format(&self, app_format: &AppTimeFormat) -> String {
let parse_str = match app_format {
AppTimeFormat::HhMmSs => Some("[hour]:[minute]:[second]"),
AppTimeFormat::HhMm => Some("[hour]:[minute]"),
AppTimeFormat::Hh12Mm => Some("[hour]:[minute] [period]"),
AppTimeFormat::Hidden => None,
};
if let Some(str) = parse_str {
format_description::parse(str)
.map_err(|_| "parse error")
.and_then(|fd| {
OffsetDateTime::from(*self)
.format(&fd)
.map_err(|_| "format error")
})
.unwrap_or_else(|e| e.to_string())
} else {
"".to_owned()
}
}
}

View File

@ -1,5 +1,5 @@
use crate::{
common::{Content, Style},
common::{AppTimeFormat, Content, Style},
widgets::pomodoro::Mode as PomodoroMode,
};
use color_eyre::eyre::Result;
@ -12,6 +12,7 @@ use std::time::Duration;
pub struct AppStorage {
pub content: Content,
pub show_menu: bool,
pub app_time_format: AppTimeFormat,
pub style: Style,
pub with_decis: bool,
pub pomodoro_mode: PomodoroMode,
@ -36,6 +37,7 @@ impl Default for AppStorage {
AppStorage {
content: Content::default(),
show_menu: true,
app_time_format: AppTimeFormat::default(),
style: Style::default(),
with_decis: false,
pomodoro_mode: PomodoroMode::Work,

View File

@ -1,25 +1,57 @@
use std::collections::BTreeMap;
use crate::common::Content;
use crate::common::{AppTime, AppTimeFormat, Content};
use ratatui::{
buffer::Buffer,
layout::{Constraint, Layout, Rect},
style::{Modifier, Style},
symbols::{border, scrollbar},
text::{Line, Span},
widgets::{Block, Borders, Cell, Row, Table, Widget},
widgets::{Block, Borders, Cell, Row, StatefulWidget, Table, Widget},
};
#[derive(Debug, Clone)]
pub struct FooterState {
show_menu: bool,
app_time_format: AppTimeFormat,
}
impl FooterState {
pub const fn new(show_menu: bool, app_time_format: AppTimeFormat) -> Self {
Self {
show_menu,
app_time_format,
}
}
pub fn set_show_menu(&mut self, value: bool) {
self.show_menu = value;
}
pub const fn get_show_menu(&self) -> bool {
self.show_menu
}
pub const fn app_time_format(&self) -> &AppTimeFormat {
&self.app_time_format
}
pub fn toggle_app_time_format(&mut self) {
self.app_time_format = self.app_time_format.next();
}
}
#[derive(Debug)]
pub struct Footer {
pub show_menu: bool,
pub running_clock: bool,
pub selected_content: Content,
pub edit_mode: bool,
pub app_time: AppTime,
}
impl Widget for Footer {
fn render(self, area: Rect, buf: &mut Buffer) {
impl StatefulWidget for Footer {
type State = FooterState;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
let content_labels: BTreeMap<Content, &str> = BTreeMap::from([
(Content::Countdown, "[c]ountdown"),
(Content::Timer, "[t]imer"),
@ -31,15 +63,25 @@ impl Widget for Footer {
let [border_area, menu_area] =
Layout::vertical([Constraint::Length(1), Constraint::Percentage(100)]).areas(area);
Block::new()
.borders(Borders::TOP)
.title(
format! {"[m]enu {:} ", if self.show_menu {scrollbar::VERTICAL.end} else {scrollbar::VERTICAL.begin}},
format! {"[m]enu {:} ", if state.show_menu {scrollbar::VERTICAL.end} else {scrollbar::VERTICAL.begin}},
)
.title(
Line::from(
match state.app_time_format {
// `Hidden` -> no (empty) title
AppTimeFormat::Hidden => "".into(),
// others -> add some space around
_ => format!(" {} ", self.app_time.format(&state.app_time_format))
}
).right_aligned())
.border_set(border::PLAIN)
.render(border_area, buf);
// show menu
if self.show_menu {
if state.show_menu {
let content_labels: Vec<Span> = content_labels
.iter()
.enumerate()
@ -60,7 +102,7 @@ impl Widget for Footer {
const SPACE: &str = " "; // 2 empty spaces
let widths = [Constraint::Length(12), Constraint::Percentage(100)];
Table::new(
let table = Table::new(
[
// content
Row::new(vec![
@ -80,6 +122,14 @@ impl Widget for Footer {
Span::from("[,]change style"),
Span::from(SPACE),
Span::from("[.]toggle deciseconds"),
Span::from(SPACE),
Span::from(format!(
"[:]toggle {} time",
match self.app_time {
AppTime::Local(_) => "local",
AppTime::Utc(_) => "utc",
}
)),
])),
]),
// edit
@ -128,8 +178,9 @@ impl Widget for Footer {
],
widths,
)
.column_spacing(1)
.render(menu_area, buf);
.column_spacing(1);
Widget::render(table, menu_area, buf);
}
}
}