From 95d914c757f1f0113cd918aa29506b2b602a2a14 Mon Sep 17 00:00:00 2001 From: Jens Krause <47693+sectore@users.noreply.github.com> Date: Wed, 15 Oct 2025 16:49:17 +0200 Subject: [PATCH] feat(event) make `date_time` + `title` editable (#130) * wip: editable events * make it work * fix: scroll position, title validation, underline inputs to visualize edit mode * show error * prefix datetime * refactor rendering inputs * compact `EditMode` * update footer to include `event` keybindings * update README --- Cargo.lock | 11 ++ Cargo.toml | 1 + README.md | 8 + src/app.rs | 29 +++- src/common.rs | 1 + src/events.rs | 17 +- src/widgets/countdown.rs | 184 ++++++++++---------- src/widgets/event.rs | 357 ++++++++++++++++++++++++++++++++++----- src/widgets/footer.rs | 122 +++++++------ src/widgets/pomodoro.rs | 6 +- src/widgets/timer.rs | 6 +- 11 files changed, 539 insertions(+), 203 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ce96d88..14162c6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2110,6 +2110,7 @@ dependencies = [ "tokio-util", "tracing", "tracing-subscriber", + "tui-input", ] [[package]] @@ -2268,6 +2269,16 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "tui-input" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "911e93158bf80bbc94bad533b2b16e3d711e1132d69a6a6980c3920a63422c19" +dependencies = [ + "ratatui", + "unicode-width 0.2.0", +] + [[package]] name = "uds_windows" version = "1.1.0" diff --git a/Cargo.toml b/Cargo.toml index 5660ddd..d0f0bc8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,6 +42,7 @@ rodio = { version = "0.20.1", features = [ "symphonia-wav", ], default-features = false, optional = true } thiserror = { version = "2.0.17", optional = true } +tui-input = "0.14.0" [features] diff --git a/README.md b/README.md index 1b21be3..c0f049a 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,14 @@ Extra option (if `--features sound` is enabled by local build only): | | edit to go down | | ctrl+↓ | edit to go down 10x | +**In `Event` `edit` mode only:** + +| Key | Description | +| --- | --- | +| Enter | save changes | +| Esc | skip changes | +| Tab | switch input | + **In `Pomodoro` screen only:** | Key | Description | diff --git a/src/app.rs b/src/app.rs index be7f2b9..a8d533c 100644 --- a/src/app.rs +++ b/src/app.rs @@ -18,6 +18,8 @@ use crate::{ }, }; +use crossterm::event::Event as CrosstermEvent; + #[cfg(feature = "sound")] use crate::sound::Sound; @@ -25,7 +27,7 @@ use color_eyre::Result; use ratatui::{ buffer::Buffer, crossterm::event::{KeyCode, KeyEvent}, - layout::{Constraint, Layout, Rect}, + layout::{Constraint, Layout, Position, Rect}, widgets::{StatefulWidget, Widget}, }; use std::path::PathBuf; @@ -55,6 +57,7 @@ pub struct App { style: Style, with_decis: bool, footer: FooterState, + cursor_position: Option, } pub struct AppArgs { @@ -229,6 +232,7 @@ impl App { None }, ), + cursor_position: None, } } @@ -323,10 +327,13 @@ impl App { Content::LocalTime => app.local_time.update(event.clone()), } { match unhandled { - events::TuiEvent::Render | events::TuiEvent::Resize => { + events::TuiEvent::Render + | events::TuiEvent::Crossterm(crossterm::event::Event::Resize(_, _)) => { app.draw(terminal)?; } - events::TuiEvent::Key(key) => handle_key_event(app, key), + events::TuiEvent::Crossterm(CrosstermEvent::Key(key)) => { + handle_key_event(app, key) + } _ => {} } } @@ -366,6 +373,9 @@ impl App { ); } } + events::AppEvent::SetCursor(position) => { + app.cursor_position = position; + } } Ok(()) }; @@ -411,7 +421,13 @@ impl App { AppEditMode::None } } - Content::Event => AppEditMode::None, + Content::Event => { + if self.event.is_edit_mode() { + AppEditMode::Event + } else { + AppEditMode::None + } + } Content::LocalTime => AppEditMode::None, } } @@ -441,6 +457,11 @@ impl App { fn draw(&mut self, terminal: &mut Terminal) -> Result<()> { terminal.draw(|frame| { frame.render_stateful_widget(AppWidget, frame.area(), self); + + // Set cursor position if requested + if let Some(position) = self.cursor_position { + frame.set_cursor_position(position); + } })?; Ok(()) } diff --git a/src/common.rs b/src/common.rs index c1b3ee7..eb1ab7f 100644 --- a/src/common.rs +++ b/src/common.rs @@ -209,6 +209,7 @@ pub enum AppEditMode { None, Clock, Time, + Event, } #[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq, Default, Serialize, Deserialize)] diff --git a/src/events.rs b/src/events.rs index 8bc231c..b9a162e 100644 --- a/src/events.rs +++ b/src/events.rs @@ -1,5 +1,6 @@ -use crossterm::event::{Event as CrosstermEvent, EventStream, KeyEvent, KeyEventKind}; +use crossterm::event::{Event as CrosstermEvent, EventStream}; use futures::{Stream, StreamExt}; +use ratatui::layout::Position; use std::{pin::Pin, time::Duration}; use tokio::sync::mpsc; use tokio::time::interval; @@ -20,13 +21,13 @@ pub enum TuiEvent { Error, Tick, Render, - Key(KeyEvent), - Resize, + Crossterm(CrosstermEvent), } #[derive(Clone, Debug)] pub enum AppEvent { ClockDone(ClockTypeId, String), + SetCursor(Option), } pub type AppEventTx = mpsc::UnboundedSender; @@ -89,14 +90,10 @@ fn crossterm_stream() -> Pin>> { EventStream::new() .fuse() // we are not interested in all events - .filter_map(|event| async move { - match event { - Ok(CrosstermEvent::Key(key)) if key.kind == KeyEventKind::Press => { - Some(TuiEvent::Key(key)) - } - Ok(CrosstermEvent::Resize(_, _)) => Some(TuiEvent::Resize), + .filter_map(|result| async move { + match result { + Ok(event) => Some(TuiEvent::Crossterm(event)), Err(_) => Some(TuiEvent::Error), - _ => None, } }), ) diff --git a/src/widgets/countdown.rs b/src/widgets/countdown.rs index 4d93de6..be57c2e 100644 --- a/src/widgets/countdown.rs +++ b/src/widgets/countdown.rs @@ -9,7 +9,7 @@ use crate::{ edit_time::{EditTimeState, EditTimeStateArgs, EditTimeWidget}, }, }; -use crossterm::event::KeyModifiers; +use crossterm::event::{Event as CrosstermEvent, KeyModifiers}; use ratatui::{ buffer::Buffer, crossterm::event::KeyCode, @@ -163,102 +163,106 @@ impl TuiEventHandler for CountdownState { } } // EDIT CLOCK mode - TuiEvent::Key(key) if self.is_clock_edit_mode() => match key.code { - // skip editing - KeyCode::Esc => { - // Important: set current value first - self.clock.set_current_value(*self.clock.get_prev_value()); - // before toggling back to non-edit mode - self.clock.toggle_edit(); - } - // Apply changes and set new initial value - KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => { - // toggle edit mode - self.clock.toggle_edit(); - // set initial value - self.clock - .set_initial_value(*self.clock.get_current_value()); - // always reset `elapsed_clock` - self.elapsed_clock.reset(); - } - // Apply changes - KeyCode::Char('s') => { - // toggle edit mode - self.clock.toggle_edit(); - // always reset `elapsed_clock` - self.elapsed_clock.reset(); - } - KeyCode::Right => { - self.clock.edit_prev(); - } - KeyCode::Left => { - self.clock.edit_next(); - } - KeyCode::Up if key.modifiers.contains(KeyModifiers::CONTROL) => { - self.clock.edit_jump_up(); - } - KeyCode::Up => { - self.clock.edit_up(); - } - KeyCode::Down if key.modifiers.contains(KeyModifiers::CONTROL) => { - self.clock.edit_jump_down(); - } - KeyCode::Down => { - self.clock.edit_down(); - } - _ => return Some(event), - }, - // EDIT LOCAL TIME mode - TuiEvent::Key(key) if self.is_time_edit_mode() => match key.code { - // skip editing - KeyCode::Esc => { - self.edit_time = None; - } - // Apply changes and set new initial value - KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => { - if let Some(edit_time) = &mut self.edit_time.clone() { - // Order matters: - // 1. update current value - self.edit_time_done(edit_time); - // 2. set initial value + TuiEvent::Crossterm(CrosstermEvent::Key(key)) if self.is_clock_edit_mode() => { + match key.code { + // skip editing + KeyCode::Esc => { + // Important: set current value first + self.clock.set_current_value(*self.clock.get_prev_value()); + // before toggling back to non-edit mode + self.clock.toggle_edit(); + } + // Apply changes and set new initial value + KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => { + // toggle edit mode + self.clock.toggle_edit(); + // set initial value self.clock .set_initial_value(*self.clock.get_current_value()); + // always reset `elapsed_clock` + self.elapsed_clock.reset(); } - // always reset `elapsed_clock` - self.elapsed_clock.reset(); - } - // Apply changes of editing by local time - KeyCode::Char('s') => { - if let Some(edit_time) = &mut self.edit_time.clone() { - self.edit_time_done(edit_time) + // Apply changes + KeyCode::Char('s') => { + // toggle edit mode + self.clock.toggle_edit(); + // always reset `elapsed_clock` + self.elapsed_clock.reset(); } - // always reset `elapsed_clock` - self.elapsed_clock.reset(); + KeyCode::Right => { + self.clock.edit_prev(); + } + KeyCode::Left => { + self.clock.edit_next(); + } + KeyCode::Up if key.modifiers.contains(KeyModifiers::CONTROL) => { + self.clock.edit_jump_up(); + } + KeyCode::Up => { + self.clock.edit_up(); + } + KeyCode::Down if key.modifiers.contains(KeyModifiers::CONTROL) => { + self.clock.edit_jump_down(); + } + KeyCode::Down => { + self.clock.edit_down(); + } + _ => return Some(event), } - // move edit position to the left - KeyCode::Left => { - // safe unwrap because we are in `is_time_edit_mode` - self.edit_time.as_mut().unwrap().next(); + } + // EDIT LOCAL TIME mode + TuiEvent::Crossterm(CrosstermEvent::Key(key)) if self.is_time_edit_mode() => { + match key.code { + // skip editing + KeyCode::Esc => { + self.edit_time = None; + } + // Apply changes and set new initial value + KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => { + if let Some(edit_time) = &mut self.edit_time.clone() { + // Order matters: + // 1. update current value + self.edit_time_done(edit_time); + // 2. set initial value + self.clock + .set_initial_value(*self.clock.get_current_value()); + } + // always reset `elapsed_clock` + self.elapsed_clock.reset(); + } + // Apply changes of editing by local time + KeyCode::Char('s') => { + if let Some(edit_time) = &mut self.edit_time.clone() { + self.edit_time_done(edit_time) + } + // always reset `elapsed_clock` + self.elapsed_clock.reset(); + } + // move edit position to the left + KeyCode::Left => { + // safe unwrap because we are in `is_time_edit_mode` + self.edit_time.as_mut().unwrap().next(); + } + // move edit position to the right + KeyCode::Right => { + // safe unwrap because we are in `is_time_edit_mode` + self.edit_time.as_mut().unwrap().prev(); + } + // Value up + KeyCode::Up => { + // safe unwrap because of previous check in `is_time_edit_mode` + self.edit_time.as_mut().unwrap().up(); + } + // Value down + KeyCode::Down => { + // safe unwrap because of previous check in `is_time_edit_mode` + self.edit_time.as_mut().unwrap().down(); + } + _ => return Some(event), } - // move edit position to the right - KeyCode::Right => { - // safe unwrap because we are in `is_time_edit_mode` - self.edit_time.as_mut().unwrap().prev(); - } - // Value up - KeyCode::Up => { - // safe unwrap because of previous check in `is_time_edit_mode` - self.edit_time.as_mut().unwrap().up(); - } - // Value down - KeyCode::Down => { - // safe unwrap because of previous check in `is_time_edit_mode` - self.edit_time.as_mut().unwrap().down(); - } - _ => return Some(event), - }, + } // default mode - TuiEvent::Key(key) => match key.code { + TuiEvent::Crossterm(CrosstermEvent::Key(key)) => match key.code { KeyCode::Char('r') => { // reset both clocks to use intial values self.clock.reset(); diff --git a/src/widgets/event.rs b/src/widgets/event.rs index a7295d0..3ba4e9a 100644 --- a/src/widgets/event.rs +++ b/src/widgets/event.rs @@ -1,13 +1,18 @@ +use color_eyre::{Report, eyre::eyre}; +use crossterm::event::{Event as CrosstermEvent, KeyCode, KeyModifiers}; use ratatui::{ buffer::Buffer, - layout::{Constraint, Layout, Rect}, + layout::{Constraint, Layout, Position, Rect}, + style::{Color, Modifier, Style}, text::Line, - widgets::{StatefulWidget, Widget}, + widgets::{Paragraph, StatefulWidget, Widget}, }; use time::{OffsetDateTime, macros::format_description}; +use tui_input::Input; +use tui_input::backend::crossterm::EventHandler; use crate::{ - common::{AppTime, ClockTypeId, Style}, + common::{AppTime, ClockTypeId, Style as DigitStyle}, duration::CalendarDuration, event::Event, events::{AppEvent, AppEventTx, TuiEvent, TuiEventHandler}, @@ -16,6 +21,44 @@ use crate::{ }; use std::{cmp::max, time::Duration}; +#[derive(Clone, Copy, Default)] +enum Editable { + #[default] + DateTime, + Title, +} + +impl Editable { + pub fn next(&self) -> Self { + match self { + Editable::DateTime => Editable::Title, + Editable::Title => Editable::DateTime, + } + } + + pub fn prev(&self) -> Self { + match self { + Editable::DateTime => Editable::Title, + Editable::Title => Editable::DateTime, + } + } +} + +#[derive(Clone, Copy)] +enum EditMode { + None, + Editing(Editable), +} + +impl EditMode { + fn is_editable(&self) -> bool { + match self { + EditMode::None => false, + EditMode::Editing(_) => true, + } + } +} + /// State for `EventWidget` pub struct EventState { title: Option, @@ -27,6 +70,13 @@ pub struct EventState { /// Default value: `None` done_count: Option, app_tx: AppEventTx, + // inputs + input_datetime: Input, + input_datetime_error: Option, + input_title: Input, + input_title_error: Option, + edit_mode: EditMode, + last_editable: Editable, } pub struct EventStateArgs { @@ -48,15 +98,23 @@ impl EventState { let app_datetime = OffsetDateTime::from(app_time); // assume event has as same `offset` as `app_time` let event_offset = event.date_time.assume_offset(app_datetime.offset()); + let input_datetime_value = format_offsetdatetime(&event_offset); + let input_title_value = event.title.clone().unwrap_or("".into()); Self { - title: event.title, + title: event.title.clone(), event_time: event_offset, app_time: app_datetime, start_time: app_datetime, with_decis, done_count: None, app_tx, + input_datetime: Input::default().with_value(input_datetime_value), + input_datetime_error: None, + input_title: Input::default().with_value(input_title_value), + input_title_error: None, + edit_mode: EditMode::None, + last_editable: Editable::default(), } } @@ -107,11 +165,131 @@ impl EventState { self.done_count = clock::count_clock_done(self.done_count); } } + + fn reset_cursor(&mut self) { + _ = self.app_tx.send(AppEvent::SetCursor(None)); + } + + pub fn is_edit_mode(&self) -> bool { + self.edit_mode.is_editable() + } + + fn reset_edit_mode(&mut self) { + self.edit_mode = EditMode::None; + } + + fn reset_input_datetime(&mut self) { + self.input_datetime = Input::default().with_value(format_offsetdatetime(&self.event_time)); + self.input_datetime_error = None; + } + + fn reset_input_title(&mut self) { + self.input_title = Input::default().with_value(self.title.clone().unwrap_or_default()); + self.input_title_error = None; + } +} + +fn validate_datetime(value: &str) -> Result { + time::PrimitiveDateTime::parse( + value, + format_description!("[year]-[month]-[day] [hour]:[minute]:[second]"), + ) + .map_err(|_| eyre!("Expected format 'YYYY-MM-DD HH:MM:SS'")) +} + +const MAX_LABEL_WIDTH: usize = 60; + +fn validate_title(value: &str) -> Result<&str, Report> { + if value.len() > MAX_LABEL_WIDTH { + return Err(eyre!("Max. {} chars", MAX_LABEL_WIDTH)); + } + Ok(value) } impl TuiEventHandler for EventState { fn update(&mut self, event: TuiEvent) -> Option { - Some(event) + let editable = self.edit_mode.is_editable(); + match event { + // EDIT mode + TuiEvent::Crossterm(crossterm_event @ CrosstermEvent::Key(key)) if editable => { + match key.code { + // Skip changes + KeyCode::Esc => { + // reset inputs + self.reset_input_datetime(); + self.reset_input_title(); + + self.reset_edit_mode(); + self.reset_cursor(); + } + KeyCode::Tab if key.modifiers.contains(KeyModifiers::SHIFT) => { + if let EditMode::Editing(e) = self.edit_mode { + self.last_editable = e.prev(); + self.edit_mode = EditMode::Editing(self.last_editable) + } + } + KeyCode::Tab => { + if let EditMode::Editing(e) = self.edit_mode { + self.last_editable = e.next(); + self.edit_mode = EditMode::Editing(self.last_editable) + } + } + KeyCode::Enter => match self.edit_mode { + EditMode::Editing(Editable::DateTime) => { + // validate + if let Ok(date_time) = validate_datetime(self.input_datetime.value()) { + // apply offset + self.event_time = date_time.assume_offset(self.app_time.offset()); + } else { + // reset + self.reset_input_datetime(); + } + self.reset_edit_mode(); + self.reset_cursor(); + } + EditMode::Editing(Editable::Title) => { + self.title = validate_title(self.input_title.value()) + .ok() + .filter(|v| !v.is_empty()) + .map(str::to_string); + self.reset_edit_mode(); + self.reset_cursor(); + } + EditMode::None => {} + }, + _ => match self.edit_mode { + EditMode::Editing(Editable::DateTime) => { + self.input_datetime.handle_event(&crossterm_event); + if let Err(e) = validate_datetime(self.input_datetime.value()) { + self.input_datetime_error = Some(e); + } else { + self.input_datetime_error = None; + } + } + EditMode::Editing(Editable::Title) => { + self.input_title.handle_event(&crossterm_event); + if let Err(e) = validate_title(self.input_title.value()) { + self.input_title_error = Some(e); + } else { + self.input_title_error = None; + } + } + EditMode::None => {} + }, + } + } + // default mode + TuiEvent::Crossterm(CrosstermEvent::Key(key)) => match key.code { + // Enter edit mode + KeyCode::Char('e') => { + self.edit_mode = EditMode::Editing(self.last_editable); + } + _ => return Some(event), + }, + _ => return Some(event), + } + + None } } @@ -132,9 +310,16 @@ fn get_percentage(start: OffsetDateTime, end: OffsetDateTime, current: OffsetDat percentage as u16 } +fn format_offsetdatetime(dt: &OffsetDateTime) -> String { + dt.format(&format_description!( + "[year]-[month]-[day] [hour]:[minute]:[second]" + )) + .unwrap_or_else(|e| format!("time format error: {}", e)) +} + #[derive(Debug)] pub struct EventWidget { - pub style: Style, + pub style: DigitStyle, pub blink: bool, } @@ -147,42 +332,20 @@ impl StatefulWidget for EventWidget { let clock_widths = clock::clock_horizontal_lengths(&clock_format, with_decis); let clock_width = clock_widths.iter().sum(); - let label_event = Line::raw(state.title.clone().unwrap_or("".into()).to_uppercase()); - let time_str = state - .event_time - .format(&format_description!( - "[year]-[month]-[day] [hour]:[minute]:[second]" - )) - .unwrap_or_else(|e| format!("time format error: {}", e)); - let time_prefix = if clock_duration.is_since() { - let duration: Duration = clock_duration.clone().into(); - // Show `done` for a short of time (1 sec) - if duration < Duration::from_secs(1) { - "Done" - } else { - "Since" - } - } else { - "Until" - }; - - let label_time = Line::raw(format!( - "{} {}", - time_prefix.to_uppercase(), - time_str.to_uppercase() - )); - let max_label_width = max(label_event.width(), label_time.width()) as u16; - let area = center( area, - Constraint::Length(max(clock_width, max_label_width)), - Constraint::Length(DIGIT_HEIGHT + 3 /* height of label */), + Constraint::Length(max(clock_width, MAX_LABEL_WIDTH as u16)), + Constraint::Length( + DIGIT_HEIGHT + 7, /* height of all labels + empty lines */ + ), ); - let [_, v1, v2, v3] = Layout::vertical(Constraint::from_lengths([ - 1, // empty (offset) to keep everything centered vertically comparing to "clock" widgets with one label only + let [_, v1, v2, v3, _, v4] = Layout::vertical(Constraint::from_lengths([ + 3, // empty (offset) to keep everything centered vertically comparing to "clock" widgets with one label only DIGIT_HEIGHT, - 1, // event date - 1, // event title + 1, // label: event date + 1, // label: event title + 1, // empty + 1, // label: error ])) .areas(area); @@ -196,7 +359,7 @@ impl StatefulWidget for EventWidget { let render_clock_state = clock::RenderClockState { with_decis, - duration: clock_duration, + duration: clock_duration.clone(), editable_time: None, format: clock_format, symbol, @@ -204,8 +367,120 @@ impl StatefulWidget for EventWidget { }; clock::render_clock(v1, buf, render_clock_state); - label_time.centered().render(v2, buf); - label_event.centered().render(v3, buf); + + // Helper to calculate centered area, cursor x position, and scroll + let calc_editable_input_positions = |input: &Input, area: Rect| -> (Rect, u16, usize) { + // Calculate scroll position to keep cursor visible + let input_scroll = input.visual_scroll(area.width as usize); + + // Get correct visual width (handles unicode properly) + let text_width = Line::raw(input.value()).width() as u16; + + // Calculate visible text width after scrolling + let visible_text_width = text_width + .saturating_sub(input_scroll as u16) + .min(area.width); + + // Center the visible portion + let offset_x = (area.width.saturating_sub(visible_text_width)) / 2; + + let centered_area = Rect { + x: area.x + offset_x, + y: area.y, + width: visible_text_width, + height: area.height, + }; + + // Cursor position relative to the visible scrolled text + let cursor_offset = input.visual_cursor().saturating_sub(input_scroll); + let cursor_x = area.x + offset_x + cursor_offset as u16; + + (centered_area, cursor_x, input_scroll) + }; + + fn input_edit_style(with_error: bool) -> Style { + if with_error { + Style::default() + .add_modifier(Modifier::UNDERLINED) + .fg(Color::Red) + } else { + Style::default().add_modifier(Modifier::UNDERLINED) + } + } + + // Render date time input + match state.edit_mode { + // EDIT + EditMode::Editing(Editable::DateTime) => { + let (datetime_area, datetime_cursor_x, datetime_scroll) = + calc_editable_input_positions(&state.input_datetime, v2); + + Paragraph::new(state.input_datetime.value()) + .style(input_edit_style(state.input_datetime_error.is_some())) + .scroll((0, datetime_scroll as u16)) + .render(datetime_area, buf); + + // Update cursor + let cp = Position::new(datetime_cursor_x, v2.y); + let _ = state.app_tx.send(AppEvent::SetCursor(Some(cp))); + } + // NORMAL + _ => { + let mut prefix = "Until"; + + if clock_duration.is_since() { + let duration: Duration = clock_duration.clone().into(); + // Show `done` for a short of time (1 sec) + prefix = if duration < Duration::from_secs(1) { + "Done" + } else { + "Since" + }; + }; + + Paragraph::new(format!( + "{} {}", + prefix.to_uppercase(), + state.input_datetime.value() + )) + .centered() + .render(v2, buf); + } + }; + + // Render title input + match state.edit_mode { + // EDIT + EditMode::Editing(Editable::Title) => { + let (title_area, title_cursor_x, title_scroll) = + calc_editable_input_positions(&state.input_title, v3); + + Paragraph::new(state.input_title.value().to_uppercase()) + .style(input_edit_style(state.input_title_error.is_some())) + .scroll((0, title_scroll as u16)) + .render(title_area, buf); + // Update cursor + let cp = Position::new(title_cursor_x, v3.y); + let _ = state.app_tx.send(AppEvent::SetCursor(Some(cp))); + } + // NORMAL + _ => { + Paragraph::new(state.input_title.value().to_uppercase()) + .centered() + .render(v3, buf); + } + }; + + // Render error + let error_txt: String = match (&state.input_datetime_error, &state.input_title_error) { + (Some(e), _) => e.to_string(), + (_, Some(e)) => e.to_string(), + _ => "".into(), + }; + Paragraph::new(error_txt.to_lowercase()) + .style(Style::default().add_modifier(Modifier::ITALIC)) + .centered() + .render(v4, buf); } } diff --git a/src/widgets/footer.rs b/src/widgets/footer.rs index b2b0d8a..1c6e4ca 100644 --- a/src/widgets/footer.rs +++ b/src/widgets/footer.rs @@ -143,10 +143,8 @@ impl StatefulWidget for Footer { ]), ]; - // Controls (except for `localtime` and `event`) - if self.selected_content != Content::LocalTime - && self.selected_content != Content::Event - { + // Controls (except for `localtime`) + if self.selected_content != Content::LocalTime { table_rows.extend_from_slice(&[ // controls - 1. row Row::new(vec![ @@ -156,7 +154,7 @@ impl StatefulWidget for Footer { )), Cell::from(Line::from({ match self.app_edit_mode { - AppEditMode::None => { + AppEditMode::None if self.selected_content != Content::Event => { let mut spans = vec![Span::from(if self.running_clock { "[s]top" } else { @@ -184,8 +182,16 @@ impl StatefulWidget for Footer { } spans } - _ => { + AppEditMode::None if self.selected_content == Content::Event => { + vec![Span::from("[e]dit")] + } + AppEditMode::Clock | AppEditMode::Time | AppEditMode::Event => { let mut spans = vec![Span::from("[s]ave changes")]; + + if self.selected_content == Content::Event { + spans[0] = Span::from("[enter]save changes") + }; + if self.selected_content == Content::Countdown || self.selected_content == Content::Pomodoro { @@ -198,60 +204,72 @@ impl StatefulWidget for Footer { Span::from(SPACE), Span::from("[esc]skip changes"), ]); + + if self.selected_content == Content::Event { + spans.extend_from_slice(&[ + Span::from(SPACE), + Span::from("[tab]switch input"), + ]); + } spans } + _ => vec![], } })), ]), // controls - 2. row - Row::new(vec![ - Cell::from(Line::from("")), - Cell::from(Line::from({ - match self.app_edit_mode { - AppEditMode::None => { - let mut spans = vec![]; - if self.selected_content == Content::Pomodoro { - spans.extend_from_slice(&[Span::from( - "[^←] or [^→] switch work/pause", - )]); + Row::new(if self.selected_content == Content::Event { + vec![] + } else { + vec![ + Cell::from(Line::from("")), + Cell::from(Line::from({ + match self.app_edit_mode { + AppEditMode::None => { + let mut spans = vec![]; + if self.selected_content == Content::Pomodoro { + spans.extend_from_slice(&[Span::from( + "[^←] or [^→] switch work/pause", + )]); + } + spans } - spans + _ => vec![ + Span::from(format!( + // ← →, + "[{} {}]change selection", + scrollbar::HORIZONTAL.begin, + scrollbar::HORIZONTAL.end + )), + Span::from(SPACE), + Span::from(format!( + // ↑ + "[{}]edit up", + scrollbar::VERTICAL.begin + )), + Span::from(SPACE), + Span::from(format!( + // ctrl + ↑ + "[^{}]edit up 10x", + scrollbar::VERTICAL.begin + )), + Span::from(SPACE), + Span::from(format!( + // ↓ + "[{}]edit up", + scrollbar::VERTICAL.end + )), + Span::from(SPACE), + Span::from(format!( + // ctrl + ↓ + "[^{}]edit up 10x", + scrollbar::VERTICAL.end + )), + ], } - _ => vec![ - Span::from(format!( - // ← →, - "[{} {}]change selection", - scrollbar::HORIZONTAL.begin, - scrollbar::HORIZONTAL.end - )), - Span::from(SPACE), - Span::from(format!( - // ↑ - "[{}]edit up", - scrollbar::VERTICAL.begin - )), - Span::from(SPACE), - Span::from(format!( - // ctrl + ↑ - "[^{}]edit up 10x", - scrollbar::VERTICAL.begin - )), - Span::from(SPACE), - Span::from(format!( - // ↓ - "[{}]edit up", - scrollbar::VERTICAL.end - )), - Span::from(SPACE), - Span::from(format!( - // ctrl + ↓ - "[^{}]edit up 10x", - scrollbar::VERTICAL.end - )), - ], - } - })), - ]), + })), + ] + }), ]) } diff --git a/src/widgets/pomodoro.rs b/src/widgets/pomodoro.rs index cb63714..3761808 100644 --- a/src/widgets/pomodoro.rs +++ b/src/widgets/pomodoro.rs @@ -5,7 +5,7 @@ use crate::{ utils::center, widgets::clock::{ClockState, ClockStateArgs, ClockWidget, Countdown}, }; -use crossterm::event::{KeyCode, KeyModifiers}; +use crossterm::event::{Event as CrosstermEvent, KeyCode, KeyModifiers}; use ratatui::{ buffer::Buffer, layout::{Constraint, Layout, Rect}, @@ -149,7 +149,7 @@ impl TuiEventHandler for PomodoroState { self.get_clock_mut().update_done_count(); } // EDIT mode - TuiEvent::Key(key) if edit_mode => match key.code { + TuiEvent::Crossterm(CrosstermEvent::Key(key)) if edit_mode => match key.code { // Skip changes KeyCode::Esc => { let clock = self.get_clock_mut(); @@ -188,7 +188,7 @@ impl TuiEventHandler for PomodoroState { _ => return Some(event), }, // default mode - TuiEvent::Key(key) => match key.code { + TuiEvent::Crossterm(CrosstermEvent::Key(key)) => match key.code { // Toggle run/pause KeyCode::Char('s') => { self.get_clock_mut().toggle_pause(); diff --git a/src/widgets/timer.rs b/src/widgets/timer.rs index abb48d4..a6e6412 100644 --- a/src/widgets/timer.rs +++ b/src/widgets/timer.rs @@ -4,7 +4,7 @@ use crate::{ utils::center, widgets::clock::{self, ClockState, ClockWidget}, }; -use crossterm::event::KeyModifiers; +use crossterm::event::{Event as CrosstermEvent, KeyModifiers}; use ratatui::{ buffer::Buffer, crossterm::event::KeyCode, @@ -41,7 +41,7 @@ impl TuiEventHandler for TimerState { self.clock.update_done_count(); } // EDIT mode - TuiEvent::Key(key) if edit_mode => match key.code { + TuiEvent::Crossterm(CrosstermEvent::Key(key)) if edit_mode => match key.code { // Skip changes KeyCode::Esc => { // Important: set current value first @@ -78,7 +78,7 @@ impl TuiEventHandler for TimerState { _ => return Some(event), }, // default mode - TuiEvent::Key(key) => match key.code { + TuiEvent::Crossterm(CrosstermEvent::Key(key)) => match key.code { // Toggle run/pause KeyCode::Char('s') => { self.clock.toggle_pause();