use crate::config::Config;
use crate::model::display::random_session_example;
use crate::model::parser::{extract_inline_aliases, validate_alias_integrity};
use crate::model::{AppIntent, CalendarListEntry, Task, TaskStatus};
use crate::storage::{LOCAL_CALENDAR_HREF, LOCAL_TRASH_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 is_undo(key: &KeyEvent) -> bool {
matches!(key.code, KeyCode::Char('z') | KeyCode::Char('Z'))
&& key.modifiers.contains(KeyModifiers::CONTROL)
&& !key.modifiers.contains(KeyModifiers::SHIFT)
}
fn dispatch_intent_tui(state: &mut AppState, intent: AppIntent, action_tx: &Sender<Action>) {
let config = Config::load(state.ctx.as_ref()).unwrap_or_default();
let actions = state.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;
});
}
}
fn handle_alarm_action(
state: &mut AppState,
task_uid: &str,
alarm_uid: &str,
action_tx: &Sender<Action>,
f: impl FnOnce(&mut Task, &str) -> bool,
extra: impl FnOnce(&mut AppState),
) -> bool {
if let Some((t, _)) = state.store.get_task_mut(task_uid)
&& f(t, alarm_uid)
{
t.sequence += 1;
let cloned = t.clone();
state.active_alarm = None;
extra(state);
state.refresh_filtered_view();
update_alarms(state);
let tx = action_tx.clone();
tokio::spawn(async move {
let _ = tx
.send(Action::PersistBatch(vec![crate::journal::Action::Update(
cloned,
)]))
.await;
});
true
} else {
false
}
}
fn is_redo(key: &KeyEvent) -> bool {
(matches!(key.code, KeyCode::Char('y') | KeyCode::Char('Y'))
&& key.modifiers.contains(KeyModifiers::CONTROL))
|| (matches!(key.code, KeyCode::Char('z') | KeyCode::Char('Z'))
&& key.modifiers.contains(KeyModifiers::CONTROL)
&& key.modifiers.contains(KeyModifiers::SHIFT))
}
fn handle_text_undo(state: &mut AppState) {
if let Some(prev) = state.text_history.pop_undo(state.input_buffer.clone()) {
state.input_buffer = prev;
state.cursor_position = state.input_buffer.chars().count();
}
}
fn handle_text_redo(state: &mut AppState) {
if let Some(next) = state.text_history.pop_redo(state.input_buffer.clone()) {
state.input_buffer = next;
state.cursor_position = state.input_buffer.chars().count();
}
}
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_info = !task.description.is_empty()
|| !task.dependencies.is_empty()
|| !task.related_to.is_empty()
|| task.has_blocking_tasks
|| task.has_related_tasks
|| task.parent_uid.is_some()
|| !task.sessions.is_empty()
|| task.time_spent_seconds > 0
|| task.created_date().is_some()
|| task.last_modified_date().is_some()
|| task.goal.is_some()
|| task.rrule.is_some();
for &action in TaskAction::ALL {
if task.is_note
&& matches!(
action,
crate::config::TaskAction::ToggleTimer
| crate::config::TaskAction::StopTimer
| crate::config::TaskAction::AddSession
| crate::config::TaskAction::CompleteAndShift
)
{
continue;
}
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 => task.tree_location_count > 1,
TaskAction::ToggleDetails => has_info,
TaskAction::CompleteAndShift => {
task.rrule.is_some() && !is_done_or_cancelled && !task.is_relative_recurrence()
}
TaskAction::EditTree => true,
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();
let matches_alias = a.search_aliases().contains(&filter.as_str());
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"));
}
}
}
fn open_relationship_browser(state: &mut AppState) -> bool {
let task = match state.get_selected_task() {
Some(t) => t.clone(),
None => return false,
};
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(),
));
}
let mut text = task.summary.clone();
text.push(' ');
text.push_str(&task.description);
let mut seen = std::collections::HashSet::new();
let mut start = 0;
while let Some(idx) = text[start..].find("[[") {
let abs_start = start + idx;
if let Some(end_idx) = text[abs_start..].find("]]") {
let link = &text[abs_start + 2..abs_start + end_idx];
if seen.insert(link.to_string()) {
items.push((
format!("[[{link}]]"),
format!("[Link] {}", link),
"wiki_link".to_string(),
));
}
start = abs_start + end_idx + 2;
} else {
break;
}
}
let mut start = 0;
while let Some(idx) = text[start..].find("http") {
let abs_start = start + idx;
let mut end_idx = abs_start;
for c in text[abs_start..].chars() {
if c.is_whitespace() || c == ']' || c == ')' {
break;
}
end_idx += c.len_utf8();
}
if end_idx > abs_start {
let link = &text[abs_start..end_idx];
if seen.insert(link.to_string()) {
items.push((
link.to_string(),
format!("[URL] {}", link),
"url".to_string(),
));
}
start = end_idx;
} else {
break;
}
}
if items.is_empty() {
state.message = rust_i18n::t!("error_no_related_tasks").to_string();
false
} else {
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"));
true
}
}
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 {
crate::system::open_url(url);
state.message = rust_i18n::t!("open_url").to_string();
}
}
OpenCoordinates => {
if let Some(geo) = &task.geo {
crate::system::open_url(&format!("geo:{}", geo));
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() {
crate::system::open_url(&path.to_string_lossy());
state.message = rust_i18n::t!("action_open_locations").to_string();
} else {
state.message = rust_i18n::t!("error_write_gpx").to_string();
}
}
}
ToggleDetails => {
state.mode = InputMode::ViewingDetails;
state.details_scroll = 0;
state.message = rust_i18n::t!("tui_view_details_help").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!(
"{} ({} {}):",
rust_i18n::t!("tui_log_time_prompt", name = task.summary.clone()),
rust_i18n::t!("eg"),
random_session_example()
);
}
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;
}
EditTree => {
let desc = crate::model::extractor::serialize_task_tree(
&state.store,
&uid,
&state.calendars,
false,
);
match run_external_editor(&desc, state.ctx.as_ref()) {
Ok(Some(new_desc)) => {
if new_desc != desc {
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 is_journal = task.is_journal;
let sync_options = crate::store::SyncTreeOptions {
aliases: &state.tag_aliases,
default_reminder_time: def_time,
trash_retention_days: config.trash_retention_days,
calendars: &state.calendars,
};
match state.store.sync_tree_from_markdown(
&uid,
&new_desc,
&sync_options,
is_journal,
) {
Ok((actions, warnings)) => {
if !warnings.is_empty() {
for w in warnings {
log::warn!("Dependency resolution: {}", w);
}
}
state.refresh_filtered_view();
let _ = action_tx
.try_send(crate::tui::action::Action::PersistBatch(actions));
}
Err(e) => {
state.message = e;
}
}
}
state.needs_redraw = true;
}
Ok(None) => {
state.input_buffer = desc;
state.cursor_position = state.input_buffer.chars().count();
state.edit_scroll_offset = 0;
state.edit_scroll_x = 0;
state.editing_tree_uid = Some(uid.clone());
state.mode = InputMode::EditingTree(uid);
}
Err(e) => {
state.message = e;
state.input_buffer = desc;
state.cursor_position = state.input_buffer.chars().count();
state.edit_scroll_offset = 0;
state.edit_scroll_x = 0;
state.editing_tree_uid = Some(uid.clone());
state.mode = InputMode::EditingTree(uid);
state.needs_redraw = true;
}
}
}
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 });
}
CreateSubtask => {
let mut initial_input = String::new();
for cat in &task.categories {
initial_input.push_str(&format!("#{} ", crate::model::parser::quote_value(cat)));
}
for loc in &task.locations {
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 });
}
Focus => {
intent = Some(AppIntent::FocusTaskTree { uid: Some(uid) });
}
CompleteTree => {
intent = Some(AppIntent::CompleteTree { uid });
}
BrowseRelations => {
open_relationship_browser(state);
}
}
if let Some(i) = intent {
let actions = state.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.sidebar_mode == SidebarMode::Journal
&& !state.creating_with_desc
&& state.editing_tree_uid.is_none()
&& state.journal_editing_uid.is_none()
&& state.editing_uid.is_none()
{
let new_text = state.input_buffer.clone();
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())
})
.unwrap_or_else(|| crate::storage::LOCAL_CALENDAR_HREF.to_string());
let existing_opt = state
.store
.get_journal_entry(&target_href, state.journal_date)
.cloned();
let mut actions = Vec::new();
if let Some(mut existing) = existing_opt {
if new_text.trim().is_empty() {
let _ = state.store.delete_task(&existing.uid);
actions.push(crate::journal::Action::Delete(existing));
} else {
existing.description = new_text;
existing.sequence += 1;
state.store.update_or_add_task(existing.clone());
actions.push(crate::journal::Action::Update(existing));
}
} else if !new_text.trim().is_empty() {
let mut new_journal = crate::model::Task::new("", &state.tag_aliases, None);
new_journal.is_journal = true;
new_journal.calendar_href = target_href;
new_journal.dtstart = Some(crate::model::DateType::AllDay(state.journal_date));
new_journal.summary = state.journal_date.format("%Y-%m-%d").to_string();
new_journal.description = new_text;
state.store.add_task(new_journal.clone());
actions.push(crate::journal::Action::Create(new_journal));
}
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.reset_input();
state.refresh_filtered_view();
return;
}
if let InputMode::EditingTree(ref uid) = state.mode {
let uid = uid.clone();
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 is_journal = state
.store
.get_task_ref(&uid)
.map(|t| t.is_journal)
.unwrap_or(false);
let sync_options = crate::store::SyncTreeOptions {
aliases: &state.tag_aliases,
default_reminder_time: def_time,
trash_retention_days: config.trash_retention_days,
calendars: &state.calendars,
};
match state.store.sync_tree_from_markdown(
&uid,
&state.input_buffer,
&sync_options,
is_journal,
) {
Ok((actions, warnings)) => {
if !warnings.is_empty() {
for w in warnings {
log::warn!("Dependency resolution: {}", w);
}
}
state.refresh_filtered_view();
state.mode = InputMode::Normal;
state.reset_input();
state.editing_tree_uid = None;
let _ = action_tx.try_send(Action::PersistBatch(actions));
}
Err(e) => {
state.message = e;
}
}
return;
}
if state.creating_with_desc {
let desc_text = state.input_buffer.clone();
let (clean_desc, extracted) =
crate::model::extractor::extract_markdown_tasks(&desc_text, false);
let (clean_input_1, new_goals) =
crate::model::parser::extract_inline_goals(&state.new_task_title);
let (clean_input, new_aliases) =
crate::model::parser::extract_inline_aliases(&clean_input_1);
let mut config_changed = false;
if !new_goals.is_empty() {
for (k, v) in new_goals {
state.goals.insert(k, v);
}
config_changed = true;
}
if !new_aliases.is_empty() {
for (k, v) in &new_aliases {
state.tag_aliases.insert(k.clone(), v.clone());
}
config_changed = true;
}
if config_changed && let Ok(mut cfg) = Config::load(state.ctx.as_ref()) {
let old = cfg.clone();
cfg.tag_aliases = state.tag_aliases.clone();
cfg.goals = state.goals.clone();
cfg.update_sync_timestamp_if_changed(&old);
let _ = cfg.save(state.ctx.as_ref());
}
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);
let warnings = state.store.resolve_dependencies(&mut parent);
if !warnings.is_empty() {
for w in warnings {
log::warn!("Dependency resolution: {}", w);
}
}
if parent.summary.trim().is_empty() && !clean_input.trim().is_empty() {
parent.summary = clean_input.clone();
}
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();
let mut resolved_props = std::collections::HashMap::new();
resolved_props.insert(
parent_uid.clone(),
(
parent.categories.clone(),
parent.locations.clone(),
parent.priority,
),
);
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;
let p_uid_str = ext.parent_uid.clone().unwrap_or_else(|| parent_uid.clone());
if let Some((p_cats, p_loc, p_prio)) = resolved_props.get(&p_uid_str) {
sub.inherit_properties(p_cats, p_loc, *p_prio);
}
resolved_props.insert(
sub.uid.clone(),
(sub.categories.clone(), sub.locations.clone(), sub.priority),
);
let warnings = state.store.resolve_dependencies(&mut sub);
if !warnings.is_empty() {
for w in warnings {
log::warn!("Dependency resolution: {}", w);
}
}
if !ext.description.is_empty() {
if sub.description.is_empty() {
sub.description = ext.description;
} else {
sub.description
.push_str(&format!("\n\n{}", ext.description));
}
}
sub.apply_extracted_status(ext.status);
sub.parent_uid = Some(ext.parent_uid.unwrap_or(parent_uid.clone()));
sub.dependencies.extend(ext.dependencies);
sub.dependencies.sort();
sub.dependencies.dedup();
sub.calendar_href = target_href.clone();
if let Some(pc) = ext.percent_complete {
sub.percent_complete = Some(pc);
}
sub.is_note = ext.is_note;
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 desc_text = state.input_buffer.clone();
let is_journal = state
.store
.get_task_ref(&uid)
.map(|t| t.is_journal)
.unwrap_or(false);
let (clean_desc, extracted) =
crate::model::extractor::extract_markdown_tasks(&desc_text, is_journal);
let mut actions = Vec::new();
let mut parent_href = String::new();
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 resolved_props = std::collections::HashMap::new();
if let Some((t_mut, _)) = state.store.get_task_mut(&uid) {
t_mut.description = clean_desc;
t_mut.sequence += 1;
parent_href = t_mut.calendar_href.clone();
resolved_props.insert(
uid.clone(),
(
t_mut.categories.clone(),
t_mut.locations.clone(),
t_mut.priority,
),
);
actions.push(crate::journal::Action::Update(t_mut.clone()));
}
for ext in extracted {
let mut sub = Task::new(&ext.raw_text, &state.tag_aliases, def_time);
sub.uid = ext.uid;
let p_uid_str = ext.parent_uid.clone().unwrap_or_else(|| uid.clone());
if let Some((p_cats, p_loc, p_prio)) = resolved_props.get(&p_uid_str) {
sub.inherit_properties(p_cats, p_loc, *p_prio);
}
resolved_props.insert(
sub.uid.clone(),
(sub.categories.clone(), sub.locations.clone(), sub.priority),
);
let warnings = state.store.resolve_dependencies(&mut sub);
if !warnings.is_empty() {
for w in warnings {
log::warn!("Dependency resolution: {}", w);
}
}
if !ext.description.is_empty() {
if sub.description.is_empty() {
sub.description = ext.description;
} else {
sub.description
.push_str(&format!("\n\n{}", ext.description));
}
}
sub.apply_extracted_status(ext.status);
sub.parent_uid = Some(ext.parent_uid.unwrap_or(uid.clone()));
sub.dependencies = ext.dependencies;
sub.calendar_href = parent_href.clone();
if let Some(pc) = ext.percent_complete {
sub.percent_complete = Some(pc);
}
sub.is_note = ext.is_note;
state.store.add_task(sub.clone());
actions.push(crate::journal::Action::Create(sub));
}
state.refresh_filtered_view();
let _ = action_tx.try_send(Action::PersistBatch(actions));
}
state.mode = InputMode::Normal;
state.reset_input();
state.editing_uid = None;
}
}
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://"));
}
state.calendars = cals;
state.sort_calendars();
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.hidden_calendars.contains(&c.href)
&& !state.disabled_calendars.contains(&c.href)
&& c.href != LOCAL_TRASH_HREF
&& c.href != "local://recovery"
})
.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(sync_task) => {
if let Some((existing, _)) = state.store.get_task_mut(&sync_task.uid) {
existing.href = sync_task.href.clone();
existing.etag = sync_task.etag.clone();
if sync_task.sequence > existing.sequence {
existing.sequence = sync_task.sequence;
}
} else if sync_task.summary.ends_with("(Conflict Copy)") {
state.store.add_task(*sync_task);
state.refresh_filtered_view();
}
}
AppEvent::ConfigUpdated(cfg) => {
state.tag_aliases = cfg.tag_aliases.clone();
state.goals = cfg.goals.clone();
state.show_calendars_tab = cfg.show_calendars_tab;
state.show_tags_tab = cfg.show_tags_tab;
state.show_locations_tab = cfg.show_locations_tab;
state.show_goals_tab = cfg.show_goals_tab;
state.show_journal_tab = cfg.show_journal_tab;
state.verify_sidebar_mode();
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.show_inline_descriptions = cfg.show_inline_descriptions;
state.sort_cutoff_days = cfg.sort_cutoff_days;
state.sort_standard_by_priority = cfg.sort_standard_by_priority;
state.paused_sort_behavior = cfg.paused_sort_behavior;
state.sort_tiebreak_recent = cfg.sort_tiebreak_recent;
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;
state.show_calendars_tab = cfg.show_calendars_tab;
state.show_tags_tab = cfg.show_tags_tab;
state.show_locations_tab = cfg.show_locations_tab;
state.show_goals_tab = cfg.show_goals_tab;
state.show_journal_tab = cfg.show_journal_tab;
let old_mode = state.sidebar_mode;
let valid = match state.sidebar_mode {
SidebarMode::Calendars => state.show_calendars_tab,
SidebarMode::Categories => state.show_tags_tab,
SidebarMode::Locations => state.show_locations_tab,
SidebarMode::Goals => state.show_goals_tab,
SidebarMode::Journal => state.show_journal_tab,
};
if !valid {
state.sidebar_mode = if state.show_calendars_tab {
SidebarMode::Calendars
} else if state.show_tags_tab {
SidebarMode::Categories
} else if state.show_locations_tab {
SidebarMode::Locations
} else if state.show_goals_tab {
SidebarMode::Goals
} else {
SidebarMode::Journal
};
}
if state.sidebar_mode != old_mode {
state.needs_redraw = true;
}
}
}
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') => {
handle_alarm_action(
state,
&task.uid,
&alarm_uid,
action_tx,
|t, a| t.handle_dismiss(a),
|_| {},
);
return None;
}
KeyCode::Char('1') => {
let mins = state.snooze_short_mins;
handle_alarm_action(
state,
&task.uid,
&alarm_uid,
action_tx,
|t, a| t.handle_snooze(a, mins),
|_| {},
);
return None;
}
KeyCode::Char('2') => {
let mins = state.snooze_long_mins;
handle_alarm_action(
state,
&task.uid,
&alarm_uid,
action_tx,
|t, a| t.handle_snooze(a, mins),
|_| {},
);
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;
dispatch_intent_tui(state, AppIntent::ToggleTask { uid }, action_tx);
update_alarms(state);
}
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;
dispatch_intent_tui(state, AppIntent::CancelTask { uid }, action_tx);
update_alarms(state);
}
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;
}
let is_text_undo_mode = matches!(
state.mode,
InputMode::Creating
| InputMode::Editing
| InputMode::EditingDescription
| InputMode::EditingTree(_)
| InputMode::AddingSession
| InputMode::EditingSession(_, _)
| InputMode::JumpingToDate
);
if is_text_undo_mode {
if is_undo(&key) {
handle_text_undo(state);
return None;
}
if is_redo(&key) {
handle_text_redo(state);
return None;
}
if matches!(key.code, KeyCode::Char('o') | KeyCode::Char('O'))
&& key.modifiers.contains(KeyModifiers::CONTROL)
{
let target_text = &state.input_buffer;
let cursor_pos = state.cursor_position;
let line_start = target_text[..cursor_pos]
.rfind('\n')
.map(|i| i + 1)
.unwrap_or(0);
let line_end = target_text[cursor_pos..]
.find('\n')
.map(|i| cursor_pos + i)
.unwrap_or(target_text.len());
let current_line = &target_text[line_start..line_end];
let local_cursor = cursor_pos.saturating_sub(line_start);
let tokens = crate::model::parser::tokenize_smart_input(current_line, false);
let mut active_context = None;
for t in tokens {
if local_cursor >= t.start && local_cursor <= t.end {
if matches!(
t.kind,
crate::model::parser::SyntaxType::Dependency
| crate::model::parser::SyntaxType::Relation
| crate::model::parser::SyntaxType::WikiLink
| crate::model::parser::SyntaxType::Url
) {
active_context = Some((t.kind, current_line[t.start..t.end].to_string()));
}
break;
}
}
if let Some((kind, raw_word)) = active_context {
let clean_uid = if kind == crate::model::parser::SyntaxType::WikiLink {
crate::model::parser::strip_quotes(
raw_word.trim_start_matches("[[").trim_end_matches("]]"),
)
} else if kind == crate::model::parser::SyntaxType::Url {
crate::model::parser::strip_quotes(raw_word.trim_start_matches("url:"))
} else {
let lex_guard = crate::model::parser::LEXICON.read().unwrap();
let lower = raw_word.to_lowercase();
if let Some((p_str, _, _)) = lex_guard.match_prefix(&lower) {
crate::model::parser::strip_quotes(&raw_word[p_str.len()..])
} else {
crate::model::parser::strip_quotes(&raw_word)
}
};
if !clean_uid.is_empty() {
let context_uid = state
.editing_tree_uid
.as_ref()
.or(state.editing_uid.as_ref())
.or(state.journal_editing_uid.as_ref())
.map(|s| s.as_str());
let is_url = clean_uid.contains("://") || clean_uid.starts_with("mailto:");
if is_url {
crate::system::open_url(&clean_uid);
state.message = rust_i18n::t!("open_url").to_string();
} else {
let target_uid =
match state.store.resolve_dependency_ref(&clean_uid, context_uid) {
Ok(resolved_uid) => resolved_uid,
Err(_) => {
if kind == crate::model::parser::SyntaxType::WikiLink {
let config =
crate::config::Config::load(state.ctx.as_ref())
.unwrap_or_default();
let mut new_task = crate::model::Task::new(
&clean_uid,
&state.tag_aliases,
chrono::NaiveTime::parse_from_str(
&config.default_reminder_time,
"%H:%M",
)
.ok(),
);
let ctx_is_journal = context_uid
.and_then(|u| state.store.get_task_ref(u))
.map(|t| t.is_journal)
.unwrap_or(state.sidebar_mode == SidebarMode::Journal);
if ctx_is_journal {
new_task.is_journal = true;
new_task.is_note = true;
}
let target_href = context_uid
.and_then(|u| state.store.get_task_ref(u))
.map(|t| t.calendar_href.clone())
.or_else(|| state.active_cal_href.clone())
.unwrap_or_else(|| {
crate::storage::LOCAL_CALENDAR_HREF.to_string()
});
new_task.calendar_href = target_href;
let new_uid = new_task.uid.clone();
state.store.add_task(new_task.clone());
let tx = action_tx.clone();
tokio::spawn(async move {
let _ = tx
.send(crate::tui::action::Action::PersistBatch(
vec![crate::journal::Action::Create(new_task)],
))
.await;
});
new_uid
} else {
state.message = format!("Searching: '{}'", clean_uid);
state.input_buffer = clean_uid;
state.active_search_query = state.input_buffer.clone();
state.search_collapsed_tasks.clear();
state.selected_categories.clear();
state.selected_locations.clear();
state.refresh_filtered_view();
state.mode = InputMode::Normal;
return None;
}
}
};
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);
}
if let Some(task) = state.store.get_task_ref(&target_uid).cloned() {
if task.status.is_done() && state.hide_completed {
state.hide_completed = false;
}
if task.status.is_done() {
let group_key = task.parent_uid.clone().unwrap_or_default();
state.expanded_done_groups.insert(group_key);
}
let mut to_uncollapse = Vec::new();
for p_uid in state.store.collect_ancestor_uids(&task.uid) {
if let Some(p_task) = state.store.get_task_ref(&p_uid)
&& p_task.collapsed
{
to_uncollapse.push(p_uid);
}
}
if !to_uncollapse.is_empty() {
let config = crate::config::Config::load(state.ctx.as_ref())
.unwrap_or_default();
let mut actions = Vec::new();
for p_uid in to_uncollapse {
let intent = crate::model::AppIntent::SetTreeCollapse {
uid: p_uid,
collapsed: false,
};
actions.extend(state.apply_task_intent(&intent, &config));
}
if !actions.is_empty() {
let tx = action_tx.clone();
tokio::spawn(async move {
let _ = tx
.send(crate::tui::action::Action::PersistBatch(
actions,
))
.await;
});
}
}
if task.is_journal {
state.sidebar_mode = SidebarMode::Journal;
state.journal_editing_uid = Some(target_uid.clone());
}
}
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();
}
}
}
}
return None;
}
}
match state.mode {
InputMode::Creating => match key.code {
KeyCode::Char('e') | KeyCode::Char('E')
if key.modifiers.contains(KeyModifiers::CONTROL) =>
{
state.creating_with_desc = true;
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;
}
}
}
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_1, new_goals) =
crate::model::parser::extract_inline_goals(&state.input_buffer);
let (clean_input, new_aliases): (String, HashMap<String, Vec<String>>) =
extract_inline_aliases(&clean_input_1);
let mut config_changed = false;
if !new_goals.is_empty() {
for (k, v) in new_goals {
state.goals.insert(k, v);
}
config_changed = true;
}
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),
]));
}
config_changed = true;
}
}
if config_changed && let Ok(mut cfg) = Config::load(state.ctx.as_ref()) {
let old = cfg.clone();
cfg.tag_aliases = state.tag_aliases.clone();
cfg.goals = state.goals.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.contains(":=") || trimmed.to_lowercase().starts_with("loc:")));
if trimmed.starts_with(':') && !trimmed.contains(' ') {
match trimmed.to_lowercase().as_str() {
":undo" => {
if let Some(record) = state.undo_history.pop_undo() {
state.store.apply_actions(&record.reverse);
state.undo_history.push_redo(record.clone());
state.refresh_filtered_view();
if let Some(uid) = &record.primary_uid
&& let Some(idx) = state.find_task_index_by_uid(uid)
{
state.list_state.select(Some(idx));
}
let tx = action_tx.clone();
tokio::spawn(async move {
let _ = tx.send(Action::PersistBatch(record.reverse)).await;
});
state.message =
rust_i18n::t!("task_action_undone", desc = record.description)
.to_string();
}
}
":redo" => {
if let Some(record) = state.undo_history.pop_redo() {
state.store.apply_actions(&record.forward);
state.undo_history.push_undo(record.clone());
state.refresh_filtered_view();
if let Some(uid) = &record.primary_uid
&& let Some(idx) = state.find_task_index_by_uid(uid)
{
state.list_state.select(Some(idx));
}
let tx = action_tx.clone();
tokio::spawn(async move {
let _ = tx.send(Action::PersistBatch(record.forward)).await;
});
state.message =
rust_i18n::t!("task_action_redone", desc = record.description)
.to_string();
}
}
":empty-trash" => {
let tx = action_tx.clone();
let store_arc =
std::sync::Arc::new(tokio::sync::Mutex::new(state.store.clone()));
let ctrl = crate::controller::TaskController::new(
store_arc,
std::sync::Arc::new(tokio::sync::Mutex::new(None)),
state.ctx.clone(),
);
tokio::spawn(async move {
let _ = ctrl.empty_trash().await;
let _ = tx.send(crate::tui::action::Action::Refresh).await;
});
}
_ => {}
}
state.mode = InputMode::Normal;
state.reset_input();
return None;
}
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);
let warnings = state.store.resolve_dependencies(&mut task);
if !warnings.is_empty() {
for w in warnings {
log::warn!("Dependency resolution: {}", w);
}
}
if task.summary.trim().is_empty() && task.description.trim().is_empty() {
if !clean_input.trim().is_empty() {
task.summary = clean_input.clone();
} else {
state.mode = InputMode::Normal;
state.reset_input();
state.creating_child_of = None;
return None;
}
}
task.calendar_href = href.clone();
task.parent_uid = state.creating_child_of.clone();
if let Some(target) = task.target_collection.take() {
task.calendar_href = crate::model::resolve_collection(
&target,
&state.calendars,
&task.calendar_href,
);
}
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.creating_child_of = None;
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_1, new_goals) =
crate::model::parser::extract_inline_goals(&state.input_buffer);
let (clean_input, new_aliases): (String, HashMap<String, Vec<String>>) =
extract_inline_aliases(&clean_input_1);
let mut config_changed = false;
if !new_goals.is_empty() {
for (k, v) in new_goals {
state.goals.insert(k, v);
}
config_changed = true;
}
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),
]));
}
config_changed = true;
}
}
if config_changed && let Ok(mut cfg) = Config::load(state.ctx.as_ref()) {
let old = cfg.clone();
cfg.tag_aliases = state.tag_aliases.clone();
cfg.goals = state.goals.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_ref) = state.store.get_task_ref(&uid)
{
let mut t = t_ref.clone();
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);
let warnings = state.store.resolve_dependencies(&mut t);
if !warnings.is_empty() {
for w in warnings {
log::warn!("Dependency resolution: {}", w);
}
}
if let Some(target) = t.target_collection.take() {
t.calendar_href = crate::model::resolve_collection(
&target,
&state.calendars,
&t.calendar_href,
);
}
t.sequence += 1;
let clone = t.clone();
state.store.update_or_add_task(t);
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();
state.editing_uid = 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::EditingDescription | InputMode::EditingTree(_) => match key.code {
KeyCode::Enter => {
state.enter_char('\n');
let text_up_to_cursor = &state.input_buffer[..state.cursor_position];
let lines: Vec<&str> = text_up_to_cursor.split('\n').collect();
if lines.len() >= 2 {
let prev_line = lines[lines.len() - 2];
let prefix = crate::model::extractor::extract_list_prefix(prev_line);
if !prefix.is_empty() {
if prev_line.trim() == prefix.trim() {
state.delete_char(); for _ in 0..prefix.chars().count() {
state.delete_char();
}
} else {
for c in prefix.chars() {
state.enter_char(c);
}
}
}
}
}
KeyCode::Char('s') | KeyCode::Char('d')
if key.modifiers.contains(KeyModifiers::CONTROL) =>
{
save_description(state, action_tx);
return None;
}
KeyCode::Char('e') | KeyCode::Char('E')
if key.modifiers.contains(KeyModifiers::CONTROL) =>
{
if state.creating_with_desc {
return None; }
let was_tree = matches!(state.mode, InputMode::EditingTree(_));
let uid = if let InputMode::EditingTree(ref u) = state.mode {
u.clone()
} else if let Some(u) = &state.editing_uid {
u.clone()
} else {
return None;
};
save_description(state, action_tx);
if was_tree {
if let Some(t) = state.store.get_task_ref(&uid) {
state.input_buffer = t.description.clone();
state.cursor_position = state.input_buffer.chars().count();
state.edit_scroll_offset = 0;
state.edit_scroll_x = 0;
state.editing_uid = Some(uid);
state.mode = InputMode::EditingDescription;
}
} else {
let is_journal = state
.store
.get_task_ref(&uid)
.map(|t| t.is_journal)
.unwrap_or(false);
let desc = crate::model::extractor::serialize_task_tree(
&state.store,
&uid,
&state.calendars,
is_journal,
);
state.input_buffer = desc;
state.cursor_position = state.input_buffer.chars().count();
state.edit_scroll_offset = 0;
state.edit_scroll_x = 0;
state.editing_tree_uid = Some(uid.clone());
state.mode = InputMode::EditingTree(uid);
}
state.needs_redraw = true;
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.editing_uid = None;
state.editing_tree_uid = None;
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() {
handle_alarm_action(
state,
&task.uid,
&alarm_uid,
action_tx,
|t, a| t.snooze_alarm(a, mins),
|s| {
s.mode = InputMode::Normal;
s.reset_input();
},
);
}
} 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();
let tags = crate::model::parser::resolve_selection_aliases(
&tag,
false,
&state.tag_aliases,
);
state.sidebar_mode = SidebarMode::Categories;
state.selected_categories.clear();
for t in tags {
state.selected_categories.insert(t);
}
state.active_search_query.clear();
state.search_collapsed_tasks.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);
let locs = crate::model::parser::resolve_selection_aliases(
&loc,
true,
&state.tag_aliases,
);
state.sidebar_mode = SidebarMode::Locations;
state.selected_locations.clear();
for l in locs {
state.selected_locations.insert(l);
}
state.active_search_query.clear();
state.search_collapsed_tasks.clear();
} else {
state.active_search_query = state.input_buffer.clone();
if state.sidebar_mode == SidebarMode::Journal {
state.sidebar_mode = SidebarMode::Calendars;
state.active_focus = Focus::Main;
}
}
state.mode = InputMode::Normal;
state.reset_input();
state.refresh_filtered_view();
}
KeyCode::Esc => {
state.active_search_query.clear();
state.search_collapsed_tasks.clear();
state.mode = InputMode::Normal;
state.reset_input();
state.refresh_filtered_view();
}
KeyCode::Char(c) => {
state.enter_char(c);
if state.input_buffer.is_empty() {
state.search_collapsed_tasks.clear();
}
state.refresh_filtered_view();
}
KeyCode::Backspace => {
state.delete_char();
if state.input_buffer.is_empty() {
state.search_collapsed_tasks.clear();
}
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::ViewingDetails => match key.code {
KeyCode::Esc | KeyCode::Char('q') | KeyCode::Enter => {
state.mode = InputMode::Normal;
state.message = String::new();
}
KeyCode::Down | KeyCode::Char('j') => {
state.details_scroll = state.details_scroll.saturating_add(1);
}
KeyCode::Up | KeyCode::Char('k') => {
state.details_scroll = state.details_scroll.saturating_sub(1);
}
KeyCode::PageDown => {
state.details_scroll = state.details_scroll.saturating_add(10);
}
KeyCode::PageUp => {
state.details_scroll = state.details_scroll.saturating_sub(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::JumpingToDate => match key.code {
KeyCode::Enter => {
let input = state.input_buffer.trim();
if let Ok(d) = chrono::NaiveDate::parse_from_str(input, "%Y-%m-%d") {
state.journal_date = d;
state.journal_editing_uid = None;
state.update_journal_editing_uid();
state.mode = InputMode::Normal;
state.input_buffer.clear();
state.message = String::new();
state.refresh_filtered_view();
} else {
state.message = rust_i18n::t!("error_format", msg = "YYYY-MM-DD").to_string();
}
}
KeyCode::Esc => {
state.mode = InputMode::Normal;
state.input_buffer.clear();
state.message = String::new();
}
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::Normal => match key.code {
KeyCode::Char('n') | KeyCode::Char('N')
if key.modifiers.contains(KeyModifiers::CONTROL) =>
{
state.mode = InputMode::Creating;
state.creating_with_desc = true;
state.reset_input();
state.new_task_title.clear();
if state.sidebar_mode == SidebarMode::Journal {
state.input_buffer = "is:page ".to_string();
state.cursor_position = state.input_buffer.chars().count();
}
state.message = rust_i18n::t!("task_title_prompt").to_string();
}
KeyCode::Char('e') | KeyCode::Char('E')
if key.modifiers.contains(KeyModifiers::CONTROL) =>
{
let mut target_uid = None;
if state.active_focus == Focus::Sidebar
&& state.sidebar_mode == SidebarMode::Journal
{
if let Some(idx) = state.cal_state.selected()
&& let Some(page) = state.cached_journal_pages.get(idx)
&& page.is_task
{
target_uid = Some(page.key.clone());
}
} else if state.sidebar_mode == SidebarMode::Journal {
target_uid = state.journal_editing_uid.clone().or_else(|| {
let target_href = state
.active_cal_href
.clone()
.unwrap_or_else(|| crate::storage::LOCAL_CALENDAR_HREF.to_string());
state
.store
.get_journal_entry(&target_href, state.journal_date)
.map(|t| t.uid.clone())
});
} else if let Some(t) = state.get_selected_task() {
target_uid = Some(t.uid.clone());
}
if let Some(uid) = target_uid {
let is_journal = state
.store
.get_task_ref(&uid)
.map(|t| t.is_journal)
.unwrap_or(false);
let desc = crate::model::extractor::serialize_task_tree(
&state.store,
&uid,
&state.calendars,
is_journal,
);
match run_external_editor(&desc, state.ctx.as_ref()) {
Ok(Some(new_desc)) => {
if new_desc != desc {
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 sync_options = crate::store::SyncTreeOptions {
aliases: &state.tag_aliases,
default_reminder_time: def_time,
trash_retention_days: config.trash_retention_days,
calendars: &state.calendars,
};
match state.store.sync_tree_from_markdown(
&uid,
&new_desc,
&sync_options,
is_journal,
) {
Ok((actions, warnings)) => {
if !warnings.is_empty() {
for w in warnings {
log::warn!("Dependency resolution: {}", w);
}
}
state.refresh_filtered_view();
let _ = action_tx.try_send(
crate::tui::action::Action::PersistBatch(actions),
);
}
Err(e) => {
state.message = e;
}
}
}
state.needs_redraw = true;
}
Ok(None) => {
state.input_buffer = desc;
state.cursor_position = state.input_buffer.chars().count();
state.edit_scroll_offset = 0;
state.edit_scroll_x = 0;
state.editing_tree_uid = Some(uid.clone());
state.mode = InputMode::EditingTree(uid);
}
Err(e) => {
state.message = e;
state.input_buffer = desc;
state.cursor_position = state.input_buffer.chars().count();
state.edit_scroll_offset = 0;
state.edit_scroll_x = 0;
state.editing_tree_uid = Some(uid.clone());
state.mode = InputMode::EditingTree(uid);
state.needs_redraw = true;
}
}
}
}
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.sidebar_mode == SidebarMode::Journal
&& state.journal_editing_uid.is_some()
{
state.journal_editing_uid = None;
needs_refresh = true;
} else if state.focused_task_uid.is_some() {
let intent = AppIntent::FocusTaskTree { uid: None };
let config = Config::load(state.ctx.as_ref()).unwrap_or_default();
let _ = state.apply_task_intent(&intent, &config);
needs_refresh = true;
} else if !state.active_search_query.is_empty() {
state.active_search_query.clear();
state.search_collapsed_tasks.clear();
needs_refresh = true;
} else if !state.selected_categories.is_empty() {
state.selected_categories.clear();
needs_refresh = true;
} else if !state.selected_locations.is_empty() {
state.selected_locations.clear();
needs_refresh = true;
}
if needs_refresh {
state.refresh_filtered_view();
}
}
KeyCode::Down if key.modifiers.contains(KeyModifiers::SHIFT) => {
state.details_scroll = state.details_scroll.saturating_add(1);
}
KeyCode::Up if key.modifiers.contains(KeyModifiers::SHIFT) => {
state.details_scroll = state.details_scroll.saturating_sub(1);
}
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('f') => {
let intent = AppIntent::FocusTaskTree {
uid: state.get_selected_task().map(|t| t.uid.clone()),
};
let config = Config::load(state.ctx.as_ref()).unwrap_or_default();
let _ = state.apply_task_intent(&intent, &config);
state.refresh_filtered_view();
}
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 state.sidebar_mode == SidebarMode::Journal {
state.journal_date = chrono::Local::now().date_naive();
state.journal_editing_uid = None;
state.update_journal_editing_uid();
state.refresh_filtered_view();
} else if let Some(summary) = state.get_selected_task().map(|t| t.summary.clone()) {
state.mode = InputMode::AddingSession;
state.reset_input();
state.message = format!(
"{} ({} {}):",
t!("tui_log_time_prompt", name = summary),
t!("eg"),
random_session_example()
);
}
}
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!(
"{} {}-{} ({})",
s_dt.format("%Y-%m-%d"),
s_dt.format("%H:%M"),
e_dt.format("%H:%M"),
crate::model::parser::format_duration_human(dur as u32)
);
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" || view_task.is_note {
return None;
}
let uid = view_task.uid.clone();
let intent = if is_shift {
if view_task.rrule.is_some() {
AppIntent::ToggleTaskShift { uid: uid.clone() }
} else if view_task.has_subtasks {
AppIntent::CompleteTree { uid: uid.clone() }
} else {
AppIntent::ToggleTask { uid: uid.clone() }
}
} else {
AppIntent::ToggleTask { uid: uid.clone() }
};
dispatch_intent_tui(state, intent, action_tx);
update_alarms(state);
}
} 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 intent = if task.status == TaskStatus::InProcess {
AppIntent::PauseTask { uid }
} else {
AppIntent::StartTask { uid }
};
dispatch_intent_tui(state, intent, action_tx);
return None;
}
}
KeyCode::Char('S') => {
if let Some(uid) = state.get_selected_task().map(|t| t.uid.clone()) {
dispatch_intent_tui(state, AppIntent::StopTask { uid }, action_tx);
return None;
}
}
KeyCode::Char('x') => {
if state.active_focus == Focus::Main {
if state.sidebar_mode == SidebarMode::Journal {
} else if let Some(uid) = state.get_selected_task().map(|t| t.uid.clone()) {
dispatch_intent_tui(state, AppIntent::CancelTask { uid }, action_tx);
update_alarms(state);
}
} else if state.active_focus == Focus::Sidebar
&& state.sidebar_mode == SidebarMode::Goals
&& let Some(idx) = state.cal_state.selected()
{
let mut keys: Vec<_> = state.goals.keys().cloned().collect();
keys.sort();
if idx < keys.len()
&& let Some(key) = keys.get(idx)
{
state.goals.remove(key);
if let Ok(mut cfg) = Config::load(state.ctx.as_ref()) {
let old = cfg.clone();
cfg.goals = state.goals.clone();
cfg.update_sync_timestamp_if_changed(&old);
let _ = cfg.save(state.ctx.as_ref());
}
state.refresh_filtered_view();
}
} else if state.sidebar_mode == SidebarMode::Journal
&& state.active_focus == Focus::Sidebar
&& let Some(idx) = state.cal_state.selected()
&& let Some(page) = state.cached_journal_pages.get(idx)
&& page.is_task
{
let uid = page.key.clone();
let uid_clone = uid.clone();
dispatch_intent_tui(state, AppIntent::DeleteTaskTree { uid }, action_tx);
if state.journal_editing_uid == Some(uid_clone) {
state.journal_editing_uid = None;
}
}
}
KeyCode::Char('+') => {
if let Some(uid) = state.get_selected_task().map(|t| t.uid.clone()) {
dispatch_intent_tui(
state,
AppIntent::ChangePriority { uid, delta: 1 },
action_tx,
);
}
}
KeyCode::Char('-') => {
if let Some(uid) = state.get_selected_task().map(|t| t.uid.clone()) {
dispatch_intent_tui(
state,
AppIntent::ChangePriority { uid, delta: -1 },
action_tx,
);
}
}
KeyCode::Delete => {
if state.active_focus == Focus::Main {
if state.sidebar_mode == SidebarMode::Journal {
let uid_opt = if let Some(uid) = &state.journal_editing_uid {
Some(uid.clone())
} else {
let active_href = state
.active_cal_href
.clone()
.unwrap_or_else(|| crate::storage::LOCAL_CALENDAR_HREF.to_string());
state
.store
.get_journal_entry(&active_href, state.journal_date)
.map(|t| t.uid.clone())
};
if let Some(uid) = uid_opt {
let config = Config::load(state.ctx.as_ref()).unwrap_or_default();
let intent = AppIntent::DeleteTaskTree { uid: uid.clone() };
let actions = state.apply_task_intent(&intent, &config);
state.refresh_filtered_view();
if state.journal_editing_uid == Some(uid) {
state.journal_editing_uid = None;
}
if !actions.is_empty() {
let tx = action_tx.clone();
tokio::spawn(async move {
let _ = tx.send(Action::PersistBatch(actions)).await;
});
}
}
} else if let Some(uid) = state.get_selected_task().map(|t| t.uid.clone()) {
let intent = if key.modifiers.contains(KeyModifiers::CONTROL) {
AppIntent::DeleteTaskTree { uid }
} else {
AppIntent::DeleteTask { uid }
};
dispatch_intent_tui(state, intent, action_tx);
update_alarms(state);
}
} else if state.active_focus == Focus::Sidebar
&& state.sidebar_mode == SidebarMode::Goals
&& let Some(idx) = state.cal_state.selected()
{
let mut keys: Vec<_> = state.goals.keys().cloned().collect();
keys.sort();
if idx < keys.len()
&& let Some(key) = keys.get(idx)
{
state.goals.remove(key);
if let Ok(mut cfg) = Config::load(state.ctx.as_ref()) {
let old = cfg.clone();
cfg.goals = state.goals.clone();
cfg.update_sync_timestamp_if_changed(&old);
let _ = cfg.save(state.ctx.as_ref());
}
state.refresh_filtered_view();
}
} else if state.active_focus == Focus::Sidebar
&& state.sidebar_mode == SidebarMode::Journal
&& let Some(idx) = state.cal_state.selected()
&& let Some(page) = state.cached_journal_pages.get(idx)
&& page.is_task
{
let uid = page.key.clone();
let uid_clone = uid.clone();
dispatch_intent_tui(state, AppIntent::DeleteTaskTree { uid }, action_tx);
if state.journal_editing_uid == Some(uid_clone) {
state.journal_editing_uid = None;
}
}
}
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 {
dispatch_intent_tui(
state,
AppIntent::MakeChild {
uid: child_uid,
parent_uid,
},
action_tx,
);
if !state.yank_lock_active {
state.yanked_uid = None;
}
}
}
KeyCode::Char('C') => {
let mut target_uid = None;
if state.active_focus == Focus::Main {
if state.sidebar_mode == SidebarMode::Journal {
target_uid = state.journal_editing_uid.clone().or_else(|| {
let target_href = state
.active_cal_href
.clone()
.unwrap_or_else(|| crate::storage::LOCAL_CALENDAR_HREF.to_string());
state
.store
.get_journal_entry(&target_href, state.journal_date)
.map(|t| t.uid.clone())
});
} else if let Some(task) = state.get_selected_task() {
target_uid = Some(task.uid.clone());
}
} else if state.active_focus == Focus::Sidebar
&& state.sidebar_mode == SidebarMode::Journal
&& let Some(idx) = state.cal_state.selected()
&& let Some(page) = state.cached_journal_pages.get(idx)
&& page.is_task
{
target_uid = Some(page.key.clone());
}
if let Some(uid) = target_uid {
if state.sidebar_mode == SidebarMode::Journal {
let summary = state.store.get_summary(&uid).unwrap_or_default();
state.input_buffer = "is:page ".to_string();
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();
} else if let Some(task) = state.store.get_task_ref(&uid) {
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)));
}
for loc in &task.locations {
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.clone());
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()) {
dispatch_intent_tui(state, AppIntent::DuplicateTaskTree { uid }, action_tx);
}
}
KeyCode::Char('[') => {
if state.sidebar_mode == SidebarMode::Journal && state.active_focus == Focus::Main {
let mut visible_cals = Vec::new();
for c in &state.calendars {
let supports = if c.href.starts_with("local://") {
true
} else {
c.supports_vjournal.unwrap_or(false)
};
if !state.hidden_calendars.contains(&c.href)
&& !state.disabled_calendars.contains(&c.href)
&& c.href != crate::storage::LOCAL_TRASH_HREF
&& c.href != "local://recovery"
&& supports
{
visible_cals.push(c);
}
}
if !visible_cals.is_empty() {
let current_idx = visible_cals
.iter()
.position(|c| Some(&c.href) == state.active_cal_href.as_ref())
.unwrap_or(0);
let prev_idx = if current_idx == 0 {
visible_cals.len() - 1
} else {
current_idx - 1
};
state.active_cal_href = Some(visible_cals[prev_idx].href.clone());
state.journal_editing_uid = None;
state.refresh_filtered_view();
}
}
}
KeyCode::Char(']') => {
if state.sidebar_mode == SidebarMode::Journal && state.active_focus == Focus::Main {
let mut visible_cals = Vec::new();
for c in &state.calendars {
let supports = if c.href.starts_with("local://") {
true
} else {
c.supports_vjournal.unwrap_or(false)
};
if !state.hidden_calendars.contains(&c.href)
&& !state.disabled_calendars.contains(&c.href)
&& c.href != crate::storage::LOCAL_TRASH_HREF
&& c.href != "local://recovery"
&& supports
{
visible_cals.push(c);
}
}
if !visible_cals.is_empty() {
let current_idx = visible_cals
.iter()
.position(|c| Some(&c.href) == state.active_cal_href.as_ref())
.unwrap_or(0);
let next_idx = (current_idx + 1) % visible_cals.len();
state.active_cal_href = Some(visible_cals[next_idx].href.clone());
state.journal_editing_uid = None;
state.refresh_filtered_view();
}
}
}
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 state.sidebar_mode == SidebarMode::Journal && state.active_focus == Focus::Main {
state.mode = InputMode::JumpingToDate;
state.input_buffer.clear();
state.cursor_position = 0;
state.message = rust_i18n::t!("journal_date_placeholder").to_string();
} else if let Some(task) = state.get_selected_task() {
let uid = task.uid.clone();
let count = task.tree_location_count;
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() {
crate::system::open_url(&path.to_string_lossy());
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 {
crate::system::open_url(&format!("geo:{}", _geo));
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 {
crate::system::open_url(_url);
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 {
dispatch_intent_tui(
state,
AppIntent::AddDependency {
uid: curr_uid,
blocker_uid: yanked_uid,
},
action_tx,
);
if !state.yank_lock_active {
state.yanked_uid = None;
}
}
}
}
KeyCode::Char('l') => {
if state.sidebar_mode == SidebarMode::Journal && state.active_focus == Focus::Main {
state.journal_date += chrono::Duration::days(1);
state.journal_editing_uid = None;
state.refresh_filtered_view();
} else {
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 {
dispatch_intent_tui(
state,
AppIntent::AddRelatedTo {
uid: curr_uid,
related_uid: yanked_uid,
},
action_tx,
);
if !state.yank_lock_active {
state.yanked_uid = None;
}
}
}
}
}
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.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();
dispatch_intent_tui(state, AppIntent::RemoveParent { uid }, action_tx);
}
}
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') => {
let task_opt = if state.sidebar_mode == SidebarMode::Journal
&& state.active_focus == Focus::Main
{
let active_href = state
.active_cal_href
.clone()
.unwrap_or_else(|| crate::storage::LOCAL_CALENDAR_HREF.to_string());
if let Some(uid) = &state.journal_editing_uid {
state.store.get_task_ref(uid).cloned()
} else {
state
.store
.get_journal_entry(&active_href, state.journal_date)
.cloned()
}
} else {
state.get_selected_task().cloned()
};
if let Some(task) = task_opt {
let current_href = task.calendar_href.clone();
let has_subtasks = task.has_subtasks;
let include_current = has_subtasks;
let move_targets: Vec<CalendarListEntry> = state
.calendars
.iter()
.filter(|c| {
(include_current || c.href != current_href)
&& !state.disabled_calendars.contains(&c.href)
&& c.href != crate::storage::LOCAL_TRASH_HREF
&& c.href != "local://recovery"
})
.cloned()
.collect();
if !move_targets.is_empty() {
state.move_targets = move_targets;
state.move_selection_state.select(Some(0));
state.mode = InputMode::Moving;
state.moving_tree = has_subtasks;
state.moving_task_uid = Some(task.uid.clone());
state.message = if has_subtasks {
format!(
"{} ({})",
rust_i18n::t!("tui_select_calendar_prompt"),
rust_i18n::t!("tui_move_toggle")
)
} else {
rust_i18n::t!("tui_select_calendar_prompt").to_string()
};
}
}
}
KeyCode::Down | KeyCode::Char('j') => {
if state.sidebar_mode == SidebarMode::Journal && state.active_focus == Focus::Main {
state.journal_date += chrono::Duration::days(7);
state.journal_editing_uid = None;
state.refresh_filtered_view();
} else {
state.next();
}
}
KeyCode::Up | KeyCode::Char('k') => {
if state.sidebar_mode == SidebarMode::Journal && state.active_focus == Focus::Main {
state.journal_date -= chrono::Duration::days(7);
state.journal_editing_uid = None;
state.refresh_filtered_view();
} else {
state.previous();
}
}
KeyCode::PageDown => state.jump_forward(10),
KeyCode::PageUp => state.jump_backward(10),
KeyCode::Tab => state.toggle_focus(),
KeyCode::Char('1') => {
if state.show_calendars_tab {
state.sidebar_mode = SidebarMode::Calendars;
state.refresh_filtered_view();
}
}
KeyCode::Char('2') => {
if state.show_tags_tab {
state.sidebar_mode = SidebarMode::Categories;
state.refresh_filtered_view();
}
}
KeyCode::Char('z') | KeyCode::Char('Z') if is_undo(&key) => {
if let Some(record) = state.undo_history.pop_undo() {
state.store.apply_actions(&record.reverse);
state.undo_history.push_redo(record.clone());
state.refresh_filtered_view();
if let Some(uid) = &record.primary_uid
&& let Some(idx) = state.find_task_index_by_uid(uid)
{
state.list_state.select(Some(idx));
}
let tx = action_tx.clone();
tokio::spawn(async move {
let _ = tx.send(Action::PersistBatch(record.reverse)).await;
});
state.message =
rust_i18n::t!("task_action_undone", desc = record.description).to_string();
}
}
KeyCode::Char('z') => {
if state.active_focus == Focus::Main {
if let Some(task) = state.get_selected_task() {
let uid = task.uid.clone();
let new_state = !task.collapsed;
let is_searching = state.mode == InputMode::Searching
|| !state.active_search_query.is_empty();
if is_searching {
if new_state {
state.search_collapsed_tasks.insert(uid.clone());
} else {
state.search_collapsed_tasks.remove(&uid);
}
}
dispatch_intent_tui(
state,
AppIntent::SetTreeCollapse {
uid,
collapsed: new_state,
},
action_tx,
);
}
} 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());
}
}
}
SidebarMode::Journal => {
if let Some(idx) = state.cal_state.selected()
&& let Some(page) = state.cached_journal_pages.get(idx)
{
if page.is_task {
dispatch_intent_tui(
state,
AppIntent::ToggleTreeCollapse {
uid: page.key.clone(),
},
action_tx,
);
} else {
let key = page.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());
}
}
}
}
_ => {}
}
}
}
KeyCode::Char('3') => {
if state.show_locations_tab {
state.sidebar_mode = SidebarMode::Locations;
state.refresh_filtered_view();
}
}
KeyCode::Char('4') => {
if state.show_goals_tab {
state.sidebar_mode = SidebarMode::Goals;
state.refresh_filtered_view();
}
}
KeyCode::Char('5') => {
if state.show_journal_tab {
state.sidebar_mode = SidebarMode::Journal;
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') => {
open_relationship_browser(state);
}
KeyCode::Char('*') => {
let mut needs_refresh = false;
if !state.active_search_query.is_empty() {
state.active_search_query.clear();
state.search_collapsed_tasks.clear();
needs_refresh = true;
}
if state.focused_task_uid.is_some() {
let intent = AppIntent::FocusTaskTree { uid: None };
let config = Config::load(state.ctx.as_ref()).unwrap_or_default();
let _ = state.apply_task_intent(&intent, &config);
needs_refresh = true;
}
if !state.selected_categories.is_empty() {
state.selected_categories.clear();
needs_refresh = true;
}
if !state.selected_locations.is_empty() {
state.selected_locations.clear();
needs_refresh = true;
}
if needs_refresh {
state.refresh_filtered_view();
} else {
if 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;
}
state.refresh_filtered_view();
} else if state.sidebar_mode == SidebarMode::Journal {
state.active_focus = Focus::Main;
}
}
}
KeyCode::Left | KeyCode::Char('h') => {
if state.sidebar_mode == SidebarMode::Journal && state.active_focus == Focus::Main {
state.journal_date -= chrono::Duration::days(1);
state.journal_editing_uid = None;
state.refresh_filtered_view();
} else {
state.move_cursor_left()
}
}
KeyCode::Right => {
if state.sidebar_mode == SidebarMode::Journal && state.active_focus == Focus::Main {
state.journal_date += chrono::Duration::days(1);
state.journal_editing_uid = None;
state.refresh_filtered_view();
} 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.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());
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());
state.refresh_filtered_view();
}
}
SidebarMode::Goals => {
let mut keys: Vec<_> = state.goals.keys().cloned().collect();
keys.sort();
if let Some(idx) = state.cal_state.selected() {
if idx < keys.len() {
let key = &keys[idx];
if key.starts_with('#') {
state.sidebar_mode = SidebarMode::Categories;
state.selected_categories.clear();
let tag = key.trim_start_matches('#').to_string();
let tags = crate::model::parser::resolve_selection_aliases(
&tag,
false,
&state.tag_aliases,
);
for t in tags {
state.selected_categories.insert(t);
}
} else if key.starts_with("@@") {
state.sidebar_mode = SidebarMode::Locations;
state.selected_locations.clear();
let loc = key.trim_start_matches("@@").to_string();
let locs = crate::model::parser::resolve_selection_aliases(
&loc,
true,
&state.tag_aliases,
);
for l in locs {
state.selected_locations.insert(l);
}
}
state.refresh_filtered_view();
} else {
let task_idx = idx - keys.len();
if let Some(task_goal) = state.cached_task_goals.get(task_idx) {
let target_uid = task_goal.0.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(t_idx) =
state.find_task_index_by_uid(&target_uid)
{
state.list_state.select(Some(t_idx));
}
state.mode = InputMode::Normal;
state.message =
rust_i18n::t!("jumped_to_task").to_string();
}
}
}
}
}
SidebarMode::Journal => {
if let Some(idx) = state.cal_state.selected()
&& let Some(page) = state.cached_journal_pages.get(idx)
{
if page.is_task {
dispatch_intent_tui(
state,
AppIntent::ToggleTreeCollapse {
uid: page.key.clone(),
},
action_tx,
);
} else {
let key = page.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());
}
}
}
}
}
} else if state.mode == InputMode::Editing {
state.move_cursor_right();
}
}
KeyCode::Enter => {
if state.sidebar_mode == SidebarMode::Journal && state.active_focus == Focus::Main {
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())
})
.unwrap_or_else(|| crate::storage::LOCAL_CALENDAR_HREF.to_string());
let (desc, is_daily) = if let Some(uid) = &state.journal_editing_uid {
let t = state.store.get_task_ref(uid).unwrap();
(t.description.clone(), false)
} else {
let entry = state
.store
.get_journal_entry(&target_href, state.journal_date);
(
entry.map(|e| e.description.clone()).unwrap_or_default(),
true,
)
};
match run_external_editor(&desc, state.ctx.as_ref()) {
Ok(Some(new_desc)) => {
if new_desc != desc {
state.input_buffer = new_desc;
if !is_daily {
state.editing_uid = state.journal_editing_uid.clone();
}
state.mode = InputMode::EditingDescription;
save_description(state, action_tx);
}
state.needs_redraw = true;
return None;
}
Ok(None) => {
state.input_buffer = desc;
state.cursor_position = state.input_buffer.chars().count();
if !is_daily {
state.editing_uid = state.journal_editing_uid.clone();
}
state.mode = InputMode::EditingDescription;
return None;
}
Err(e) => {
state.message = e;
state.input_buffer = desc;
state.cursor_position = state.input_buffer.chars().count();
if !is_daily {
state.editing_uid = state.journal_editing_uid.clone();
}
state.mode = InputMode::EditingDescription;
state.needs_redraw = true;
return None;
}
}
}
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();
return None;
}
TaskListItem::CollapseGroup(key, _) => {
state.expanded_done_groups.remove(key);
state.refresh_filtered_view();
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());
}
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());
}
state.refresh_filtered_view();
}
}
SidebarMode::Goals => {
let mut keys: Vec<_> = state.goals.keys().cloned().collect();
keys.sort();
if let Some(idx) = state.cal_state.selected()
&& let Some(key) = keys.get(idx)
{
if key.starts_with('#') {
state.sidebar_mode = SidebarMode::Categories;
state.selected_categories.clear();
let tag = key.trim_start_matches('#').to_string();
let tags = crate::model::parser::resolve_selection_aliases(
&tag,
false,
&state.tag_aliases,
);
for t in tags {
state.selected_categories.insert(t);
}
} else if key.starts_with("@@") {
state.sidebar_mode = SidebarMode::Locations;
state.selected_locations.clear();
let loc = key.trim_start_matches("@@").to_string();
let locs = crate::model::parser::resolve_selection_aliases(
&loc,
true,
&state.tag_aliases,
);
for l in locs {
state.selected_locations.insert(l);
}
}
state.refresh_filtered_view();
}
}
SidebarMode::Journal => {
if let Some(idx) = state.cal_state.selected()
&& let Some(page) = state.cached_journal_pages.get(idx)
{
if page.is_task {
state.journal_editing_uid = Some(page.key.clone());
state.active_focus = Focus::Main;
state.details_scroll = 0;
state.refresh_filtered_view();
} else {
let key = page.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());
}
}
}
}
}
}
}
KeyCode::Char('/') => {
state.mode = InputMode::Searching;
state.reset_input();
}
KeyCode::Char('a') => {
if state.sidebar_mode == SidebarMode::Journal {
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())
})
.unwrap_or_else(|| crate::storage::LOCAL_CALENDAR_HREF.to_string());
let (desc, is_daily) = if let Some(uid) = &state.journal_editing_uid {
let t = state.store.get_task_ref(uid).unwrap();
(t.description.clone(), false)
} else {
let entry = state
.store
.get_journal_entry(&target_href, state.journal_date);
(
entry.map(|e| e.description.clone()).unwrap_or_default(),
true,
)
};
match run_external_editor(&desc, state.ctx.as_ref()) {
Ok(Some(new_desc)) => {
if new_desc != desc {
state.input_buffer = new_desc;
if !is_daily {
state.editing_uid = state.journal_editing_uid.clone();
}
state.mode = InputMode::EditingDescription;
save_description(state, action_tx);
}
state.needs_redraw = true;
return None;
}
Ok(None) => {
state.input_buffer = desc;
state.cursor_position = state.input_buffer.chars().count();
if !is_daily {
state.editing_uid = state.journal_editing_uid.clone();
}
state.mode = InputMode::EditingDescription;
return None;
}
Err(e) => {
state.message = e;
state.input_buffer = desc;
state.cursor_position = state.input_buffer.chars().count();
if !is_daily {
state.editing_uid = state.journal_editing_uid.clone();
}
state.mode = InputMode::EditingDescription;
state.needs_redraw = true;
return None;
}
}
}
state.mode = InputMode::Creating;
state.reset_input();
state.new_task_title.clear();
state.creating_with_desc = false;
state.message = rust_i18n::t!("new_task_prompt").to_string();
}
KeyCode::Char('e') => {
if state.sidebar_mode == SidebarMode::Journal
&& state.active_focus == Focus::Sidebar
{
if let Some(idx) = state.cal_state.selected()
&& let Some(page) = state.cached_journal_pages.get(idx)
&& page.is_task
{
let uid = page.key.clone();
let desc = crate::model::extractor::serialize_task_tree(
&state.store,
&uid,
&state.calendars,
true,
);
match run_external_editor(&desc, state.ctx.as_ref()) {
Ok(Some(new_desc)) => {
if new_desc != desc {
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 sync_options = crate::store::SyncTreeOptions {
aliases: &state.tag_aliases,
default_reminder_time: def_time,
trash_retention_days: config.trash_retention_days,
calendars: &state.calendars,
};
if let Ok((actions, _warnings)) =
state.store.sync_tree_from_markdown(
&uid,
&new_desc,
&sync_options,
true,
)
{
state.refresh_filtered_view();
let _ = action_tx.try_send(
crate::tui::action::Action::PersistBatch(actions),
);
}
}
state.needs_redraw = true;
return None;
}
Ok(None) | Err(_) => {
state.input_buffer = desc;
state.cursor_position = state.input_buffer.chars().count();
state.editing_tree_uid = Some(uid.clone());
state.mode = InputMode::EditingTree(uid);
state.needs_redraw = true;
return None;
}
}
}
} else if state.sidebar_mode == SidebarMode::Journal
&& state.active_focus == Focus::Main
{
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())
})
.unwrap_or_else(|| crate::storage::LOCAL_CALENDAR_HREF.to_string());
let (desc, is_daily) = if let Some(uid) = &state.journal_editing_uid {
let t = state.store.get_task_ref(uid).unwrap();
(t.description.clone(), false)
} else {
let entry = state
.store
.get_journal_entry(&target_href, state.journal_date);
(
entry.map(|e| e.description.clone()).unwrap_or_default(),
true,
)
};
match run_external_editor(&desc, state.ctx.as_ref()) {
Ok(Some(new_desc)) => {
if new_desc != desc {
state.input_buffer = new_desc;
if !is_daily {
state.editing_uid = state.journal_editing_uid.clone();
}
state.mode = InputMode::EditingDescription;
save_description(state, action_tx);
}
state.needs_redraw = true;
return None;
}
Ok(None) => {
state.input_buffer = desc;
state.cursor_position = state.input_buffer.chars().count();
if !is_daily {
state.editing_uid = state.journal_editing_uid.clone();
}
state.mode = InputMode::EditingDescription; return None;
}
Err(e) => {
state.message = e;
state.input_buffer = desc;
state.cursor_position = state.input_buffer.chars().count();
if !is_daily {
state.editing_uid = state.journal_editing_uid.clone();
}
state.mode = InputMode::EditingDescription;
state.needs_redraw = true;
return None;
}
}
} else 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') => {
let mut target_uid = None;
if state.active_focus == Focus::Sidebar
&& state.sidebar_mode == SidebarMode::Journal
{
if let Some(idx) = state.cal_state.selected()
&& let Some(page) = state.cached_journal_pages.get(idx)
&& page.is_task
{
target_uid = Some(page.key.clone());
}
} else if state.sidebar_mode == SidebarMode::Journal {
target_uid = state.journal_editing_uid.clone().or_else(|| {
let target_href = state
.active_cal_href
.clone()
.unwrap_or_else(|| crate::storage::LOCAL_CALENDAR_HREF.to_string());
state
.store
.get_journal_entry(&target_href, state.journal_date)
.map(|t| t.uid.clone())
});
} else if let Some(t) = state.get_selected_task() {
target_uid = Some(t.uid.clone());
}
if let Some(uid) = target_uid {
if state.sidebar_mode == SidebarMode::Journal
&& let Some(t) = state.store.get_task_ref(&uid)
{
state.input_buffer = t.summary.clone();
if state.input_buffer.starts_with("- ") {
state.input_buffer = state.input_buffer[2..].to_string();
}
state.input_buffer = format!("is:page {}", state.input_buffer);
state.cursor_position = state.input_buffer.chars().count();
state.editing_uid = Some(uid.clone());
state.mode = InputMode::Editing;
return None;
}
if let Some(t) = state.store.get_task_ref(&uid) {
let desc = t.description.clone();
match run_external_editor(&desc, state.ctx.as_ref()) {
Ok(Some(new_desc)) => {
if new_desc != desc {
state.input_buffer = new_desc;
state.editing_uid = Some(uid);
state.mode = InputMode::EditingDescription;
save_description(state, action_tx);
}
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;
}
}
}
} else if state.sidebar_mode == SidebarMode::Journal
&& state.active_focus == Focus::Main
{
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())
})
.unwrap_or_else(|| crate::storage::LOCAL_CALENDAR_HREF.to_string());
let entry = state
.store
.get_journal_entry(&target_href, state.journal_date);
let desc = entry.map(|e| e.description.clone()).unwrap_or_default();
match run_external_editor(&desc, state.ctx.as_ref()) {
Ok(Some(new_desc)) => {
if new_desc != desc {
state.input_buffer = new_desc;
state.mode = InputMode::EditingDescription;
save_description(state, action_tx);
}
state.needs_redraw = true;
return None;
}
Ok(None) => {
state.input_buffer = desc;
state.cursor_position = state.input_buffer.chars().count();
state.mode = InputMode::EditingDescription;
return None;
}
Err(e) => {
state.message = e;
state.input_buffer = desc;
state.cursor_position = state.input_buffer.chars().count();
state.mode = InputMode::EditingDescription;
state.needs_redraw = true;
return None;
}
}
}
}
_ => {}
},
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;
});
}
}
KeyCode::Char('e') | KeyCode::Enter => {
if let Some(idx) = state.session_selection_state.selected()
&& let Some(&(real_idx, _)) = state.session_items.get(idx)
&& let Some(task) = state.get_selected_task()
&& let Some(session) = task.sessions.get(real_idx)
{
let s_dt = chrono::DateTime::from_timestamp(session.start, 0)
.unwrap()
.with_timezone(&chrono::Local);
let e_dt = chrono::DateTime::from_timestamp(session.end, 0)
.unwrap()
.with_timezone(&chrono::Local);
let prefill = format!(
"{} {}-{}",
s_dt.format("%Y-%m-%d"),
s_dt.format("%H:%M"),
e_dt.format("%H:%M")
);
let uid = task.uid.clone();
state.input_buffer = prefill;
state.cursor_position = state.input_buffer.chars().count();
state.mode = InputMode::EditingSession(uid, real_idx);
state.message = rust_i18n::t!("edit").to_string();
}
}
_ => {}
},
InputMode::EditingSession(ref uid, idx) => match key.code {
KeyCode::Enter => {
let input = state.input_buffer.clone();
if let Some(session) = crate::model::parser::parse_session_input(&input) {
let uid_clone = uid.clone();
if let Some((t_mut, _)) = state.store.get_task_mut(&uid_clone) {
t_mut.remove_session(idx);
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::Moving => match key.code {
KeyCode::Esc => {
state.mode = InputMode::Normal;
state.message = String::new();
state.moving_task_uid = None;
}
KeyCode::Char('t') | KeyCode::Char('T') => {
if let Some(uid) = &state.moving_task_uid
&& let Some(task) = state.store.get_task_ref(uid)
&& state.store.children_index.contains_key(uid)
{
let current_href = task.calendar_href.clone();
let new_moving_tree = !state.moving_tree;
let include_current = new_moving_tree;
let move_targets: Vec<CalendarListEntry> = state
.calendars
.iter()
.filter(|c| {
(include_current || c.href != current_href)
&& !state.disabled_calendars.contains(&c.href)
&& c.href != crate::storage::LOCAL_TRASH_HREF
&& c.href != "local://recovery"
})
.cloned()
.collect();
state.moving_tree = new_moving_tree;
state.move_targets = move_targets;
if let Some(idx) = state.move_selection_state.selected()
&& idx >= state.move_targets.len()
&& !state.move_targets.is_empty()
{
state
.move_selection_state
.select(Some(state.move_targets.len() - 1));
}
}
}
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(uid) = &state.moving_task_uid {
if let Some(idx) = state.move_selection_state.selected() {
state
.move_targets
.get(idx)
.map(|target_cal| (uid.clone(), target_cal.href.clone()))
} else {
None
}
} else {
None
};
if let Some((uid, target_href)) = data {
let intent = if state.moving_tree {
AppIntent::MoveTaskTree {
uid,
target_href: target_href.clone(),
}
} else {
AppIntent::MoveTask {
uid,
target_href: target_href.clone(),
}
};
dispatch_intent_tui(state, intent, action_tx);
if state.sidebar_mode == SidebarMode::Journal {
state.active_cal_href = Some(target_href);
}
update_alarms(state);
state.message = rust_i18n::t!("moving_task").to_string();
state.mode = InputMode::Normal;
state.moving_task_uid = None;
} else {
state.mode = InputMode::Normal;
state.moving_task_uid = None;
}
}
_ => {}
},
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 mut intent = None;
if rel_type == "dependency" {
intent = Some(AppIntent::RemoveDependency {
uid: curr_uid,
blocker_uid: target_uid.clone(),
});
} else if rel_type == "related_to" {
intent = Some(AppIntent::RemoveRelatedTo {
uid: curr_uid,
related_uid: target_uid.clone(),
});
} else if rel_type == "related_from" {
intent = Some(AppIntent::RemoveRelatedTo {
uid: target_uid.clone(),
related_uid: curr_uid,
});
} else if rel_type == "blocking" {
intent = Some(AppIntent::RemoveDependency {
uid: target_uid.clone(),
blocker_uid: curr_uid,
});
} else if rel_type == "parent" {
intent = Some(AppIntent::RemoveParent { uid: curr_uid });
}
if let Some(i) = intent {
dispatch_intent_tui(state, i, action_tx);
}
state.mode = InputMode::Normal;
state.message = String::new();
}
}
KeyCode::Enter => {
if let Some(idx) = state.relationship_selection_state.selected()
&& let Some((target_uid, _, rel_type)) = state.relationship_items.get(idx)
{
let target_uid = target_uid.clone();
let rel_type = rel_type.clone();
if rel_type == "url" {
crate::system::open_url(&target_uid);
state.mode = InputMode::Normal;
state.message = rust_i18n::t!("open_url").to_string();
return None;
}
let curr_uid = state.get_selected_task().map(|t| t.uid.clone());
let actual_target = if rel_type == "wiki_link" {
match state
.store
.resolve_dependency_ref(&target_uid, curr_uid.as_deref())
{
Ok(uid) => uid,
Err(msg) if msg.starts_with("Ambiguous") => {
state.mode = InputMode::Normal;
state.message = msg;
return None;
}
Err(_) => {
let config = crate::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 context_is_journal = curr_uid
.as_deref()
.and_then(|uid| state.store.get_task_ref(uid))
.map(|t| t.is_journal)
.unwrap_or(false);
let (final_uid, actions) = state.store.walk_or_create_wiki_path(
&target_uid,
curr_uid.as_deref(),
context_is_journal,
&state.tag_aliases,
def_time,
state.active_cal_href.clone(),
);
if !actions.is_empty() {
let tx = action_tx.clone();
tokio::spawn(async move {
let _ = tx
.send(crate::tui::action::Action::PersistBatch(actions))
.await;
});
}
final_uid
}
}
} else {
target_uid.clone()
};
if let Some(href) = state.store.index.get(&actual_target).cloned() {
let target_uid = actual_target;
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);
}
if let Some(task) = state.store.get_task_ref(&target_uid).cloned() {
if task.status.is_done() && state.hide_completed {
state.hide_completed = false;
if let Ok(mut cfg) = crate::config::Config::load(state.ctx.as_ref())
{
cfg.hide_completed = false;
let _ = cfg.save(state.ctx.as_ref());
}
}
if task.status.is_done() {
let group_key = task.parent_uid.clone().unwrap_or_default();
state.expanded_done_groups.insert(group_key);
}
let mut to_uncollapse = Vec::new();
for p_uid in state.store.collect_ancestor_uids(&task.uid) {
if let Some(p_task) = state.store.get_task_ref(&p_uid)
&& p_task.collapsed
{
to_uncollapse.push(p_uid);
}
}
if !to_uncollapse.is_empty() {
let config = crate::config::Config::load(state.ctx.as_ref())
.unwrap_or_default();
let mut actions = Vec::new();
for p_uid in to_uncollapse {
let intent = crate::model::AppIntent::SetTreeCollapse {
uid: p_uid,
collapsed: false,
};
actions.extend(state.apply_task_intent(&intent, &config));
}
if !actions.is_empty() {
let tx = action_tx.clone();
tokio::spawn(async move {
let _ = tx
.send(crate::tui::action::Action::PersistBatch(actions))
.await;
});
}
}
}
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 {
let target_uid_clone = target_uid.clone();
state.input_buffer = target_uid_clone.clone();
state.active_search_query = target_uid_clone;
state.search_collapsed_tasks.clear();
state.selected_categories.clear();
state.selected_locations.clear();
state.refresh_filtered_view();
state.mode = InputMode::Normal;
state.message = format!("Searching: '{}'", target_uid.clone());
}
}
}
_ => {}
},
}
None
}