use crate::config::Config;
use crate::model::parser::{extract_inline_aliases, validate_alias_integrity};
use crate::model::{AppIntent, Task, TaskStatus};
use crate::storage::LOCAL_CALENDAR_HREF;
use crate::system::SystemEvent;
use crate::tui::action::{Action, AppEvent, SidebarMode};
use crate::tui::state::{AppState, Focus, InputMode};
use chrono::NaiveTime;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use std::collections::HashMap;
use tokio::sync::mpsc::Sender;
use crate::store::{TaskListItem, select_weighted_random_index};
use rust_i18n::t;
fn get_available_actions(state: &AppState, task: &Task) -> Vec<crate::config::TaskAction> {
use crate::config::TaskAction;
let mut actions = Vec::new();
let is_done_or_cancelled =
task.status.is_done() || task.status == crate::model::TaskStatus::Cancelled;
let is_paused = task.is_paused();
let has_relationships = !task.dependencies.is_empty()
|| !task.related_to.is_empty()
|| task.has_blocking_tasks
|| task.has_related_tasks
|| task.parent_uid.is_some();
for &action in TaskAction::ALL {
let available = match action {
TaskAction::Move => {
let count = state
.calendars
.iter()
.filter(|c| !state.disabled_calendars.contains(&c.href))
.count();
count > 1
}
TaskAction::OpenUrl => task.url.is_some(),
TaskAction::DeleteTree => task.has_subtasks,
TaskAction::OpenCoordinates => task.geo.is_some(),
TaskAction::OpenLocations => state.store.count_tree_locations(&task.uid) > 1,
TaskAction::ToggleDetails => has_relationships,
TaskAction::CompleteAndShift => {
task.rrule.is_some() && !is_done_or_cancelled && !task.is_relative_recurrence()
}
TaskAction::ExtractSubtasks => task.has_extractable_subtasks(),
TaskAction::TogglePin => true,
TaskAction::Promote => task.parent_uid.is_some(),
TaskAction::Yank => state.yanked_uid.is_none(),
TaskAction::StopTimer => {
task.status == crate::model::TaskStatus::InProcess || is_paused
}
TaskAction::ToggleTimer | TaskAction::AddSession | TaskAction::Cancel => {
!is_done_or_cancelled
}
_ => true,
};
if available {
actions.push(action);
}
}
actions
}
fn update_action_menu_filter(state: &mut AppState) {
let filter = state.action_filter.to_lowercase();
state.action_menu_items = state
.available_actions
.iter()
.copied()
.filter(|a| {
if filter.is_empty() {
return true;
}
let label = a.label().to_lowercase();
use crate::config::TaskAction::*;
let matches_alias = match a {
ToggleDetails => filter == "l" || filter == "rel",
CompleteAndShift => filter == "r" || filter == "rep" || filter == "repeat",
ToggleTimer => filter == "s" || filter == "start" || filter == "pause",
StopTimer => filter == "stop",
AddSession => filter == "t" || filter == "log",
IncreasePriority => filter == "+" || filter == "up",
DecreasePriority => filter == "-" || filter == "down",
Edit => filter == "e",
Yank => filter == "y" || filter == "copy",
ExtractSubtasks => filter == "extract" || filter == "parse",
TogglePin => filter == "p" || filter == "pin",
CreateSubtask => filter == "c" || filter == "sub",
DuplicateTree => filter == "d" || filter == "dup",
Promote => filter == "<" || filter == "outdent",
Move => filter == "m",
Cancel => filter == "x",
Delete | DeleteTree => filter == "del" || filter == "rm",
OpenUrl => filter == "o" || filter == "url" || filter == "link",
OpenCoordinates | OpenLocations => filter == "g" || filter == "map",
};
label.contains(&filter) || matches_alias
})
.collect();
state.action_selection_state.select(Some(0));
}
fn open_action_menu(state: &mut AppState) {
if let Some(task) = state.get_selected_task() {
let actions = get_available_actions(state, task);
if !actions.is_empty() {
state.available_actions = actions.clone();
state.action_menu_items = actions;
state.action_selection_state.select(Some(0));
state.action_filter.clear();
state.mode = InputMode::ActionMenu;
state.message = format!(" {} ", rust_i18n::t!("actions"));
}
}
}
async fn execute_task_action(
state: &mut AppState,
action: crate::config::TaskAction,
task: &Task,
action_tx: &Sender<Action>,
) {
use crate::config::TaskAction::*;
let uid = task.uid.clone();
let config = Config::load(state.ctx.as_ref()).unwrap_or_default();
let mut intent = None;
match action {
OpenUrl => {
if let Some(url) = &task.url {
#[cfg(not(target_os = "android"))]
{
let target_url = url.clone();
std::thread::spawn(move || {
#[cfg(target_os = "linux")]
let _ = std::process::Command::new("xdg-open")
.arg(target_url)
.spawn();
#[cfg(target_os = "windows")]
let _ = std::process::Command::new("explorer")
.arg(target_url)
.spawn();
#[cfg(target_os = "macos")]
let _ = std::process::Command::new("open").arg(target_url).spawn();
});
}
state.message = rust_i18n::t!("open_url").to_string();
}
}
OpenCoordinates => {
if let Some(geo) = &task.geo {
#[cfg(not(target_os = "android"))]
{
let target_url = format!("geo:{}", geo);
std::thread::spawn(move || {
#[cfg(target_os = "linux")]
let _ = std::process::Command::new("xdg-open")
.arg(target_url)
.spawn();
#[cfg(target_os = "windows")]
let _ = std::process::Command::new("explorer")
.arg(target_url)
.spawn();
#[cfg(target_os = "macos")]
let _ = std::process::Command::new("open").arg(target_url).spawn();
});
}
state.message = rust_i18n::t!("open_coordinates").to_string();
}
}
OpenLocations => {
let waypoints = state.store.get_tree_waypoints(&uid);
let mut gpx_string = String::from(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<gpx version=\"1.1\" creator=\"Cfait\" xmlns=\"http://www.topografix.com/GPX/1/1\">\n",
);
for (name, geo) in waypoints {
let parts: Vec<&str> = geo.split(',').collect();
if parts.len() >= 2 {
let escaped_name = name
.replace('&', "&")
.replace('<', "<")
.replace('>', ">");
gpx_string.push_str(&format!(
" <wpt lat=\"{}\" lon=\"{}\"><name>{}</name></wpt>\n",
parts[0].trim(),
parts[1].trim(),
escaped_name
));
}
}
gpx_string.push_str("</gpx>");
if let Ok(cache_dir) = state.ctx.get_cache_dir() {
let path = cache_dir.join(format!("locations_{}.gpx", uuid::Uuid::new_v4()));
if std::fs::write(&path, gpx_string).is_ok() {
#[cfg(not(target_os = "android"))]
{
let target = path.to_string_lossy().to_string();
std::thread::spawn(move || {
#[cfg(target_os = "linux")]
let _ = std::process::Command::new("xdg-open").arg(target).spawn();
#[cfg(target_os = "windows")]
let _ = std::process::Command::new("explorer").arg(target).spawn();
#[cfg(target_os = "macos")]
let _ = std::process::Command::new("open").arg(target).spawn();
});
}
state.message = rust_i18n::t!("action_open_locations").to_string();
} else {
state.message = rust_i18n::t!("error_write_gpx").to_string();
}
}
}
ToggleDetails => {
let mut items = Vec::new();
if let Some(p_uid) = &task.parent_uid {
let name = state
.store
.get_summary(p_uid)
.unwrap_or_else(|| "Unknown task".to_string());
items.push((
p_uid.clone(),
format!("↑ [Parent] {}", name),
"parent".to_string(),
));
}
for dep_uid in &task.dependencies {
let name = state
.store
.get_summary(dep_uid)
.unwrap_or_else(|| "Unknown task".to_string());
let is_done = state.store.is_task_done(dep_uid).unwrap_or(false);
let check = if is_done { "[x]" } else { "[ ]" };
items.push((
dep_uid.clone(),
format!("⬆ [Blocked by] {} {}", check, name),
"dependency".to_string(),
));
}
for related_uid in &task.related_to {
let name = state
.store
.get_summary(related_uid)
.unwrap_or_else(|| "Unknown task".to_string());
items.push((
related_uid.clone(),
format!("→ [Related to] {}", name),
"related_to".to_string(),
));
}
let incoming_related = state.store.get_tasks_related_to(&task.uid);
for (related_uid, related_name) in incoming_related {
items.push((
related_uid.clone(),
format!("← [Related from] {}", related_name),
"related_from".to_string(),
));
}
let blocking_tasks = state.store.get_tasks_blocking(&task.uid);
for (blocking_uid, blocking_name) in blocking_tasks {
items.push((
blocking_uid.clone(),
format!("⬇ [Blocking] {}", blocking_name),
"blocking".to_string(),
));
}
if !items.is_empty() {
state.relationship_items = items;
state.relationship_selection_state.select(Some(0));
state.mode = InputMode::RelationshipBrowsing;
state.message =
format!("{} (Del/x: Remove)", rust_i18n::t!("tui_select_task_jump"));
} else {
state.message = rust_i18n::t!("error_no_related_tasks").to_string();
}
}
CompleteAndShift => {
intent = Some(AppIntent::ToggleTaskShift { uid });
}
ToggleTimer => {
if task.status == crate::model::TaskStatus::InProcess {
intent = Some(AppIntent::PauseTask { uid });
} else {
intent = Some(AppIntent::StartTask { uid });
}
}
StopTimer => {
intent = Some(AppIntent::StopTask { uid });
}
AddSession => {
state.mode = InputMode::AddingSession;
state.reset_input();
state.message = format!(
"{} ({} 30m, yesterday 1h):",
rust_i18n::t!("tui_log_time_prompt", name = task.summary.clone()),
rust_i18n::t!("eg")
);
}
IncreasePriority => {
intent = Some(AppIntent::ChangePriority { uid, delta: 1 });
}
DecreasePriority => {
intent = Some(AppIntent::ChangePriority { uid, delta: -1 });
}
Edit => {
let smart_string = task.to_smart_string();
state.input_buffer = smart_string;
state.cursor_position = state.input_buffer.chars().count();
state.editing_uid = Some(uid);
state.mode = InputMode::Editing;
}
Yank => {
let summary = task.summary.clone();
let text = if task.description.is_empty() {
task.to_smart_string()
} else {
format!("{}\n\n{}", task.to_smart_string(), task.description)
};
state.yanked_uid = Some(uid);
use base64::Engine;
let b64 = base64::engine::general_purpose::STANDARD.encode(text);
print!("\x1b]52;c;{}\x07", b64);
use std::io::Write;
let _ = std::io::stdout().flush();
state.message = rust_i18n::t!("yanked_and_copied", summary = summary).to_string();
}
TogglePin => {
intent = Some(AppIntent::TogglePin { uid });
}
ExtractSubtasks => {
if let Some((parent, _)) = state.store.get_task_mut(&uid) {
let desc_text = parent.description.clone();
let (clean_desc, extracted) =
crate::model::extractor::extract_markdown_tasks(&desc_text);
if !extracted.is_empty() {
parent.description = clean_desc;
parent.sequence += 1;
let parent_copy = parent.clone();
let target_href = parent_copy.calendar_href.clone();
let config = Config::load(state.ctx.as_ref()).unwrap_or_default();
let def_time =
chrono::NaiveTime::parse_from_str(&config.default_reminder_time, "%H:%M")
.ok();
let mut actions = vec![crate::journal::Action::Update(parent_copy)];
for ext in extracted {
let mut sub = Task::new(&ext.raw_text, &state.tag_aliases, def_time);
sub.uid = ext.uid;
if !ext.description.is_empty() {
if sub.description.is_empty() {
sub.description = ext.description;
} else {
sub.description
.push_str(&format!("\n\n{}", ext.description));
}
}
if ext.is_completed {
sub.status = crate::model::TaskStatus::Completed;
sub.set_completion_date(Some(chrono::Utc::now()));
}
sub.parent_uid = Some(ext.parent_uid.unwrap_or(uid.clone()));
sub.dependencies = ext.dependencies;
sub.calendar_href = target_href.clone();
state.store.add_task(sub.clone());
actions.push(crate::journal::Action::Create(sub));
}
state.refresh_filtered_view();
tokio::spawn({
let tx = action_tx.clone();
async move {
let _ = tx.send(Action::PersistBatch(actions)).await;
}
});
}
}
}
CreateSubtask => {
let mut initial_input = String::new();
for cat in &task.categories {
initial_input.push_str(&format!("#{} ", crate::model::parser::quote_value(cat)));
}
if let Some(loc) = &task.location {
initial_input.push_str(&format!("@@{} ", crate::model::parser::quote_value(loc)));
}
state.input_buffer = initial_input;
state.cursor_position = state.input_buffer.chars().count();
state.mode = InputMode::Creating;
state.creating_with_desc = false;
state.new_task_title.clear();
state.creating_child_of = Some(uid);
state.message = rust_i18n::t!("new_child_of", name = task.summary.clone()).to_string();
}
DuplicateTree => {
intent = Some(AppIntent::DuplicateTaskTree { uid });
}
Promote => {
intent = Some(AppIntent::RemoveParent { uid });
}
Move => {
let current_href = task.calendar_href.clone();
state.move_targets = state
.calendars
.iter()
.filter(|c| {
c.href != current_href
&& !state.disabled_calendars.contains(&c.href)
&& c.href != crate::storage::LOCAL_TRASH_HREF
&& c.href != "local://recovery"
})
.cloned()
.collect();
if !state.move_targets.is_empty() {
state.move_selection_state.select(Some(0));
state.mode = InputMode::Moving;
state.message = rust_i18n::t!("tui_select_calendar_prompt").to_string();
}
}
Cancel => {
intent = Some(AppIntent::CancelTask { uid });
}
Delete => {
intent = Some(AppIntent::DeleteTask { uid });
}
DeleteTree => {
intent = Some(AppIntent::DeleteTaskTree { uid });
}
}
if let Some(i) = intent {
let actions = state.store.apply_task_intent(&i, &config);
state.refresh_filtered_view();
if !actions.is_empty() {
let tx = action_tx.clone();
tokio::spawn(async move {
let _ = tx.send(Action::PersistBatch(actions)).await;
});
}
}
}
fn run_external_editor(
initial_content: &str,
ctx: &dyn crate::context::AppContext,
) -> Result<Option<String>, String> {
let config = Config::load(ctx).unwrap_or_default();
let mut editor_cmd = None;
if config.description_editor == "builtin" {
return Ok(None); } else if !config.description_editor.is_empty() {
editor_cmd = Some(config.description_editor.clone());
} else {
if let Ok(v) = std::env::var("VISUAL")
&& !v.is_empty()
{
editor_cmd = Some(v);
}
if editor_cmd.is_none()
&& let Ok(e) = std::env::var("EDITOR")
&& !e.is_empty()
{
editor_cmd = Some(e);
}
}
if let Some(cmd) = editor_cmd {
let uuid = uuid::Uuid::new_v4();
let path = std::env::temp_dir().join(format!("cfait_desc_{}.md", uuid));
if std::fs::write(&path, initial_content.as_bytes()).is_ok() {
let _ =
crossterm::execute!(std::io::stdout(), crossterm::terminal::LeaveAlternateScreen);
let _ = crossterm::terminal::disable_raw_mode();
#[cfg(target_os = "windows")]
let status = std::process::Command::new("cmd")
.arg("/C")
.arg(format!("{} \"{}\"", cmd, path.display()))
.status();
#[cfg(not(target_os = "windows"))]
let status = std::process::Command::new("sh")
.arg("-c")
.arg(format!("{} \"{}\"", cmd, path.display()))
.status();
let _ = crossterm::terminal::enable_raw_mode();
let _ = crossterm::execute!(
std::io::stdout(),
crossterm::terminal::EnterAlternateScreen,
crossterm::event::EnableMouseCapture,
crossterm::terminal::Clear(crossterm::terminal::ClearType::All)
);
let success = status.map(|s| s.success()).unwrap_or(false);
if success {
if let Ok(new_desc) = std::fs::read_to_string(&path) {
let _ = std::fs::remove_file(&path);
return Ok(Some(new_desc.trim_end().to_string()));
}
} else {
let _ = std::fs::remove_file(&path);
return Err(format!(
"Failed to run editor '{}', falling back to builtin.",
cmd
));
}
} else {
return Err("Failed to create temp file for external editor.".to_string());
}
}
Ok(None)
}
fn save_description(state: &mut AppState, action_tx: &Sender<Action>) {
if state.creating_with_desc {
let desc_text = state.input_buffer.clone();
let (clean_desc, extracted) = crate::model::extractor::extract_markdown_tasks(&desc_text);
let (clean_input, new_aliases) =
crate::model::parser::extract_inline_aliases(&state.new_task_title);
if !new_aliases.is_empty() {
for (k, v) in &new_aliases {
state.tag_aliases.insert(k.clone(), v.clone());
}
}
let config = Config::load(state.ctx.as_ref()).unwrap_or_default();
let def_time =
chrono::NaiveTime::parse_from_str(&config.default_reminder_time, "%H:%M").ok();
let mut parent = Task::new(&clean_input, &state.tag_aliases, def_time);
if !clean_desc.is_empty() {
if parent.description.is_empty() {
parent.description = clean_desc;
} else {
parent.description.push_str(&format!("\n\n{}", clean_desc));
}
}
parent.parent_uid = state.creating_child_of.clone();
let Some(target_href) = state
.active_cal_href
.clone()
.filter(|href| state.local_mode_enabled || !href.starts_with("local://"))
.or_else(|| {
state
.get_filtered_calendars()
.first()
.map(|c| c.href.clone())
})
else {
state.message = if state.local_mode_enabled {
rust_i18n::t!("error_no_calendar_available").to_string()
} else {
rust_i18n::t!("error_no_remote_calendar").to_string()
};
state.creating_with_desc = false;
state.new_task_title.clear();
state.mode = InputMode::Normal;
state.reset_input();
state.creating_child_of = None;
return;
};
parent.calendar_href = target_href.clone();
let parent_uid = parent.uid.clone();
state.store.add_task(parent.clone());
tokio::spawn({
let tx = action_tx.clone();
async move {
let _ = tx
.send(Action::PersistBatch(vec![crate::journal::Action::Create(
parent,
)]))
.await;
}
});
for ext in extracted {
let mut sub = Task::new(&ext.raw_text, &state.tag_aliases, def_time);
sub.uid = ext.uid;
if !ext.description.is_empty() {
if sub.description.is_empty() {
sub.description = ext.description;
} else {
sub.description
.push_str(&format!("\n\n{}", ext.description));
}
}
if ext.is_completed {
sub.status = crate::model::TaskStatus::Completed;
sub.set_completion_date(Some(chrono::Utc::now()));
}
sub.parent_uid = Some(ext.parent_uid.unwrap_or(parent_uid.clone()));
sub.dependencies = ext.dependencies;
sub.calendar_href = target_href.clone();
state.store.add_task(sub.clone());
tokio::spawn({
let tx = action_tx.clone();
async move {
let _ = tx
.send(Action::PersistBatch(vec![crate::journal::Action::Create(
sub,
)]))
.await;
}
});
}
state.refresh_filtered_view();
update_alarms(state);
if let Some(idx) = state.find_task_index_by_uid(&parent_uid) {
state.list_state.select(Some(idx));
}
state.creating_with_desc = false;
state.new_task_title.clear();
state.mode = InputMode::Normal;
state.reset_input();
state.creating_child_of = None;
} else {
let target_uid: Option<String> = state.editing_uid.clone();
if let Some(uid) = target_uid
&& let Some((t, _)) = state.store.get_task_mut(&uid)
{
t.description = state.input_buffer.clone();
t.sequence += 1;
let clone = t.clone();
state.refresh_filtered_view();
state.mode = InputMode::Normal;
state.reset_input();
let _ = action_tx.try_send(Action::PersistBatch(vec![crate::journal::Action::Update(
clone,
)]));
}
state.mode = InputMode::Normal;
state.reset_input();
}
}
pub fn handle_app_event(state: &mut AppState, event: AppEvent, default_cal: &Option<String>) {
state.unsynced_changes = !crate::journal::Journal::load(state.ctx.as_ref()).is_empty();
match event {
AppEvent::Status { key: _, human } => state.message = human,
AppEvent::Error(s) => {
state.message = format!("Error: {}", s);
state.loading = false;
}
AppEvent::CalendarsLoaded(mut cals) => {
if !state.local_mode_enabled {
cals.retain(|c| !c.href.starts_with("local://"));
}
cals.sort_by_key(|c| {
if c.href == "local://recovery" {
1
} else if c.href == crate::storage::LOCAL_TRASH_HREF {
2
} else {
0
}
});
state.calendars = cals;
if let Some(def) = default_cal
&& let Some(found) = state
.calendars
.iter()
.find(|c| c.name == *def || c.href == *def)
{
if state.hidden_calendars.contains(&found.href) {
state.hidden_calendars.remove(&found.href);
}
state.active_cal_href = Some(found.href.clone());
}
if state.active_cal_href.is_none() {
state.active_cal_href = state
.calendars
.iter()
.find(|c| !state.disabled_calendars.contains(&c.href))
.map(|c| c.href.clone())
.or_else(|| {
if state.local_mode_enabled {
Some(LOCAL_CALENDAR_HREF.to_string())
} else {
None
}
});
}
state.refresh_filtered_view();
}
AppEvent::TasksLoaded(results) => {
for (href, tasks) in results {
if !state.local_mode_enabled && href.starts_with("local://") {
continue;
}
state.store.insert(href, tasks);
}
state.refresh_filtered_view();
state.loading = false;
}
AppEvent::TaskSynced {
uid,
href,
etag,
sequence,
} => {
if let Some((existing, _)) = state.store.get_task_mut(&uid) {
existing.href = href;
existing.etag = etag;
if sequence > existing.sequence {
existing.sequence = sequence;
}
}
}
AppEvent::ConfigUpdated(cfg) => {
state.tag_aliases = cfg.tag_aliases.clone();
state.hide_completed = cfg.hide_completed;
state.hide_fully_completed_tags = cfg.hide_fully_completed_tags;
state.hide_aliases_in_sidebar = cfg.hide_aliases_in_sidebar;
state.sort_cutoff_months = cfg.sort_cutoff_months;
state.sort_standard_by_priority = cfg.sort_standard_by_priority;
state.urgent_days = cfg.urgent_days_horizon;
state.urgent_prio = cfg.urgent_priority_threshold;
state.default_priority = cfg.default_priority;
state.start_grace_period_days = cfg.start_grace_period_days;
state.snooze_short_mins = cfg.snooze_short_mins;
state.snooze_long_mins = cfg.snooze_long_mins;
state.show_priority_numbers = cfg.show_priority_numbers;
state.quick_filter_term = cfg.quick_filter_term.clone();
state.quick_filter_icon = cfg.quick_filter_icon.clone();
state.show_quick_filter = cfg.show_quick_filter;
state.theme = cfg.theme;
state.refresh_filtered_view();
}
}
if let Ok(cfg) = Config::load(state.ctx.as_ref()) {
state.show_priority_numbers = cfg.show_priority_numbers;
state.quick_filter_term = cfg.quick_filter_term.clone();
state.quick_filter_icon = cfg.quick_filter_icon.clone();
state.show_quick_filter = cfg.show_quick_filter;
}
}
fn update_alarms(state: &AppState) {
if let Some(tx) = &state.alarm_actor_tx {
let all = state
.store
.calendars
.values()
.flat_map(|m| m.values())
.cloned()
.collect();
let _ = tx.try_send(SystemEvent::UpdateTasks(all));
}
}
pub async fn handle_key_event(
key: KeyEvent,
state: &mut AppState,
action_tx: &Sender<Action>,
) -> Option<Action> {
if let Some((task, alarm_uid)) = state.active_alarm.clone() {
if state.mode == InputMode::Snoozing {
} else {
match key.code {
KeyCode::Delete | KeyCode::Char('D') | KeyCode::Char('d') => {
if let Some((t, _)) = state.store.get_task_mut(&task.uid)
&& t.handle_dismiss(&alarm_uid)
{
let uid = t.uid.clone();
state.active_alarm = None;
state.refresh_filtered_view();
let config = Config::load(state.ctx.as_ref()).unwrap_or_default();
let intent = AppIntent::ToggleTask { uid: uid.clone() };
let actions = state.store.apply_task_intent(&intent, &config);
update_alarms(state);
let tx = action_tx.clone();
tokio::spawn(async move {
let _ = tx.send(Action::PersistBatch(actions)).await;
});
}
return None;
}
KeyCode::Char('1') => {
if let Some((t, _)) = state.store.get_task_mut(&task.uid)
&& t.handle_snooze(&alarm_uid, state.snooze_short_mins)
{
let uid = t.uid.clone();
state.active_alarm = None;
state.refresh_filtered_view();
let config = Config::load(state.ctx.as_ref()).unwrap_or_default();
let intent = AppIntent::ToggleTask { uid: uid.clone() };
let actions = state.store.apply_task_intent(&intent, &config);
update_alarms(state);
let tx = action_tx.clone();
tokio::spawn(async move {
let _ = tx.send(Action::PersistBatch(actions)).await;
});
}
return None;
}
KeyCode::Char('2') => {
if let Some((t, _)) = state.store.get_task_mut(&task.uid)
&& t.handle_snooze(&alarm_uid, state.snooze_long_mins)
{
let uid = t.uid.clone();
state.active_alarm = None;
state.refresh_filtered_view();
let config = Config::load(state.ctx.as_ref()).unwrap_or_default();
let intent = AppIntent::ToggleTask { uid: uid.clone() };
let actions = state.store.apply_task_intent(&intent, &config);
update_alarms(state);
let tx = action_tx.clone();
tokio::spawn(async move {
let _ = tx.send(Action::PersistBatch(actions)).await;
});
}
return None;
}
KeyCode::Char('c') => {
if let Some((t, _)) = state.store.get_task_mut(&task.uid) {
t.dismiss_alarm(&alarm_uid);
let uid = t.uid.clone();
state.active_alarm = None;
let config = Config::load(state.ctx.as_ref()).unwrap_or_default();
let intent = AppIntent::ToggleTask { uid: uid.clone() };
let actions = state.store.apply_task_intent(&intent, &config);
state.refresh_filtered_view();
update_alarms(state);
let tx = action_tx.clone();
tokio::spawn(async move {
let _ = tx.send(Action::PersistBatch(actions)).await;
});
}
return None;
}
KeyCode::Char('x') => {
if let Some((t, _)) = state.store.get_task_mut(&task.uid) {
t.dismiss_alarm(&alarm_uid);
let uid = t.uid.clone();
state.active_alarm = None;
let config = Config::load(state.ctx.as_ref()).unwrap_or_default();
let intent = AppIntent::CancelTask { uid: uid.clone() };
let actions = state.store.apply_task_intent(&intent, &config);
state.refresh_filtered_view();
update_alarms(state);
let tx = action_tx.clone();
tokio::spawn(async move {
let _ = tx.send(Action::PersistBatch(actions)).await;
});
}
return None;
}
KeyCode::Char('S') | KeyCode::Char('s') => {
state.mode = InputMode::Snoozing;
state.reset_input();
return None;
}
_ => return None, }
}
}
let char_count = state.input_buffer.chars().count();
if state.cursor_position > char_count {
state.cursor_position = char_count;
}
match state.mode {
InputMode::Creating => match key.code {
KeyCode::Char('e') | KeyCode::Char('E')
if key.modifiers.contains(KeyModifiers::CONTROL) =>
{
state.new_task_title = state.input_buffer.clone();
state.input_buffer.clear();
state.cursor_position = 0;
state.mode = InputMode::EditingDescription;
state.creating_with_desc = true;
state.message = rust_i18n::t!("edit_description_instructions").to_string();
return None;
}
KeyCode::Enter if !state.input_buffer.is_empty() => {
if state.creating_with_desc {
state.new_task_title = state.input_buffer.clone();
state.input_buffer.clear();
state.cursor_position = 0;
match run_external_editor("", state.ctx.as_ref()) {
Ok(Some(new_desc)) => {
state.input_buffer = new_desc;
save_description(state, action_tx);
state.needs_redraw = true;
return None;
}
Ok(None) => {
state.mode = InputMode::EditingDescription;
state.message =
rust_i18n::t!("edit_description_instructions").to_string();
return None;
}
Err(e) => {
state.message = e;
state.mode = InputMode::EditingDescription;
state.needs_redraw = true;
return None;
}
}
}
let (clean_input, new_aliases): (String, HashMap<String, Vec<String>>) =
extract_inline_aliases(&state.input_buffer);
if !new_aliases.is_empty() {
for (key, tags) in new_aliases {
if let Err(e) = validate_alias_integrity(&key, &tags, &state.tag_aliases) {
state.message =
rust_i18n::t!("error_general", error = e.to_string()).to_string();
return None;
}
state.tag_aliases.insert(key.clone(), tags.clone());
let modified = state.store.apply_alias_retroactively(&key, &tags);
for t in modified {
let _ = action_tx.try_send(Action::PersistBatch(vec![
crate::journal::Action::Update(t),
]));
}
}
if let Ok(mut cfg) = Config::load(state.ctx.as_ref()) {
let old = cfg.clone();
cfg.tag_aliases = state.tag_aliases.clone();
cfg.update_sync_timestamp_if_changed(&old);
let _ = cfg.save(state.ctx.as_ref());
}
let trimmed = clean_input.trim();
let is_alias_only = trimmed.is_empty()
|| (!trimmed.contains(' ')
&& (trimmed.starts_with('#')
|| trimmed.starts_with("@@")
|| trimmed.to_lowercase().starts_with("loc:")));
if is_alias_only {
state.mode = InputMode::Normal;
state.reset_input();
state.message = rust_i18n::t!("alias_updated").to_string();
return None;
}
}
let target_href = state
.active_cal_href
.clone()
.filter(|href| state.local_mode_enabled || !href.starts_with("local://"))
.or_else(|| {
state
.get_filtered_calendars()
.first()
.map(|c| c.href.clone())
});
if let Some(href) = target_href {
let config = Config::load(state.ctx.as_ref()).unwrap_or_default();
let def_time =
NaiveTime::parse_from_str(&config.default_reminder_time, "%H:%M").ok();
let mut task = Task::new(&clean_input, &state.tag_aliases, def_time);
task.calendar_href = href.clone();
task.parent_uid = state.creating_child_of.clone();
let new_uid = task.uid.clone();
state.store.add_task(task.clone());
state.refresh_filtered_view();
update_alarms(state);
if let Some(idx) = state.find_task_index_by_uid(&new_uid) {
state.list_state.select(Some(idx));
}
state.mode = InputMode::Normal;
state.reset_input();
state.creating_child_of = None;
return Some(Action::PersistBatch(vec![crate::journal::Action::Create(
task,
)]));
}
state.message = if state.local_mode_enabled {
rust_i18n::t!("error_no_calendar_available").to_string()
} else {
rust_i18n::t!("error_no_remote_calendar").to_string()
};
state.mode = InputMode::Normal;
state.reset_input();
}
KeyCode::Esc => {
state.mode = InputMode::Normal;
state.reset_input();
state.creating_with_desc = false;
state.new_task_title.clear();
state.message = rust_i18n::t!("editing_cancelled").to_string();
}
KeyCode::Char(c) => state.enter_char(c),
KeyCode::Backspace => state.delete_char(),
KeyCode::Left => state.move_cursor_left(),
KeyCode::Right => state.move_cursor_right(),
_ => {}
},
InputMode::Editing => match key.code {
KeyCode::Enter => {
let (clean_input, new_aliases): (String, HashMap<String, Vec<String>>) =
extract_inline_aliases(&state.input_buffer);
if !new_aliases.is_empty() {
for (k, v) in new_aliases {
if let Err(e) = validate_alias_integrity(&k, &v, &state.tag_aliases) {
state.message =
rust_i18n::t!("error_general", error = e.to_string()).to_string();
return None;
}
state.tag_aliases.insert(k.clone(), v.clone());
let modified = state.store.apply_alias_retroactively(&k, &v);
for mod_t in modified {
let _ = action_tx.try_send(Action::PersistBatch(vec![
crate::journal::Action::Update(mod_t),
]));
}
}
if let Ok(mut cfg) = Config::load(state.ctx.as_ref()) {
let old = cfg.clone();
cfg.tag_aliases = state.tag_aliases.clone();
cfg.update_sync_timestamp_if_changed(&old);
let _ = cfg.save(state.ctx.as_ref());
}
}
let target_uid: Option<String> = state.editing_uid.clone();
if let Some(uid) = target_uid
&& let Some((t, _)) = state.store.get_task_mut(&uid)
{
let config = Config::load(state.ctx.as_ref()).unwrap_or_default();
let def_time =
NaiveTime::parse_from_str(&config.default_reminder_time, "%H:%M").ok();
t.apply_smart_input(&clean_input, &state.tag_aliases, def_time);
t.sequence += 1;
let clone = t.clone();
state.refresh_filtered_view();
update_alarms(state);
state.mode = InputMode::Normal;
state.reset_input();
let _ = action_tx.try_send(Action::PersistBatch(vec![
crate::journal::Action::Update(clone),
]));
}
state.mode = InputMode::Normal;
}
KeyCode::Esc => {
state.mode = InputMode::Normal;
state.reset_input();
}
KeyCode::Char(c) => state.enter_char(c),
KeyCode::Backspace => state.delete_char(),
KeyCode::Left => state.move_cursor_left(),
KeyCode::Right => state.move_cursor_right(),
_ => {}
},
InputMode::EditingDescription => match key.code {
KeyCode::Enter => {
state.enter_char('\n');
}
KeyCode::Char('s') | KeyCode::Char('d')
if key.modifiers.contains(KeyModifiers::CONTROL) =>
{
save_description(state, action_tx);
return None;
}
KeyCode::F(2) => {
save_description(state, action_tx);
return None;
}
KeyCode::Esc => {
state.mode = InputMode::Normal;
state.reset_input();
state.creating_with_desc = false;
state.new_task_title.clear();
state.message = rust_i18n::t!("editing_cancelled").to_string();
}
KeyCode::Char(c) => {
if c == '\t' {
for _ in 0..4 {
state.enter_char(' ');
}
} else if !c.is_control() || c == '\n' {
state.enter_char(c);
}
}
KeyCode::Backspace => state.delete_char(),
KeyCode::Left => state.move_cursor_left(),
KeyCode::Right => state.move_cursor_right(),
KeyCode::Up => {
let current_idx = state.cursor_position;
let chars: Vec<char> = state.input_buffer.chars().collect();
let mut line_start = current_idx;
while line_start > 0 && chars[line_start - 1] != '\n' {
line_start -= 1;
}
let col = current_idx - line_start;
if line_start > 0 {
let mut prev_line_start = line_start - 1;
while prev_line_start > 0 && chars[prev_line_start - 1] != '\n' {
prev_line_start -= 1;
}
let prev_line_len = (line_start - 1) - prev_line_start;
let new_col = col.min(prev_line_len);
state.cursor_position = prev_line_start + new_col;
} else {
state.cursor_position = 0;
}
}
KeyCode::Down => {
let current_idx = state.cursor_position;
let chars: Vec<char> = state.input_buffer.chars().collect();
let total = chars.len();
let mut line_start = current_idx;
while line_start > 0 && chars[line_start - 1] != '\n' {
line_start -= 1;
}
let col = current_idx - line_start;
let mut next_line_start = current_idx;
while next_line_start < total && chars[next_line_start] != '\n' {
next_line_start += 1;
}
if next_line_start < total {
next_line_start += 1;
let mut next_line_end = next_line_start;
while next_line_end < total && chars[next_line_end] != '\n' {
next_line_end += 1;
}
let next_line_len = next_line_end - next_line_start;
let new_col = col.min(next_line_len);
state.cursor_position = next_line_start + new_col;
} else {
state.cursor_position = total;
}
}
_ => {}
},
InputMode::Snoozing => match key.code {
KeyCode::Enter if !state.input_buffer.is_empty() => {
if let Some(mins) = crate::model::parser::parse_duration(&state.input_buffer) {
if let Some((task, alarm_uid)) = state.active_alarm.clone()
&& let Some((t, _)) = state.store.get_task_mut(&task.uid)
&& t.snooze_alarm(&alarm_uid, mins)
{
let uid = t.uid.clone();
state.active_alarm = None;
state.mode = InputMode::Normal;
state.reset_input();
state.refresh_filtered_view();
let config = Config::load(state.ctx.as_ref()).unwrap_or_default();
let intent = AppIntent::ToggleTask { uid: uid.clone() };
let actions = state.store.apply_task_intent(&intent, &config);
update_alarms(state);
let tx = action_tx.clone();
tokio::spawn(async move {
let _ = tx.send(Action::PersistBatch(actions)).await;
});
}
} else {
state.message =
rust_i18n::t!("error_invalid_duration", val = state.input_buffer.clone())
.to_string();
}
return None;
}
KeyCode::Esc => {
state.mode = InputMode::Normal;
state.reset_input();
return None;
}
KeyCode::Char(c) => {
state.enter_char(c);
}
KeyCode::Backspace => {
state.delete_char();
}
KeyCode::Left => {
state.move_cursor_left();
}
KeyCode::Right => {
state.move_cursor_right();
}
_ => {}
},
InputMode::Searching => match key.code {
KeyCode::Enter => {
if state.input_buffer.starts_with('#') && !state.input_buffer.contains(' ') {
let tag = state.input_buffer.trim_start_matches('#').to_string();
state.sidebar_mode = SidebarMode::Categories;
state.selected_categories.clear();
state.selected_categories.insert(tag);
state.active_search_query.clear();
} else if (state.input_buffer.starts_with("@@")
|| state.input_buffer.starts_with("loc:"))
&& !state.input_buffer.contains(' ')
{
let raw = if state.input_buffer.starts_with("@@") {
state.input_buffer.trim_start_matches("@@")
} else {
state.input_buffer.trim_start_matches("loc:")
};
let loc = crate::model::parser::strip_quotes(raw);
state.sidebar_mode = SidebarMode::Locations;
state.selected_locations.clear();
state.selected_locations.insert(loc);
state.active_search_query.clear();
} else {
state.active_search_query = state.input_buffer.clone();
}
state.mode = InputMode::Normal;
state.reset_input();
state.refresh_filtered_view();
}
KeyCode::Esc => {
state.active_search_query.clear();
state.mode = InputMode::Normal;
state.reset_input();
state.refresh_filtered_view();
}
KeyCode::Char(c) => {
state.enter_char(c);
state.refresh_filtered_view();
}
KeyCode::Backspace => {
state.delete_char();
state.refresh_filtered_view();
}
KeyCode::Left => state.move_cursor_left(),
KeyCode::Right => state.move_cursor_right(),
KeyCode::Down => state.next(),
KeyCode::Up => state.previous(),
KeyCode::PageDown => state.jump_forward(10),
KeyCode::PageUp => state.jump_backward(10),
_ => {}
},
InputMode::ActionMenu => match key.code {
KeyCode::Esc => {
state.mode = InputMode::Normal;
state.action_filter.clear();
}
KeyCode::Down | KeyCode::Char('j') => {
let len = state.action_menu_items.len();
if len > 0 {
let current = state.action_selection_state.selected().unwrap_or(0);
state
.action_selection_state
.select(Some((current + 1) % len));
}
}
KeyCode::Up | KeyCode::Char('k') => {
let len = state.action_menu_items.len();
if len > 0 {
let current = state.action_selection_state.selected().unwrap_or(0);
state
.action_selection_state
.select(Some((current + len - 1) % len));
}
}
KeyCode::Backspace => {
if !state.action_filter.is_empty() {
state.action_filter.pop();
update_action_menu_filter(state);
} else {
state.mode = InputMode::Normal;
}
}
KeyCode::Char(c) => {
state.action_filter.push(c);
update_action_menu_filter(state);
}
KeyCode::Enter => {
if let Some(idx) = state.action_selection_state.selected()
&& let Some(action) = state.action_menu_items.get(idx).copied()
&& let Some(task) = state.get_selected_task().cloned()
{
state.mode = InputMode::Normal;
state.action_filter.clear();
execute_task_action(state, action, &task, action_tx).await;
}
}
_ => {}
},
InputMode::Help(current_tab) => match key.code {
KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('?') => {
state.mode = InputMode::Normal;
}
KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => {
let next_tab = match current_tab {
crate::help::HelpTab::Syntax => crate::help::HelpTab::Shortcuts,
crate::help::HelpTab::Shortcuts => crate::help::HelpTab::About,
crate::help::HelpTab::About => crate::help::HelpTab::Syntax,
};
state.mode = InputMode::Help(next_tab);
state.edit_scroll_offset = 0;
}
KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => {
let prev_tab = match current_tab {
crate::help::HelpTab::Syntax => crate::help::HelpTab::About,
crate::help::HelpTab::Shortcuts => crate::help::HelpTab::Syntax,
crate::help::HelpTab::About => crate::help::HelpTab::Shortcuts,
};
state.mode = InputMode::Help(prev_tab);
state.edit_scroll_offset = 0;
}
KeyCode::Down | KeyCode::Char('j') => {
state.edit_scroll_offset = state.edit_scroll_offset.saturating_add(1);
}
KeyCode::Up | KeyCode::Char('k') => {
state.edit_scroll_offset = state.edit_scroll_offset.saturating_sub(1);
}
KeyCode::PageDown => {
state.edit_scroll_offset = state.edit_scroll_offset.saturating_add(10);
}
KeyCode::PageUp => {
state.edit_scroll_offset = state.edit_scroll_offset.saturating_sub(10);
}
_ => {}
},
InputMode::Normal => match key.code {
KeyCode::Char('e') | KeyCode::Char('E')
if key.modifiers.contains(KeyModifiers::CONTROL) =>
{
state.mode = InputMode::Creating;
state.creating_with_desc = true;
state.reset_input();
state.new_task_title.clear();
state.message = rust_i18n::t!("task_title_prompt").to_string();
}
KeyCode::Esc => {
let mut needs_refresh = false;
if state.yanked_uid.is_some() {
state.yanked_uid = None;
state.yank_lock_active = false;
state.message = rust_i18n::t!("yank_cleared").to_string();
} else if !state.active_search_query.is_empty() {
state.active_search_query.clear();
needs_refresh = true;
} else if !state.selected_categories.is_empty() {
state.selected_categories.clear();
needs_refresh = true;
}
if needs_refresh {
state.refresh_filtered_view();
}
}
KeyCode::Char('?') => {
state.mode = InputMode::Help(crate::help::HelpTab::Shortcuts);
state.edit_scroll_offset = 0;
}
KeyCode::Char('w') => {
if state.active_search_query.contains(&state.quick_filter_term) {
state.active_search_query = state
.active_search_query
.replace(&state.quick_filter_term, "")
.trim()
.to_string();
} else {
if state.active_search_query.is_empty() {
state.active_search_query = state.quick_filter_term.clone();
} else {
state.active_search_query =
format!("{} {}", state.quick_filter_term, state.active_search_query);
}
}
state.refresh_filtered_view();
}
KeyCode::Char('q') => return Some(Action::Quit),
KeyCode::Char('r') => return Some(Action::Refresh),
KeyCode::Char('R') => {
let real_tasks: Vec<Task> = state
.tasks
.iter()
.filter_map(|item| {
if let TaskListItem::Task(task) = item {
Some((**task).clone())
} else {
None
}
})
.collect();
if let Some(idx) = select_weighted_random_index(&real_tasks, state.default_priority)
{
state.list_state.select(Some(idx));
state.message = rust_i18n::t!("jumped_to_task").to_string();
}
}
KeyCode::Char('t') => {
if let Some(summary) = state.get_selected_task().map(|t| t.summary.clone()) {
state.mode = InputMode::AddingSession;
state.reset_input();
state.message = format!(
"{} ({} 30m, yesterday 1h):",
t!("tui_log_time_prompt", name = summary),
t!("eg")
);
}
}
KeyCode::Char('T') => {
if let Some(sessions) = state.get_selected_task().map(|t| t.sessions.clone()) {
let mut items = Vec::new();
for (i, session) in sessions.iter().enumerate() {
let s_dt = chrono::DateTime::from_timestamp(session.start, 0)
.unwrap_or_else(|| chrono::DateTime::from_timestamp(0, 0).unwrap())
.with_timezone(&chrono::Local);
let e_dt = chrono::DateTime::from_timestamp(session.end, 0)
.unwrap_or_else(|| chrono::DateTime::from_timestamp(0, 0).unwrap())
.with_timezone(&chrono::Local);
let dur = (session.end - session.start) / 60;
let display = format!(
"{} {}-{} ({}m)",
s_dt.format("%Y-%m-%d"),
s_dt.format("%H:%M"),
e_dt.format("%H:%M"),
dur
);
items.push((i, display));
}
if !items.is_empty() {
state.session_items = items;
state.session_selection_state.select(Some(0));
state.mode = InputMode::ManagingSessions;
state.message = t!("tui_manage_sessions_prompt").to_string();
} else {
state.message = t!("no_sessions_recorded").to_string();
}
}
}
KeyCode::Char(' ') => {
let is_shift = key.modifiers.contains(KeyModifiers::SHIFT);
if state.active_focus == Focus::Main {
if let Some(view_task) = state.get_selected_task() {
if view_task.etag == "pending_refresh" {
return None;
}
let uid = view_task.uid.clone();
let config = Config::load(state.ctx.as_ref()).unwrap_or_default();
let intent = if is_shift {
AppIntent::ToggleTaskShift { uid: uid.clone() }
} else {
AppIntent::ToggleTask { uid: uid.clone() }
};
let actions = state.store.apply_task_intent(&intent, &config);
state.refresh_filtered_view();
update_alarms(state);
let tx = action_tx.clone();
tokio::spawn(async move {
let _ = tx.send(Action::PersistBatch(actions)).await;
});
}
} else if state.active_focus == Focus::Sidebar
&& state.sidebar_mode == SidebarMode::Calendars
{
let target_href = if let Some(idx) = state.cal_state.selected() {
let filtered = state.get_filtered_calendars();
filtered.get(idx).map(|c| c.href.clone())
} else {
None
};
if let Some(href) = target_href
&& state.active_cal_href.as_ref() != Some(&href)
{
if state.hidden_calendars.contains(&href) {
state.hidden_calendars.remove(&href);
let _ = action_tx.send(Action::ToggleCalendarVisibility(href)).await;
} else {
state.hidden_calendars.insert(href);
}
state.refresh_filtered_view();
}
}
}
KeyCode::Char('s') => {
if let Some(task) = state.get_selected_task() {
let uid = task.uid.clone();
let config = Config::load(state.ctx.as_ref()).unwrap_or_default();
let intent = if task.status == TaskStatus::InProcess {
AppIntent::PauseTask { uid: uid.clone() }
} else {
AppIntent::StartTask { uid: uid.clone() }
};
let actions = state.store.apply_task_intent(&intent, &config);
if !actions.is_empty() {
state.refresh_filtered_view();
let tx = action_tx.clone();
tokio::spawn(async move {
let _ = tx.send(Action::PersistBatch(actions)).await;
});
}
return None;
}
}
KeyCode::Char('S') => {
if let Some(uid) = state.get_selected_task().map(|t| t.uid.clone()) {
let config = Config::load(state.ctx.as_ref()).unwrap_or_default();
let intent = AppIntent::StopTask { uid: uid.clone() };
let actions = state.store.apply_task_intent(&intent, &config);
if !actions.is_empty() {
state.refresh_filtered_view();
let tx = action_tx.clone();
tokio::spawn(async move {
let _ = tx.send(Action::PersistBatch(actions)).await;
});
}
return None;
}
}
KeyCode::Char('x') => {
if let Some(uid) = state.get_selected_task().map(|t| t.uid.clone()) {
let config = Config::load(state.ctx.as_ref()).unwrap_or_default();
let intent = AppIntent::CancelTask { uid: uid.clone() };
let actions = state.store.apply_task_intent(&intent, &config);
state.refresh_filtered_view();
update_alarms(state);
let tx = action_tx.clone();
tokio::spawn(async move {
let _ = tx.send(Action::PersistBatch(actions)).await;
});
}
}
KeyCode::Char('+') => {
if let Some(uid) = state.get_selected_task().map(|t| t.uid.clone()) {
let config = Config::load(state.ctx.as_ref()).unwrap_or_default();
let intent = AppIntent::ChangePriority {
uid: uid.clone(),
delta: 1,
};
let actions = state.store.apply_task_intent(&intent, &config);
state.refresh_filtered_view();
if !actions.is_empty() {
let tx = action_tx.clone();
tokio::spawn(async move {
let _ = tx.send(Action::PersistBatch(actions)).await;
});
}
}
}
KeyCode::Char('-') => {
if let Some(uid) = state.get_selected_task().map(|t| t.uid.clone()) {
let config = Config::load(state.ctx.as_ref()).unwrap_or_default();
let intent = AppIntent::ChangePriority {
uid: uid.clone(),
delta: -1,
};
let actions = state.store.apply_task_intent(&intent, &config);
state.refresh_filtered_view();
if !actions.is_empty() {
let tx = action_tx.clone();
tokio::spawn(async move {
let _ = tx.send(Action::PersistBatch(actions)).await;
});
}
}
}
KeyCode::Delete => {
if let Some(uid) = state.get_selected_task().map(|t| t.uid.clone()) {
let config = Config::load(state.ctx.as_ref()).unwrap_or_default();
let intent = if key.modifiers.contains(KeyModifiers::CONTROL) {
AppIntent::DeleteTaskTree { uid: uid.clone() }
} else {
AppIntent::DeleteTask { uid: uid.clone() }
};
let actions = state.store.apply_task_intent(&intent, &config);
state.refresh_filtered_view();
update_alarms(state);
let tx = action_tx.clone();
tokio::spawn(async move {
let _ = tx.send(Action::PersistBatch(actions)).await;
});
}
}
KeyCode::Char('c') => {
let data = if let Some(parent_uid) = &state.yanked_uid {
state
.get_selected_task()
.map(|view_task| (view_task.uid.clone(), parent_uid.clone()))
} else {
None
};
if let Some((child_uid, parent_uid)) = data {
let config = Config::load(state.ctx.as_ref()).unwrap_or_default();
let intent = AppIntent::MakeChild {
uid: child_uid.clone(),
parent_uid: parent_uid.clone(),
};
let actions = state.store.apply_task_intent(&intent, &config);
if !state.yank_lock_active {
state.yanked_uid = None;
}
state.refresh_filtered_view();
if !actions.is_empty() {
let tx = action_tx.clone();
tokio::spawn(async move {
let _ = tx.send(Action::PersistBatch(actions)).await;
});
}
}
}
KeyCode::Char('C') => {
if state.active_focus == Focus::Main
&& let Some(task) = state.get_selected_task()
{
let uid = task.uid.clone();
let summary = task.summary.clone();
let mut initial_input = String::new();
for cat in &task.categories {
initial_input
.push_str(&format!("#{} ", crate::model::parser::quote_value(cat)));
}
if let Some(loc) = &task.location {
initial_input
.push_str(&format!("@@{} ", crate::model::parser::quote_value(loc)));
}
state.input_buffer = initial_input;
state.cursor_position = state.input_buffer.chars().count();
state.mode = InputMode::Creating;
state.creating_with_desc = false;
state.new_task_title.clear();
state.creating_child_of = Some(uid);
state.message = rust_i18n::t!("new_child_of", name = summary).to_string();
}
}
KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
if let Some(uid) = state.get_selected_task().map(|t| t.uid.clone()) {
let config = Config::load(state.ctx.as_ref()).unwrap_or_default();
let intent = AppIntent::DuplicateTaskTree { uid: uid.clone() };
let actions = state.store.apply_task_intent(&intent, &config);
state.refresh_filtered_view();
if !actions.is_empty() {
let tx = action_tx.clone();
tokio::spawn(async move {
let _ = tx.send(Action::PersistBatch(actions)).await;
});
}
}
}
KeyCode::Char('Y') => {
state.yank_lock_active = !state.yank_lock_active;
state.needs_redraw = true;
}
KeyCode::Char('y') => {
if let Some(t) = state.get_selected_task() {
let uid = t.uid.clone();
let summary = t.summary.clone();
let text = if t.description.is_empty() {
t.to_smart_string()
} else {
format!("{}\n\n{}", t.to_smart_string(), t.description)
};
state.yanked_uid = Some(uid);
use base64::Engine;
let b64 = base64::engine::general_purpose::STANDARD.encode(text);
print!("\x1b]52;c;{}\x07", b64);
use std::io::Write;
let _ = std::io::stdout().flush();
state.message =
rust_i18n::t!("yanked_and_copied", summary = summary).to_string();
}
}
KeyCode::Char('g') => {
if let Some(task) = state.get_selected_task() {
let uid = task.uid.clone();
let count = state.store.count_tree_locations(&uid);
if count > 1 {
let waypoints = state.store.get_tree_waypoints(&uid);
let mut gpx_string = String::from(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<gpx version=\"1.1\" creator=\"Cfait\" xmlns=\"http://www.topografix.com/GPX/1/1\">\n",
);
for (name, geo) in waypoints {
let parts: Vec<&str> = geo.split(',').collect();
if parts.len() >= 2 {
let escaped_name = name
.replace('&', "&")
.replace('<', "<")
.replace('>', ">");
gpx_string.push_str(&format!(
" <wpt lat=\"{}\" lon=\"{}\"><name>{}</name></wpt>\n",
parts[0].trim(),
parts[1].trim(),
escaped_name
));
}
}
gpx_string.push_str("</gpx>");
if let Ok(cache_dir) = state.ctx.get_cache_dir() {
let path =
cache_dir.join(format!("locations_{}.gpx", uuid::Uuid::new_v4()));
if std::fs::write(&path, gpx_string).is_ok() {
#[cfg(not(target_os = "android"))]
{
let target = path.to_string_lossy().to_string();
std::thread::spawn(move || {
#[cfg(target_os = "linux")]
let _ = std::process::Command::new("xdg-open")
.arg(target)
.spawn();
#[cfg(target_os = "windows")]
let _ = std::process::Command::new("explorer")
.arg(target)
.spawn();
#[cfg(target_os = "macos")]
let _ =
std::process::Command::new("open").arg(target).spawn();
});
}
state.message = rust_i18n::t!("action_open_locations").to_string();
} else {
state.message = rust_i18n::t!("error_write_gpx").to_string();
}
}
} else if let Some(_geo) = &task.geo {
#[cfg(not(target_os = "android"))]
{
let target_url = format!("geo:{}", _geo);
std::thread::spawn(move || {
#[cfg(target_os = "linux")]
let _ = std::process::Command::new("xdg-open")
.arg(target_url)
.spawn();
#[cfg(target_os = "windows")]
let _ = std::process::Command::new("explorer")
.arg(target_url)
.spawn();
#[cfg(target_os = "macos")]
let _ = std::process::Command::new("open").arg(target_url).spawn();
});
}
state.message = rust_i18n::t!("open_coordinates").to_string();
} else {
state.message = rust_i18n::t!("error_no_location").to_string();
}
}
}
KeyCode::Char('o') => {
if let Some(task) = state.get_selected_task() {
if let Some(_url) = &task.url {
#[cfg(not(target_os = "android"))]
{
let target_url = _url.clone();
std::thread::spawn(move || {
#[cfg(target_os = "linux")]
let _ = std::process::Command::new("xdg-open")
.arg(target_url)
.spawn();
#[cfg(target_os = "windows")]
let _ = std::process::Command::new("explorer")
.arg(target_url)
.spawn();
#[cfg(target_os = "macos")]
let _ = std::process::Command::new("open").arg(target_url).spawn();
});
}
state.message = rust_i18n::t!("open_url").to_string();
} else {
state.message = rust_i18n::t!("error_no_url").to_string();
}
}
}
KeyCode::Char('b') => {
let data = if let Some(yanked) = &state.yanked_uid {
state
.get_selected_task()
.map(|current| (current.uid.clone(), yanked.clone()))
} else {
None
};
if let Some((curr_uid, yanked_uid)) = data {
if curr_uid == yanked_uid {
state.message = rust_i18n::t!("error_cannot_depend_on_self").to_string();
} else {
let config = Config::load(state.ctx.as_ref()).unwrap_or_default();
let intent = AppIntent::AddDependency {
uid: curr_uid.clone(),
blocker_uid: yanked_uid.clone(),
};
let actions = state.store.apply_task_intent(&intent, &config);
if !state.yank_lock_active {
state.yanked_uid = None;
}
state.refresh_filtered_view();
if !actions.is_empty() {
let tx = action_tx.clone();
tokio::spawn(async move {
let _ = tx.send(Action::PersistBatch(actions)).await;
});
}
}
}
}
KeyCode::Char('l') => {
let data = if let Some(yanked) = &state.yanked_uid {
state
.get_selected_task()
.map(|current| (current.uid.clone(), yanked.clone()))
} else {
None
};
if let Some((curr_uid, yanked_uid)) = data {
if curr_uid == yanked_uid {
state.message = rust_i18n::t!("error_cannot_relate_to_self").to_string();
} else {
let config = Config::load(state.ctx.as_ref()).unwrap_or_default();
let intent = AppIntent::AddRelatedTo {
uid: curr_uid.clone(),
related_uid: yanked_uid.clone(),
};
let actions = state.store.apply_task_intent(&intent, &config);
if !state.yank_lock_active {
state.yanked_uid = None;
}
state.refresh_filtered_view();
if !actions.is_empty() {
let tx = action_tx.clone();
tokio::spawn(async move {
let _ = tx.send(Action::PersistBatch(actions)).await;
});
}
}
}
}
KeyCode::Char('.') | KeyCode::Char('>') => {
if state.active_focus == Focus::Main
&& let Some(idx) = state.list_state.selected()
&& idx > 0
&& idx < state.tasks.len()
&& let (Some(parent_task), Some(current_task)) = (
state.get_task_at_index(idx - 1),
state.get_task_at_index(idx),
)
{
let parent_uid = parent_task.uid.clone();
let current_uid = current_task.uid.clone();
let config = Config::load(state.ctx.as_ref()).unwrap_or_default();
let intent = AppIntent::MakeChild {
uid: current_uid.clone(),
parent_uid: parent_uid.clone(),
};
let actions = state.store.apply_task_intent(&intent, &config);
state.refresh_filtered_view();
if !actions.is_empty() {
let tx = action_tx.clone();
tokio::spawn(async move {
let _ = tx.send(Action::PersistBatch(actions)).await;
});
}
}
}
KeyCode::Char(',') | KeyCode::Char('<') => {
if state.active_focus == Focus::Main
&& let Some(view_task) = state.get_selected_task()
&& view_task.parent_uid.is_some()
{
let uid = view_task.uid.clone();
let config = Config::load(state.ctx.as_ref()).unwrap_or_default();
let intent = AppIntent::RemoveParent { uid: uid.clone() };
let actions = state.store.apply_task_intent(&intent, &config);
state.refresh_filtered_view();
if !actions.is_empty() {
let tx = action_tx.clone();
tokio::spawn(async move {
let _ = tx.send(Action::PersistBatch(actions)).await;
});
}
}
}
KeyCode::Char('X') => {
state.export_source_calendars = state
.calendars
.iter()
.filter(|c| {
c.href.starts_with("local://")
&& !state.disabled_calendars.contains(&c.href)
&& c.href != crate::storage::LOCAL_TRASH_HREF
})
.cloned()
.collect();
if !state.export_source_calendars.is_empty() {
state.export_source_selection_state.select(Some(0));
state.mode = InputMode::SelectingExportSource;
state.message = rust_i18n::t!("tui_export_select_source").to_string();
}
}
KeyCode::Char('M') => {
if let Some(task) = state.get_selected_task() {
let current_href = task.calendar_href.clone();
state.move_targets = state
.calendars
.iter()
.filter(|c| {
c.href != current_href
&& !state.disabled_calendars.contains(&c.href)
&& c.href != crate::storage::LOCAL_TRASH_HREF
&& c.href != "local://recovery"
})
.cloned()
.collect();
if !state.move_targets.is_empty() {
state.move_selection_state.select(Some(0));
state.mode = InputMode::Moving;
state.message = rust_i18n::t!("tui_select_calendar_prompt").to_string();
}
}
}
KeyCode::Down | KeyCode::Char('j') => state.next(),
KeyCode::Up | KeyCode::Char('k') => state.previous(),
KeyCode::PageDown => state.jump_forward(10),
KeyCode::PageUp => state.jump_backward(10),
KeyCode::Tab => state.toggle_focus(),
KeyCode::Char('1') => {
state.sidebar_mode = SidebarMode::Calendars;
state.refresh_filtered_view();
}
KeyCode::Char('2') => {
state.sidebar_mode = SidebarMode::Categories;
state.refresh_filtered_view();
}
KeyCode::Char('z') => {
if state.active_focus == Focus::Main {
if let Some(uid) = state.get_selected_task().map(|t| t.uid.clone()) {
let config = Config::load(state.ctx.as_ref()).unwrap_or_default();
let intent = AppIntent::ToggleTreeCollapse { uid: uid.clone() };
let actions = state.store.apply_task_intent(&intent, &config);
state.refresh_filtered_view();
if !actions.is_empty() {
let tx = action_tx.clone();
tokio::spawn(async move {
let _ = tx.send(Action::PersistBatch(actions)).await;
});
}
}
} else if state.active_focus == Focus::Sidebar {
match state.sidebar_mode {
SidebarMode::Categories => {
if let Some(idx) = state.cal_state.selected()
&& let Some(item) = state.cached_categories.get(idx)
&& item.has_children
{
let key = item.full_key.clone();
if !state.expanded_tags.remove(&key) {
state.expanded_tags.insert(key);
}
state.refresh_filtered_view();
if let Ok(mut cfg) = Config::load(state.ctx.as_ref()) {
cfg.expanded_tags =
state.expanded_tags.iter().cloned().collect();
let _ = cfg.save(state.ctx.as_ref());
}
}
}
SidebarMode::Locations => {
if let Some(idx) = state.cal_state.selected()
&& let Some(item) = state.cached_locations.get(idx)
&& item.has_children
{
let key = item.full_key.clone();
if !state.expanded_locations.remove(&key) {
state.expanded_locations.insert(key);
}
state.refresh_filtered_view();
if let Ok(mut cfg) = Config::load(state.ctx.as_ref()) {
cfg.expanded_locations =
state.expanded_locations.iter().cloned().collect();
let _ = cfg.save(state.ctx.as_ref());
}
}
}
_ => {}
}
}
}
KeyCode::Char('3') => {
state.sidebar_mode = SidebarMode::Locations;
state.refresh_filtered_view();
}
KeyCode::Char('m') => {
state.match_all_categories = !state.match_all_categories;
state.refresh_filtered_view();
}
KeyCode::Char('H') => {
state.hide_completed = !state.hide_completed;
state.refresh_filtered_view();
}
KeyCode::Char('p') if key.modifiers.contains(KeyModifiers::CONTROL) => {
state.sort_standard_by_priority = !state.sort_standard_by_priority;
state.refresh_filtered_view();
state.message = if state.sort_standard_by_priority {
rust_i18n::t!("sort_standard_by_priority").to_string()
} else {
rust_i18n::t!("sort_standard_by_date").to_string()
};
}
KeyCode::Char('L') => {
if let Some(task) = state.get_selected_task() {
let mut items = Vec::new();
if let Some(p_uid) = &task.parent_uid {
let name = state
.store
.get_summary(p_uid)
.unwrap_or_else(|| "Unknown task".to_string());
items.push((
p_uid.clone(),
format!("↑ [Parent] {}", name),
"parent".to_string(),
));
}
for dep_uid in &task.dependencies {
let name = state
.store
.get_summary(dep_uid)
.unwrap_or_else(|| "Unknown task".to_string());
let is_done = state.store.is_task_done(dep_uid).unwrap_or(false);
let check = if is_done { "[x]" } else { "[ ]" };
items.push((
dep_uid.clone(),
format!("⬆ [Blocked by] {} {}", check, name),
"dependency".to_string(),
));
}
for related_uid in &task.related_to {
let name = state
.store
.get_summary(related_uid)
.unwrap_or_else(|| "Unknown task".to_string());
items.push((
related_uid.clone(),
format!("→ [Related to] {}", name),
"related_to".to_string(),
));
}
let incoming_related = state.store.get_tasks_related_to(&task.uid);
for (related_uid, related_name) in incoming_related {
items.push((
related_uid.clone(),
format!("← [Related from] {}", related_name),
"related_from".to_string(),
));
}
let blocking_tasks = state.store.get_tasks_blocking(&task.uid);
for (blocking_uid, blocking_name) in blocking_tasks {
items.push((
blocking_uid.clone(),
format!("⬇ [Blocking] {}", blocking_name),
"blocking".to_string(),
));
}
if !items.is_empty() {
state.relationship_items = items;
state.relationship_selection_state.select(Some(0));
state.mode = InputMode::RelationshipBrowsing;
state.message =
format!("{} (Del/x: Remove)", rust_i18n::t!("tui_select_task_jump"));
} else {
state.message = rust_i18n::t!("error_no_related_tasks").to_string();
}
}
}
KeyCode::Char('*') if state.active_focus == Focus::Sidebar => {
match state.sidebar_mode {
SidebarMode::Calendars => {
let are_all_visible = state
.get_filtered_calendars()
.iter()
.filter(|c| {
c.href != crate::storage::LOCAL_TRASH_HREF
&& c.href != "local://recovery"
})
.all(|c| !state.hidden_calendars.contains(&c.href));
if are_all_visible {
for cal in &state.calendars {
if state.active_cal_href.as_ref() != Some(&cal.href) {
state.hidden_calendars.insert(cal.href.clone());
}
}
} else {
state.hidden_calendars.clear();
if state.active_cal_href.as_deref() != Some("local://trash") {
state.hidden_calendars.insert("local://trash".to_string());
}
let _ = action_tx.send(Action::Refresh).await;
}
}
SidebarMode::Categories => {
state.selected_categories.clear();
}
SidebarMode::Locations => {
state.selected_locations.clear();
}
}
}
KeyCode::Right => {
if state.active_focus == Focus::Sidebar {
match state.sidebar_mode {
SidebarMode::Calendars => {
let target_href = if let Some(idx) = state.cal_state.selected() {
let filtered = state.get_filtered_calendars();
filtered.get(idx).map(|c| c.href.clone())
} else {
None
};
if let Some(href) = target_href {
state.active_cal_href = Some(href.clone());
state.hidden_calendars.clear();
for c in &state.calendars {
if c.href != href {
state.hidden_calendars.insert(c.href.clone());
}
}
state.refresh_filtered_view();
if href != LOCAL_CALENDAR_HREF {
return Some(Action::IsolateCalendar(href));
}
}
}
SidebarMode::Categories => {
let cats = &state.cached_categories;
if let Some(idx) = state.cal_state.selected()
&& let Some(c) = cats.get(idx)
{
let c_clone = c.full_key.clone();
state.selected_categories.clear();
state.selected_categories.insert(c_clone.clone());
if !state.expanded_tags.contains(&c_clone) {
state.expanded_tags.insert(c_clone);
if let Ok(mut cfg) = Config::load(state.ctx.as_ref()) {
cfg.expanded_tags =
state.expanded_tags.iter().cloned().collect();
let _ = cfg.save(state.ctx.as_ref());
}
}
state.refresh_filtered_view();
}
}
SidebarMode::Locations => {
let locs = &state.cached_locations;
if let Some(idx) = state.cal_state.selected()
&& let Some(l) = locs.get(idx)
{
let l_clone = l.full_key.clone();
state.selected_locations.clear();
state.selected_locations.insert(l_clone.clone());
if !state.expanded_locations.contains(&l_clone) {
state.expanded_locations.insert(l_clone);
if let Ok(mut cfg) = Config::load(state.ctx.as_ref()) {
cfg.expanded_locations =
state.expanded_locations.iter().cloned().collect();
let _ = cfg.save(state.ctx.as_ref());
}
}
state.refresh_filtered_view();
}
}
}
} else if state.mode == InputMode::Editing {
state.move_cursor_right();
}
}
KeyCode::Enter => {
if state.active_focus == Focus::Main {
if let Some(idx) = state.list_state.selected()
&& let Some(task_item) = state.tasks.get(idx)
{
match task_item {
TaskListItem::ExpandGroup(key, _) => {
state.expanded_done_groups.insert(key.clone());
state.refresh_filtered_view();
if let Ok(mut cfg) = Config::load(state.ctx.as_ref()) {
cfg.expanded_done_groups =
state.expanded_done_groups.iter().cloned().collect();
let _ = cfg.save(state.ctx.as_ref());
}
return None;
}
TaskListItem::CollapseGroup(key, _) => {
state.expanded_done_groups.remove(key);
state.refresh_filtered_view();
if let Ok(mut cfg) = Config::load(state.ctx.as_ref()) {
cfg.expanded_done_groups =
state.expanded_done_groups.iter().cloned().collect();
let _ = cfg.save(state.ctx.as_ref());
}
return None;
}
TaskListItem::Task(_) => {
open_action_menu(state);
return None;
}
}
}
} else if state.active_focus == Focus::Sidebar {
match state.sidebar_mode {
SidebarMode::Calendars => {
let target_href = if let Some(idx) = state.cal_state.selected() {
let filtered = state.get_filtered_calendars();
filtered.get(idx).map(|c| c.href.clone())
} else {
None
};
if let Some(href) = target_href {
state.active_cal_href = Some(href.clone());
state.hidden_calendars.remove(&href);
state.refresh_filtered_view();
if href != LOCAL_CALENDAR_HREF {
return Some(Action::SwitchCalendar(href));
}
}
}
SidebarMode::Categories => {
let cats = &state.cached_categories;
if let Some(idx) = state.cal_state.selected()
&& let Some(c) = cats.get(idx)
{
let c_clone = c.full_key.clone();
if state.selected_categories.contains(&c_clone) {
state.selected_categories.remove(&c_clone);
} else {
state.selected_categories.insert(c_clone.clone());
if !state.expanded_tags.contains(&c_clone) {
state.expanded_tags.insert(c_clone);
if let Ok(mut cfg) = Config::load(state.ctx.as_ref()) {
cfg.expanded_tags =
state.expanded_tags.iter().cloned().collect();
let _ = cfg.save(state.ctx.as_ref());
}
}
}
state.refresh_filtered_view();
}
}
SidebarMode::Locations => {
let locs = &state.cached_locations;
if let Some(idx) = state.cal_state.selected()
&& let Some(l) = locs.get(idx)
{
let l_clone = l.full_key.clone();
if state.selected_locations.contains(&l_clone) {
state.selected_locations.remove(&l_clone);
} else {
state.selected_locations.insert(l_clone.clone());
if !state.expanded_locations.contains(&l_clone) {
state.expanded_locations.insert(l_clone);
if let Ok(mut cfg) = Config::load(state.ctx.as_ref()) {
cfg.expanded_locations =
state.expanded_locations.iter().cloned().collect();
let _ = cfg.save(state.ctx.as_ref());
}
}
}
state.refresh_filtered_view();
}
}
}
}
}
KeyCode::Char('/') => {
state.mode = InputMode::Searching;
state.reset_input();
}
KeyCode::Char('a') => {
state.mode = InputMode::Creating;
state.reset_input();
state.creating_with_desc = false;
state.new_task_title.clear();
state.message = rust_i18n::t!("new_task_prompt").to_string();
}
KeyCode::Char('e') => {
if let Some(t) = state.get_selected_task() {
let smart_string = t.to_smart_string();
let uid = t.uid.clone();
state.input_buffer = smart_string;
state.cursor_position = state.input_buffer.chars().count();
state.editing_uid = Some(uid);
state.mode = InputMode::Editing;
}
}
KeyCode::Char('E') => {
if state.active_focus == Focus::Main
&& let Some(t) = state.get_selected_task()
{
let desc = t.description.clone();
let uid = t.uid.clone();
match run_external_editor(&desc, state.ctx.as_ref()) {
Ok(Some(new_desc)) => {
if new_desc != desc
&& let Some((t_mut, _)) = state.store.get_task_mut(&uid)
{
t_mut.description = new_desc;
t_mut.sequence += 1;
let clone = t_mut.clone();
state.refresh_filtered_view();
let _ =
action_tx.try_send(crate::tui::action::Action::PersistBatch(
vec![crate::journal::Action::Update(clone)],
));
}
state.needs_redraw = true;
return None;
}
Ok(None) => {
state.input_buffer = desc.clone();
state.cursor_position = state.input_buffer.chars().count();
state.edit_scroll_offset = 0;
state.edit_scroll_x = 0;
state.editing_uid = Some(uid.clone());
state.mode = InputMode::EditingDescription;
}
Err(e) => {
state.message = e;
state.input_buffer = desc.clone();
state.cursor_position = state.input_buffer.chars().count();
state.edit_scroll_offset = 0;
state.edit_scroll_x = 0;
state.editing_uid = Some(uid.clone());
state.mode = InputMode::EditingDescription;
state.needs_redraw = true;
}
}
}
}
_ => {}
},
InputMode::AddingSession => match key.code {
KeyCode::Enter => {
let input = state.input_buffer.clone();
if let Some(session) = crate::model::parser::parse_session_input(&input) {
if let Some(uid) = state.get_selected_task().map(|t| t.uid.clone())
&& let Some((t_mut, _)) = state.store.get_task_mut(&uid)
{
t_mut.add_session(session);
t_mut.sequence += 1;
let cloned = t_mut.clone();
state.refresh_filtered_view();
state.mode = InputMode::Normal;
state.reset_input();
let _ = action_tx.try_send(Action::PersistBatch(vec![
crate::journal::Action::Update(cloned),
]));
}
} else {
state.message = rust_i18n::t!("error_failed_to_parse_time").to_string();
}
}
KeyCode::Esc => {
state.mode = InputMode::Normal;
state.reset_input();
}
KeyCode::Char(c) => state.enter_char(c),
KeyCode::Backspace => state.delete_char(),
KeyCode::Left => state.move_cursor_left(),
KeyCode::Right => state.move_cursor_right(),
_ => {}
},
InputMode::ManagingSessions => match key.code {
KeyCode::Esc => {
state.mode = InputMode::Normal;
state.message = String::new();
}
KeyCode::Down | KeyCode::Char('j') => {
let len = state.session_items.len();
if len > 0 {
let current = state.session_selection_state.selected().unwrap_or(0);
let next = if current >= len - 1 { 0 } else { current + 1 };
state.session_selection_state.select(Some(next));
}
}
KeyCode::Up | KeyCode::Char('k') => {
let len = state.session_items.len();
if len > 0 {
let current = state.session_selection_state.selected().unwrap_or(0);
let prev = if current == 0 { len - 1 } else { current - 1 };
state.session_selection_state.select(Some(prev));
}
}
KeyCode::Delete | KeyCode::Char('x') => {
if let Some(idx) = state.session_selection_state.selected()
&& let Some(&(real_idx, _)) = state.session_items.get(idx)
&& let Some(uid) = state.get_selected_task().map(|t| t.uid.clone())
&& let Some((t_mut, _)) = state.store.get_task_mut(&uid)
{
t_mut.remove_session(real_idx);
t_mut.sequence += 1;
let cloned = t_mut.clone();
state.mode = InputMode::Normal;
state.refresh_filtered_view();
let tx = action_tx.clone();
tokio::spawn(async move {
let _ = tx
.send(Action::PersistBatch(vec![crate::journal::Action::Update(
cloned,
)]))
.await;
});
}
}
_ => {}
},
InputMode::Moving => match key.code {
KeyCode::Esc => {
state.mode = InputMode::Normal;
state.message = String::new();
}
KeyCode::Down | KeyCode::Char('j') => state.next_move_target(),
KeyCode::Up | KeyCode::Char('k') => state.previous_move_target(),
KeyCode::Enter => {
let data = if let Some(task) = state.get_selected_task() {
if let Some(idx) = state.move_selection_state.selected() {
state
.move_targets
.get(idx)
.map(|target_cal| (task.uid.clone(), target_cal.href.clone()))
} else {
None
}
} else {
None
};
if let Some((uid, target_href)) = data {
let config = Config::load(state.ctx.as_ref()).unwrap_or_default();
let intent = AppIntent::MoveTask {
uid: uid.clone(),
target_href: target_href.clone(),
};
let actions = state.store.apply_task_intent(&intent, &config);
state.refresh_filtered_view();
update_alarms(state);
state.message = rust_i18n::t!("moving_task").to_string();
state.mode = InputMode::Normal;
if !actions.is_empty() {
let tx = action_tx.clone();
tokio::spawn(async move {
let _ = tx.send(Action::PersistBatch(actions)).await;
});
}
}
state.mode = InputMode::Normal;
}
_ => {}
},
InputMode::SelectingExportSource => match key.code {
KeyCode::Esc => {
state.mode = InputMode::Normal;
state.message = String::new();
}
KeyCode::Down | KeyCode::Char('j') => state.next_export_source(),
KeyCode::Up | KeyCode::Char('k') => state.previous_export_source(),
KeyCode::Enter => {
if let Some(idx) = state.export_source_selection_state.selected()
&& let Some(source) = state.export_source_calendars.get(idx)
{
state.export_targets = state
.calendars
.iter()
.filter(|c| {
!c.href.starts_with("local://")
&& !state.disabled_calendars.contains(&c.href)
})
.cloned()
.collect();
if !state.export_targets.is_empty() {
state.export_selection_state.select(Some(0));
state.mode = InputMode::Exporting;
state.message = rust_i18n::t!(
"exporting_select_destination",
name = source.name.clone()
)
.to_string();
} else {
state.mode = InputMode::Normal;
state.message =
rust_i18n::t!("error_no_remote_calendars_export").to_string();
}
}
}
_ => {}
},
InputMode::Exporting => match key.code {
KeyCode::Esc => {
state.mode = InputMode::Normal;
state.message = String::new();
}
KeyCode::Down | KeyCode::Char('j') => state.next_export_target(),
KeyCode::Up | KeyCode::Char('k') => state.previous_export_target(),
KeyCode::Enter => {
if let Some(source_idx) = state.export_source_selection_state.selected()
&& let Some(source) = state.export_source_calendars.get(source_idx)
&& let Some(target_idx) = state.export_selection_state.selected()
&& let Some(target) = state.export_targets.get(target_idx)
{
let source_href = source.href.clone();
let target_href = target.href.clone();
state.mode = InputMode::Normal;
state.message = String::new();
return Some(Action::MigrateLocal(source_href, target_href));
}
}
_ => {}
},
InputMode::RelationshipBrowsing => match key.code {
KeyCode::Esc => {
state.mode = InputMode::Normal;
state.message = String::new();
}
KeyCode::Down | KeyCode::Char('j') => {
let len = state.relationship_items.len();
if len > 0 {
let current = state.relationship_selection_state.selected().unwrap_or(0);
let next = if current >= len - 1 { 0 } else { current + 1 };
state.relationship_selection_state.select(Some(next));
}
}
KeyCode::Up | KeyCode::Char('k') => {
let len = state.relationship_items.len();
if len > 0 {
let current = state.relationship_selection_state.selected().unwrap_or(0);
let prev = if current == 0 { len - 1 } else { current - 1 };
state.relationship_selection_state.select(Some(prev));
}
}
KeyCode::Delete | KeyCode::Char('x') => {
if let Some(idx) = state.relationship_selection_state.selected()
&& let Some((target_uid, _, rel_type)) = state.relationship_items.get(idx)
&& let Some(curr_uid) = state.get_selected_task().map(|t| t.uid.clone())
{
let config = Config::load(state.ctx.as_ref()).unwrap_or_default();
let mut intent = None;
if rel_type == "dependency" {
intent = Some(AppIntent::RemoveDependency {
uid: curr_uid.clone(),
blocker_uid: target_uid.clone(),
});
} else if rel_type == "related_to" {
intent = Some(AppIntent::RemoveRelatedTo {
uid: curr_uid.clone(),
related_uid: target_uid.clone(),
});
} else if rel_type == "related_from" {
intent = Some(AppIntent::RemoveRelatedTo {
uid: target_uid.clone(),
related_uid: curr_uid.clone(),
});
} else if rel_type == "blocking" {
intent = Some(AppIntent::RemoveDependency {
uid: target_uid.clone(),
blocker_uid: curr_uid.clone(),
});
} else if rel_type == "parent" {
intent = Some(AppIntent::RemoveParent {
uid: curr_uid.clone(),
});
}
if let Some(i) = intent {
let actions = state.store.apply_task_intent(&i, &config);
state.refresh_filtered_view();
if !actions.is_empty() {
let tx = action_tx.clone();
tokio::spawn(async move {
let _ = tx.send(Action::PersistBatch(actions)).await;
});
}
}
state.mode = InputMode::Normal;
state.message = String::new();
}
}
KeyCode::Enter => {
if let Some(idx) = state.relationship_selection_state.selected()
&& let Some((target_uid, _, _)) = state.relationship_items.get(idx)
{
let target_uid = target_uid.clone();
if let Some(href) = state.store.index.get(&target_uid).cloned() {
state.active_search_query.clear();
state.selected_categories.clear();
state.selected_locations.clear();
if state.active_cal_href.as_ref() != Some(&href) {
state.active_cal_href = Some(href.clone());
state.hidden_calendars.remove(&href);
}
state.refresh_filtered_view();
if let Some(task_idx) = state.find_task_index_by_uid(&target_uid) {
state.list_state.select(Some(task_idx));
}
state.mode = InputMode::Normal;
state.message = rust_i18n::t!("jumped_to_task").to_string();
} else {
state.message = rust_i18n::t!("error_task_not_found").to_string();
state.mode = InputMode::Normal;
}
}
}
_ => {}
},
}
None
}