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
This commit is contained in:
parent
e11dcaa913
commit
95d914c757
11
Cargo.lock
generated
11
Cargo.lock
generated
@ -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"
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -150,6 +150,14 @@ Extra option (if `--features sound` is enabled by local build only):
|
||||
| <kbd>↓</kbd> | edit to go down |
|
||||
| <kbd>ctrl+↓</kbd> | edit to go down 10x |
|
||||
|
||||
**In `Event` `edit` mode only:**
|
||||
|
||||
| Key | Description |
|
||||
| --- | --- |
|
||||
| <kbd>Enter</kbd> | save changes |
|
||||
| <kbd>Esc</kbd> | skip changes |
|
||||
| <kbd>Tab</kbd> | switch input |
|
||||
|
||||
**In `Pomodoro` screen only:**
|
||||
|
||||
| Key | Description |
|
||||
|
||||
29
src/app.rs
29
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<Position>,
|
||||
}
|
||||
|
||||
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(())
|
||||
}
|
||||
|
||||
@ -209,6 +209,7 @@ pub enum AppEditMode {
|
||||
None,
|
||||
Clock,
|
||||
Time,
|
||||
Event,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
|
||||
@ -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<Position>),
|
||||
}
|
||||
|
||||
pub type AppEventTx = mpsc::UnboundedSender<AppEvent>;
|
||||
@ -89,14 +90,10 @@ fn crossterm_stream() -> Pin<Box<dyn Stream<Item = TuiEvent>>> {
|
||||
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,
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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<String>,
|
||||
@ -27,6 +70,13 @@ pub struct EventState {
|
||||
/// Default value: `None`
|
||||
done_count: Option<u64>,
|
||||
app_tx: AppEventTx,
|
||||
// inputs
|
||||
input_datetime: Input,
|
||||
input_datetime_error: Option<Report>,
|
||||
input_title: Input,
|
||||
input_title_error: Option<Report>,
|
||||
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, Report> {
|
||||
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<TuiEvent> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
)),
|
||||
],
|
||||
}
|
||||
})),
|
||||
]),
|
||||
})),
|
||||
]
|
||||
}),
|
||||
])
|
||||
}
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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();
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user