feat(app): keyboard event handling with panel switching and CRUD
- App struct with full state (lists, tasks, focus, popups, DB) - Tab cycles focus: Tabs -> TaskList -> Detail -> Tabs - Left/Right arrows switch lists when focus on Tabs - Up/Down navigate tasks (TaskList) or scroll (Detail) - Alt+Up/Down reorder tasks with position persistence - n: create new list or task, d: delete, e: edit title - Enter on Detail opens DatePicker popup - InputPopup with full text editing (navigation, insert, delete) - ConfirmDelete popup before destructive actions - DatePicker adjusts draft_date with Up/Down - main.rs: terminal setup, event loop, raw mode, alternate screen
This commit is contained in:
+409
@@ -0,0 +1,409 @@
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use chrono::NaiveDateTime;
|
||||
|
||||
use crate::domain::models::*;
|
||||
use crate::infrastructure::db::Db;
|
||||
use crate::ui::{Focus, NetworkStatus, Popup};
|
||||
|
||||
pub struct App {
|
||||
pub lists: Vec<TaskList>,
|
||||
pub tasks: Vec<Task>,
|
||||
pub selected_list: usize,
|
||||
pub selected_task: usize,
|
||||
pub focus: Focus,
|
||||
pub show_popup: Option<Popup>,
|
||||
pub network_status: NetworkStatus,
|
||||
pub popup_input: String,
|
||||
pub popup_cursor: usize,
|
||||
pub draft_date: NaiveDateTime,
|
||||
pub should_quit: bool,
|
||||
pub task_list_scroll: u16,
|
||||
pub detail_scroll: u16,
|
||||
pub db: Db,
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub fn new(db: Db) -> Self {
|
||||
let lists = db.get_lists();
|
||||
let tasks = if !lists.is_empty() {
|
||||
let list_id = &lists[0].id;
|
||||
db.get_tasks(list_id)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
Self {
|
||||
lists,
|
||||
tasks,
|
||||
selected_list: 0,
|
||||
selected_task: 0,
|
||||
focus: Focus::Tabs,
|
||||
show_popup: None,
|
||||
network_status: NetworkStatus::Online,
|
||||
popup_input: String::new(),
|
||||
popup_cursor: 0,
|
||||
draft_date: chrono::Local::now().naive_local(),
|
||||
should_quit: false,
|
||||
task_list_scroll: 0,
|
||||
detail_scroll: 0,
|
||||
db,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handle_key(&mut self, key: KeyEvent) {
|
||||
if let Some(ref popup) = self.show_popup.clone() {
|
||||
self.handle_popup_key(key, popup);
|
||||
return;
|
||||
}
|
||||
|
||||
match key.code {
|
||||
KeyCode::Tab => {
|
||||
self.focus = match self.focus {
|
||||
Focus::Tabs => Focus::TaskList,
|
||||
Focus::TaskList => Focus::Detail,
|
||||
Focus::Detail => Focus::Tabs,
|
||||
};
|
||||
}
|
||||
KeyCode::Up if key.modifiers.contains(KeyModifiers::ALT) => {
|
||||
if self.focus == Focus::TaskList && !self.tasks.is_empty() {
|
||||
self.reorder_task(-1);
|
||||
}
|
||||
}
|
||||
KeyCode::Down if key.modifiers.contains(KeyModifiers::ALT) => {
|
||||
if self.focus == Focus::TaskList && !self.tasks.is_empty() {
|
||||
self.reorder_task(1);
|
||||
}
|
||||
}
|
||||
KeyCode::Up => match self.focus {
|
||||
Focus::TaskList => {
|
||||
if self.selected_task > 0 {
|
||||
self.selected_task -= 1;
|
||||
self.task_list_scroll = self.task_list_scroll.saturating_sub(1);
|
||||
}
|
||||
}
|
||||
Focus::Detail => {
|
||||
self.detail_scroll = self.detail_scroll.saturating_sub(1);
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
KeyCode::Down => match self.focus {
|
||||
Focus::TaskList => {
|
||||
if self.selected_task + 1 < self.tasks.len() {
|
||||
self.selected_task += 1;
|
||||
self.task_list_scroll += 1;
|
||||
}
|
||||
}
|
||||
Focus::Detail => {
|
||||
self.detail_scroll += 1;
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
KeyCode::Right => {
|
||||
if self.focus == Focus::Tabs && !self.lists.is_empty() {
|
||||
if self.selected_list + 1 < self.lists.len() {
|
||||
self.selected_list += 1;
|
||||
self.load_tasks();
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Left => {
|
||||
if self.focus == Focus::Tabs && !self.lists.is_empty() {
|
||||
if self.selected_list > 0 {
|
||||
self.selected_list -= 1;
|
||||
self.load_tasks();
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Char('n') | KeyCode::Char('N') => {
|
||||
self.popup_input.clear();
|
||||
self.popup_cursor = 0;
|
||||
self.show_popup = Some(Popup::Input);
|
||||
}
|
||||
KeyCode::Char('d') | KeyCode::Char('D') => {
|
||||
self.show_popup = Some(Popup::ConfirmDelete);
|
||||
}
|
||||
KeyCode::Char('e') | KeyCode::Char('E') => {
|
||||
if self.focus == Focus::TaskList && !self.tasks.is_empty() {
|
||||
let task = &self.tasks[self.selected_task];
|
||||
self.popup_input = task.title.clone();
|
||||
self.popup_cursor = task.title.len();
|
||||
self.show_popup = Some(Popup::Input);
|
||||
}
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if self.focus == Focus::Detail && !self.tasks.is_empty() {
|
||||
self.show_popup = Some(Popup::DatePicker);
|
||||
}
|
||||
}
|
||||
KeyCode::Esc => {
|
||||
self.show_popup = None;
|
||||
}
|
||||
KeyCode::Char('q') | KeyCode::Char('Q') => {
|
||||
self.should_quit = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_popup_key(&mut self, key: KeyEvent, popup: &Popup) {
|
||||
match popup {
|
||||
Popup::Input => match key.code {
|
||||
KeyCode::Esc => {
|
||||
self.show_popup = None;
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
let input = self.popup_input.trim().to_string();
|
||||
if !input.is_empty() {
|
||||
match self.focus {
|
||||
Focus::Tabs => {
|
||||
let list = TaskList {
|
||||
id: uuid_v4(),
|
||||
title: input,
|
||||
};
|
||||
self.db.insert_list(&list).ok();
|
||||
self.db.push_sync(
|
||||
SyncAction::Create,
|
||||
&list.id,
|
||||
&list.id,
|
||||
&serde_json::to_string(&list).unwrap_or_default(),
|
||||
).ok();
|
||||
self.load_lists();
|
||||
}
|
||||
Focus::TaskList => {
|
||||
if !self.lists.is_empty() {
|
||||
let list_id = &self.lists[self.selected_list].id;
|
||||
let task = Task {
|
||||
id: uuid_v4(),
|
||||
list_id: list_id.clone(),
|
||||
title: input,
|
||||
notes: None,
|
||||
status: TaskStatus::NeedsAction,
|
||||
due: None,
|
||||
position: 0,
|
||||
};
|
||||
self.db.insert_task(&task).ok();
|
||||
self.db.push_sync(
|
||||
SyncAction::Create,
|
||||
&task.id,
|
||||
list_id,
|
||||
&serde_json::to_string(&task).unwrap_or_default(),
|
||||
).ok();
|
||||
self.load_tasks();
|
||||
}
|
||||
}
|
||||
Focus::Detail => {
|
||||
if !self.tasks.is_empty() {
|
||||
let task = &mut self.tasks[self.selected_task];
|
||||
task.title = input;
|
||||
self.db.update_task(task).ok();
|
||||
self.db.push_sync(
|
||||
SyncAction::Update,
|
||||
&task.id,
|
||||
&task.list_id,
|
||||
&serde_json::to_string(task).unwrap_or_default(),
|
||||
).ok();
|
||||
self.load_tasks();
|
||||
if !self.tasks.is_empty() && self.selected_task < self.tasks.len() {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.show_popup = None;
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
self.popup_input.insert(self.popup_cursor, c);
|
||||
self.popup_cursor += 1;
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
if self.popup_cursor > 0 {
|
||||
self.popup_cursor -= 1;
|
||||
self.popup_input.remove(self.popup_cursor);
|
||||
}
|
||||
}
|
||||
KeyCode::Delete => {
|
||||
if self.popup_cursor < self.popup_input.len() {
|
||||
self.popup_input.remove(self.popup_cursor);
|
||||
}
|
||||
}
|
||||
KeyCode::Left => {
|
||||
self.popup_cursor = self.popup_cursor.saturating_sub(1);
|
||||
}
|
||||
KeyCode::Right => {
|
||||
if self.popup_cursor < self.popup_input.len() {
|
||||
self.popup_cursor += 1;
|
||||
}
|
||||
}
|
||||
KeyCode::Home => {
|
||||
self.popup_cursor = 0;
|
||||
}
|
||||
KeyCode::End => {
|
||||
self.popup_cursor = self.popup_input.len();
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
Popup::DatePicker => match key.code {
|
||||
KeyCode::Esc => {
|
||||
self.show_popup = None;
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if !self.tasks.is_empty() {
|
||||
let task = &mut self.tasks[self.selected_task];
|
||||
task.due = Some(self.draft_date);
|
||||
self.db.update_task(task).ok();
|
||||
self.db.push_sync(
|
||||
SyncAction::Update,
|
||||
&task.id,
|
||||
&task.list_id,
|
||||
&serde_json::to_string(task).unwrap_or_default(),
|
||||
).ok();
|
||||
self.load_tasks();
|
||||
}
|
||||
self.show_popup = None;
|
||||
}
|
||||
KeyCode::Up => {
|
||||
self.draft_date = self.draft_date + chrono::Duration::hours(1);
|
||||
}
|
||||
KeyCode::Down => {
|
||||
self.draft_date = self.draft_date - chrono::Duration::hours(1);
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
Popup::ConfirmDelete => match key.code {
|
||||
KeyCode::Esc => {
|
||||
self.show_popup = None;
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
match self.focus {
|
||||
Focus::Tabs => {
|
||||
if self.selected_list < self.lists.len() {
|
||||
let list_id = self.lists[self.selected_list].id.clone();
|
||||
self.db.delete_list(&list_id).ok();
|
||||
self.db.push_sync(
|
||||
SyncAction::Delete,
|
||||
&list_id,
|
||||
&list_id,
|
||||
"",
|
||||
).ok();
|
||||
self.load_lists();
|
||||
if self.selected_list >= self.lists.len() {
|
||||
self.selected_list = self.lists.len().saturating_sub(1);
|
||||
}
|
||||
self.load_tasks();
|
||||
}
|
||||
}
|
||||
Focus::TaskList | Focus::Detail => {
|
||||
if !self.tasks.is_empty() && self.selected_task < self.tasks.len() {
|
||||
let task = &self.tasks[self.selected_task];
|
||||
let task_id = task.id.clone();
|
||||
let list_id = task.list_id.clone();
|
||||
self.db.delete_task(&task_id).ok();
|
||||
self.db.push_sync(
|
||||
SyncAction::Delete,
|
||||
&task_id,
|
||||
&list_id,
|
||||
"",
|
||||
).ok();
|
||||
self.load_tasks();
|
||||
if self.selected_task >= self.tasks.len() {
|
||||
self.selected_task = self.tasks.len().saturating_sub(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.show_popup = None;
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
Popup::DeviceAuth { .. } => match key.code {
|
||||
KeyCode::Enter | KeyCode::Esc => {
|
||||
self.show_popup = None;
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn reorder_task(&mut self, direction: i64) {
|
||||
if self.tasks.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let new_index = self.selected_task as i64 + direction;
|
||||
if new_index < 0 || new_index >= self.tasks.len() as i64 {
|
||||
return;
|
||||
}
|
||||
|
||||
let task_id = self.tasks[self.selected_task].id.clone();
|
||||
let list_id = self.tasks[self.selected_task].list_id.clone();
|
||||
|
||||
let new_pos = if direction > 0 {
|
||||
self.tasks[new_index as usize].position
|
||||
} else {
|
||||
self.tasks[new_index as usize].position
|
||||
};
|
||||
|
||||
if self.db.reorder_task(&task_id, new_pos).is_ok() {
|
||||
let payload = serde_json::json!({
|
||||
"task_id": task_id,
|
||||
"new_position": new_pos
|
||||
});
|
||||
self.db.push_sync(
|
||||
SyncAction::Reorder,
|
||||
&task_id,
|
||||
&list_id,
|
||||
&payload.to_string(),
|
||||
).ok();
|
||||
self.selected_task = new_index as usize;
|
||||
self.load_tasks();
|
||||
}
|
||||
}
|
||||
|
||||
fn load_lists(&mut self) {
|
||||
self.lists = self.db.get_lists();
|
||||
}
|
||||
|
||||
fn load_tasks(&mut self) {
|
||||
if self.selected_list < self.lists.len() {
|
||||
self.tasks = self.db.get_tasks(&self.lists[self.selected_list].id);
|
||||
} else {
|
||||
self.tasks.clear();
|
||||
}
|
||||
if self.selected_task >= self.tasks.len() && !self.tasks.is_empty() {
|
||||
self.selected_task = self.tasks.len() - 1;
|
||||
} else if self.tasks.is_empty() {
|
||||
self.selected_task = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn uuid_v4() -> String {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default();
|
||||
let nanos = now.as_nanos();
|
||||
format!(
|
||||
"{:08x}-{:04x}-4{:03x}-{:04x}-{:012x}",
|
||||
(nanos >> 32) as u32,
|
||||
(nanos >> 16) as u16 & 0xffff,
|
||||
(nanos >> 4) as u16 & 0xfff,
|
||||
(nanos >> 48) as u16 & 0xffff,
|
||||
(nanos & 0xfffffffffffff) as u64
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_uuid_v4_format() {
|
||||
let id = uuid_v4();
|
||||
assert_eq!(id.len(), 36);
|
||||
assert_eq!(&id[8..9], "-");
|
||||
assert_eq!(&id[13..14], "-");
|
||||
assert_eq!(&id[18..19], "-");
|
||||
assert_eq!(&id[23..24], "-");
|
||||
}
|
||||
}
|
||||
+59
-3
@@ -1,7 +1,63 @@
|
||||
mod app;
|
||||
mod domain;
|
||||
mod ui;
|
||||
mod infrastructure;
|
||||
mod ui;
|
||||
|
||||
fn main() {
|
||||
println!("Task App - Google Tasks TUI");
|
||||
use std::io;
|
||||
|
||||
use crossterm::event::{self, Event};
|
||||
use crossterm::terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen};
|
||||
use crossterm::ExecutableCommand;
|
||||
use ratatui::backend::CrosstermBackend;
|
||||
use ratatui::Terminal;
|
||||
|
||||
use crate::app::App;
|
||||
use crate::infrastructure::db::Db;
|
||||
use crate::ui::{draw, AppView};
|
||||
|
||||
fn main() -> io::Result<()> {
|
||||
let db_path = dirs::data_dir()
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("."))
|
||||
.join("task_app")
|
||||
.join("tasks.db");
|
||||
|
||||
std::fs::create_dir_all(db_path.parent().unwrap()).ok();
|
||||
|
||||
let db = Db::new(db_path.to_str().unwrap()).expect("Failed to open database");
|
||||
|
||||
enable_raw_mode()?;
|
||||
let mut stdout = io::stdout();
|
||||
stdout.execute(EnterAlternateScreen)?;
|
||||
let backend = CrosstermBackend::new(stdout);
|
||||
let mut terminal = Terminal::new(backend)?;
|
||||
|
||||
let mut app = App::new(db);
|
||||
|
||||
while !app.should_quit {
|
||||
terminal.draw(|frame| {
|
||||
let view = AppView {
|
||||
lists: &app.lists,
|
||||
tasks: &app.tasks,
|
||||
selected_list: app.selected_list,
|
||||
selected_task: app.selected_task,
|
||||
focus: app.focus.clone(),
|
||||
show_popup: app.show_popup.as_ref(),
|
||||
popup_input: &app.popup_input,
|
||||
popup_cursor: app.popup_cursor,
|
||||
draft_date: app.draft_date,
|
||||
network_status: &app.network_status,
|
||||
task_list_scroll: app.task_list_scroll,
|
||||
detail_scroll: app.detail_scroll,
|
||||
};
|
||||
draw(frame, view);
|
||||
})?;
|
||||
|
||||
if let Event::Key(key) = event::read()? {
|
||||
app.handle_key(key);
|
||||
}
|
||||
}
|
||||
|
||||
disable_raw_mode()?;
|
||||
io::stdout().execute(LeaveAlternateScreen)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user