use ratatui::crossterm::event::{
Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
};
use std::time::{Duration, Instant};
use crate::app::{App, Confirm, Focus, Mode};
use crate::form::Field;
use crate::text_input::TextInput;
use crate::undo::EditKind;
const DOUBLE_CLICK: Duration = Duration::from_millis(400);
pub fn handle_event(app: &mut App, event: Event) {
match event {
Event::Key(key) if key.kind == KeyEventKind::Press => handle_key(app, key),
Event::Mouse(m) => handle_mouse(app, m),
Event::Paste(text) => paste_text(app, &text),
_ => {}
}
}
fn paste_text(app: &mut App, text: &str) {
if text.is_empty() {
return;
}
match app.mode {
Mode::TaskForm => {
let Some(form) = &mut app.form else { return };
match form.field {
Field::Title => {
form.before_edit(EditKind::Atomic);
form.title.insert_str(text);
}
Field::Due => {}
Field::Body => {
form.before_edit(EditKind::Atomic);
form.body.insert_str(text);
}
Field::Importance => {}
}
}
Mode::CategoryForm => {
let Some(form) = &mut app.category_form else {
return;
};
form.before_edit(EditKind::Atomic);
if form.on_description {
form.description.insert_str(text);
} else {
form.name.insert_str(text);
}
}
Mode::Slash => {
app.input.insert_str(text);
app.slash_index = 0;
app.clamp_slash_index();
}
Mode::Search => {
app.input.insert_str(text);
app.update_search();
}
_ => {}
}
}
fn is_undo_chord(key: KeyEvent) -> bool {
matches!(key.code, KeyCode::Char('z') | KeyCode::Char('Z'))
&& key.modifiers.contains(KeyModifiers::CONTROL)
&& !key.modifiers.contains(KeyModifiers::SHIFT)
&& !key.modifiers.contains(KeyModifiers::ALT)
}
fn is_redo_chord(key: KeyEvent) -> bool {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let shift = key.modifiers.contains(KeyModifiers::SHIFT);
let alt = key.modifiers.contains(KeyModifiers::ALT);
if !ctrl || alt {
return false;
}
match key.code {
KeyCode::Char('z') | KeyCode::Char('Z') if shift => true,
KeyCode::Char('y') | KeyCode::Char('Y') if !shift => true,
_ => false,
}
}
fn content_edit_kind(key: KeyEvent) -> Option<EditKind> {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let alt = key.modifiers.contains(KeyModifiers::ALT);
let shift = key.modifiers.contains(KeyModifiers::SHIFT);
let word = word_mod(key);
match key.code {
KeyCode::Char(c) if ctrl || alt => match c {
'u' | 'k' | 'w' | 'W' if !shift => Some(EditKind::Atomic),
'd' | 'D' if ctrl && !alt && !shift => Some(EditKind::Atomic),
_ => None,
},
KeyCode::Char(_) if !ctrl && !alt => Some(EditKind::Typing),
KeyCode::Backspace if word => Some(EditKind::Atomic),
KeyCode::Backspace | KeyCode::Delete => Some(EditKind::Typing),
_ => None,
}
}
fn handle_key(app: &mut App, key: KeyEvent) {
if is_copy_chord(key) && copy_selected_body_image(app) {
return;
}
if is_ctrl_c(key) && app.mode == Mode::Normal {
if app.awaiting(Confirm::Quit) {
app.should_quit = true;
} else {
app.ask_confirm(Confirm::Quit, "Press Ctrl+C again to quit");
}
return;
}
if key.code != KeyCode::Backspace {
app.pending = None;
app.message = None;
}
match app.mode {
Mode::Welcome => {
app.mode = Mode::Normal;
}
Mode::Help => {
if matches!(key.code, KeyCode::Esc | KeyCode::Enter | KeyCode::Char('?')) {
app.mode = Mode::Normal;
}
}
Mode::Settings => handle_settings_key(app, key),
Mode::TaskForm => handle_form_key(app, key),
Mode::CategoryForm => handle_category_key(app, key),
Mode::Slash => handle_slash_key(app, key),
Mode::Search => handle_search_key(app, key),
_ => handle_normal_key(app, key),
}
}
fn is_copy_chord(key: KeyEvent) -> bool {
matches!(key.code, KeyCode::Char('c') | KeyCode::Char('C'))
&& (key.modifiers.contains(KeyModifiers::SUPER)
|| key.modifiers.contains(KeyModifiers::CONTROL))
}
fn is_ctrl_c(key: KeyEvent) -> bool {
matches!(key.code, KeyCode::Char('c') | KeyCode::Char('C'))
&& key.modifiers.contains(KeyModifiers::CONTROL)
}
fn copy_selected_body_image(app: &mut App) -> bool {
if app.mode == Mode::TaskForm
&& let Some(form) = &app.form
&& form.field == Field::Body
&& let Some(payload) = form.body.selected_payload()
{
finish_copy(app, payload);
return true;
}
if let Some(text) = selected_text_in_app(app) {
finish_copy(app, crate::body::CopyPayload::Text(text));
return true;
}
if app.mode != Mode::TaskForm {
return false;
}
let Some(form) = &app.form else {
return false;
};
if form.preview {
let path = form
.body
.selected_image()
.or_else(|| form.body.images().into_iter().next());
if let Some(path) = path {
finish_copy(app, crate::body::CopyPayload::Image(path));
return true;
}
}
false
}
fn selected_text_in_app(app: &App) -> Option<String> {
match app.mode {
Mode::TaskForm => {
let form = app.form.as_ref()?;
match form.field {
Field::Title => form.title.selected_text(),
Field::Body => form.body.selected_text(),
Field::Due | Field::Importance => None,
}
}
Mode::CategoryForm => {
let form = app.category_form.as_ref()?;
if form.on_description {
form.description.selected_text()
} else {
form.name.selected_text()
}
}
Mode::Slash | Mode::Search => app.input.selected_text(),
_ => None,
}
}
fn handle_normal_key(app: &mut App, key: KeyEvent) {
match key.code {
KeyCode::Tab | KeyCode::BackTab => {
if !app.searching {
app.toggle_focus();
}
}
KeyCode::Esc => {
if app.searching {
app.end_search();
}
}
KeyCode::Char('/') => app.open_slash(),
KeyCode::Char('?') => app.mode = Mode::Help,
_ => match app.focus {
Focus::Tasks => task_key(app, key),
Focus::Sidebar => sidebar_key(app, key),
},
}
}
fn task_key(app: &mut App, key: KeyEvent) {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let alt = key.modifiers.contains(KeyModifiers::ALT);
let meta = key.modifiers.contains(KeyModifiers::SUPER);
match key.code {
KeyCode::Char('a') | KeyCode::Char('A') if ctrl && !alt => {
if app.searching {
app.info("Leave search (Esc) before adding a task");
return;
}
app.open_new_task();
}
KeyCode::Char('f') | KeyCode::Char('F') if ctrl && !alt => {
app.cycle_importance(app.task_index);
}
KeyCode::Enter => app.open_edit_task(),
KeyCode::Char(' ') => app.toggle_done(app.task_index),
KeyCode::Up => app.navigate_vertical(-1),
KeyCode::Down => app.navigate_vertical(1),
KeyCode::PageUp => app.select_first_task(),
KeyCode::PageDown => app.select_last_task(),
KeyCode::Left => app.focus = Focus::Sidebar,
KeyCode::Backspace => {
if app.selected_task().is_some() {
if app.awaiting(Confirm::Delete) {
app.delete_task(app.task_index);
app.info("Task deleted");
} else {
app.ask_confirm(Confirm::Delete, "Press Backspace again to delete this task");
}
}
}
KeyCode::Char(c) if !ctrl && !alt && !meta && !c.is_control() => {
app.typeahead_jump(c);
}
_ => {}
}
}
fn sidebar_key(app: &mut App, key: KeyEvent) {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let alt = key.modifiers.contains(KeyModifiers::ALT);
let meta = key.modifiers.contains(KeyModifiers::SUPER);
match key.code {
KeyCode::Char('a') | KeyCode::Char('A') if ctrl && !alt => app.open_new_category(),
KeyCode::Enter => app.open_edit_category(),
KeyCode::Right => app.focus = Focus::Tasks,
KeyCode::Up => app.navigate_vertical(-1),
KeyCode::Down => app.navigate_vertical(1),
KeyCode::PageUp => app.select_category(0),
KeyCode::PageDown => app.select_last_category(),
KeyCode::Backspace => {
if app.is_all_view() {
return;
}
if app.awaiting(Confirm::Delete) {
app.delete_category();
app.info("Category deleted");
} else {
let count = app.category_progress(app.current_category_id()).1;
app.ask_confirm(
Confirm::Delete,
format!(
"Press Backspace again to delete this category and its {count} task(s)"
),
);
}
}
KeyCode::Char(c) if !ctrl && !alt && !meta && !c.is_control() => {
app.typeahead_jump(c);
}
_ => {}
}
}
fn word_mod(key: KeyEvent) -> bool {
key.modifiers
.intersects(KeyModifiers::ALT | KeyModifiers::CONTROL)
}
fn edit_line(input: &mut TextInput, key: KeyEvent) -> bool {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let alt = key.modifiers.contains(KeyModifiers::ALT);
let shift = key.modifiers.contains(KeyModifiers::SHIFT);
let word = word_mod(key);
match key.code {
KeyCode::Char('w') | KeyCode::Char('W') if alt && shift => input.select_word(),
KeyCode::Char('b') | KeyCode::Char('B') if alt && shift => input.select_word_left(),
KeyCode::Char('f') | KeyCode::Char('F') if alt && shift => input.select_word_right(),
KeyCode::Char('b') | KeyCode::Char('B') if alt => input.word_left(),
KeyCode::Char('f') | KeyCode::Char('F') if alt => input.word_right(),
KeyCode::Char(c) if ctrl || alt => match c {
'a' if shift => input.select_home(),
'e' if shift => input.select_end(),
'a' => input.home(),
'e' => input.end(),
'u' => input.delete_to_start(),
'k' => input.delete_to_end(),
'w' | 'W' => input.delete_word_left(),
_ => return false,
},
KeyCode::Char(c) => input.insert(c),
KeyCode::Backspace if word => input.delete_word_left(),
KeyCode::Backspace => input.backspace(),
KeyCode::Delete => input.delete(),
KeyCode::Left if word && shift => input.select_word_left(),
KeyCode::Right if word && shift => input.select_word_right(),
KeyCode::Left if shift => input.select_left(),
KeyCode::Right if shift => input.select_right(),
KeyCode::Left if word => input.word_left(),
KeyCode::Right if word => input.word_right(),
KeyCode::Left => input.left(),
KeyCode::Right => input.right(),
KeyCode::Home if shift => input.select_home(),
KeyCode::End if shift => input.select_end(),
KeyCode::Home => input.home(),
KeyCode::End => input.end(),
_ => return false,
}
true
}
fn edit_body(body: &mut crate::body::BodyEditor, key: KeyEvent) {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let alt = key.modifiers.contains(KeyModifiers::ALT);
let shift = key.modifiers.contains(KeyModifiers::SHIFT);
let word = word_mod(key);
match key.code {
KeyCode::Char('w') | KeyCode::Char('W') if alt && shift => body.select_word(),
KeyCode::Char('b') | KeyCode::Char('B') if alt && shift => body.select_word_left(),
KeyCode::Char('f') | KeyCode::Char('F') if alt && shift => body.select_word_right(),
KeyCode::Char('b') | KeyCode::Char('B') if alt => body.word_left(),
KeyCode::Char('f') | KeyCode::Char('F') if alt => body.word_right(),
KeyCode::Char(c) if ctrl || alt => match c {
'a' if shift => body.select_home(),
'e' if shift => body.select_end(),
'a' => body.home(),
'e' => body.end(),
'u' => body.delete_to_start(),
'k' => body.delete_to_end(),
'w' | 'W' => body.delete_word_left(),
_ => {}
},
KeyCode::Char(c) => body.insert(c),
KeyCode::Backspace if word => body.delete_word_left(),
KeyCode::Backspace => body.backspace(),
KeyCode::Delete => body.delete(),
KeyCode::Left if word && shift => body.select_word_left(),
KeyCode::Right if word && shift => body.select_word_right(),
KeyCode::Left if shift => body.select_left(),
KeyCode::Right if shift => body.select_right(),
KeyCode::Left if word => body.word_left(),
KeyCode::Right if word => body.word_right(),
KeyCode::Left => body.left(),
KeyCode::Right => body.right(),
KeyCode::Up => body.up(),
KeyCode::Down => body.down(),
KeyCode::Home if shift => body.select_home(),
KeyCode::End if shift => body.select_end(),
KeyCode::Home => body.home(),
KeyCode::End => body.end(),
_ => {}
}
}
fn handle_category_key(app: &mut App, key: KeyEvent) {
if matches!(key.code, KeyCode::Char('s')) && key.modifiers.contains(KeyModifiers::CONTROL) {
app.submit_category_form();
return;
}
if is_undo_chord(key) {
if let Some(form) = &mut app.category_form
&& form.undo()
{
app.info("Undo");
}
return;
}
if is_redo_chord(key) {
if let Some(form) = &mut app.category_form
&& form.redo()
{
app.info("Redo");
}
return;
}
if app
.category_form
.as_ref()
.is_some_and(|f| f.on_description && f.description.menu.is_some())
{
let outcome = {
let form = app.category_form.as_mut().unwrap();
if matches!(key.code, KeyCode::Enter | KeyCode::Tab) {
form.before_edit(EditKind::Atomic);
}
body_menu_key(&mut form.description, key)
};
match outcome {
MenuKey::Ignored => {}
MenuKey::Handled => return,
MenuKey::Copy(payload) => {
finish_copy(app, payload);
return;
}
}
}
match key.code {
KeyCode::Esc => app.close_category_form(),
KeyCode::Tab | KeyCode::BackTab => {
if let Some(form) = &mut app.category_form {
form.description.close_menu();
form.toggle_field();
}
}
KeyCode::Enter => {
let Some(form) = &mut app.category_form else {
return;
};
if form.on_description {
form.before_edit(EditKind::Atomic);
let _ = form.description.newline();
} else {
form.toggle_field();
}
}
_ => {
let Some(form) = &mut app.category_form else {
return;
};
if form.on_description {
if let Some(mut kind) = content_edit_kind(key) {
if form.description.has_selection() {
kind = EditKind::Atomic;
}
form.before_edit(kind);
} else {
form.break_coalesce();
}
edit_body(&mut form.description, key);
} else if let Some(mut kind) = content_edit_kind(key) {
if form.name.has_selection() {
kind = EditKind::Atomic;
}
form.before_edit(kind);
edit_line(&mut form.name, key);
} else {
form.break_coalesce();
edit_line(&mut form.name, key);
}
}
}
}
fn handle_slash_key(app: &mut App, key: KeyEvent) {
match key.code {
KeyCode::Esc => close_slash(app),
KeyCode::Backspace if app.input.is_empty() => close_slash(app),
KeyCode::Up => {
let n = crate::slash::matching(&app.input.value()).len();
if n > 0 {
app.slash_index = (app.slash_index + n - 1) % n;
}
}
KeyCode::Down | KeyCode::Tab => {
let n = crate::slash::matching(&app.input.value()).len();
if n > 0 {
app.slash_index = (app.slash_index + 1) % n;
}
}
KeyCode::Enter => {
let query = app.input.value();
let matches = crate::slash::matching(&query);
let cmd = matches.get(app.slash_index).copied();
close_slash(app);
if let Some(cmd) = cmd {
run_slash(app, cmd, &query);
}
}
_ => {
if edit_line(&mut app.input, key) {
app.slash_index = 0;
app.clamp_slash_index();
}
}
}
}
fn close_slash(app: &mut App) {
app.mode = Mode::Normal;
app.input = TextInput::default();
app.slash_index = 0;
}
fn handle_search_key(app: &mut App, key: KeyEvent) {
match key.code {
KeyCode::Esc => {
app.input = TextInput::default();
app.end_search();
}
KeyCode::Enter => {
app.mode = Mode::Normal;
app.input = TextInput::default();
if app.search_query.is_empty() {
app.end_search();
}
}
_ => {
if edit_line(&mut app.input, key) {
app.update_search();
}
}
}
}
fn run_slash(app: &mut App, cmd: crate::slash::SlashCommand, query: &str) {
use crate::slash::{SlashCommand, args_for};
match cmd {
SlashCommand::Search => {
let q = args_for(cmd, query);
app.start_search(&q);
}
SlashCommand::Settings => {
app.settings_index = 0;
app.mode = Mode::Settings;
}
SlashCommand::Help => app.mode = Mode::Help,
SlashCommand::CopyTitle => match app.selected_task() {
Some(task) => {
finish_copy(app, crate::body::CopyPayload::Text(task.title.clone()));
}
None => app.info("No task selected"),
},
SlashCommand::CopyTask => match app.selected_task() {
Some(task) => {
let text = task_clipboard_text(task);
finish_copy(app, crate::body::CopyPayload::Text(text));
}
None => app.info("No task selected"),
},
SlashCommand::Done => {
let hidden = app.toggle_hide_done();
if hidden {
app.info("Hiding completed tasks");
} else {
app.info("Showing completed tasks");
}
}
SlashCommand::Purge => {
let n = app.purge();
if n == 0 {
app.info("No done tasks to purge");
} else {
app.info(format!("Purged {n} done task(s)"));
}
}
SlashCommand::Update => app.start_update_check(),
SlashCommand::Quit => app.should_quit = true,
}
}
fn handle_form_key(app: &mut App, key: KeyEvent) {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
if matches!(key.code, KeyCode::Char('s')) && ctrl {
app.submit_form();
return;
}
if is_undo_chord(key) {
if let Some(form) = &mut app.form
&& form.undo()
{
app.info("Undo");
}
return;
}
if is_redo_chord(key) {
if let Some(form) = &mut app.form
&& form.redo()
{
app.info("Redo");
}
return;
}
if app.form.as_ref().is_some_and(|f| f.preview) {
match key.code {
KeyCode::Esc => {
if let Some(form) = &mut app.form {
form.close_image_preview();
}
app.images.clear_preview();
}
KeyCode::Enter | KeyCode::Char(' ') => {
if let Some(form) = &mut app.form {
form.preview_click();
}
}
_ => {}
}
return;
}
if app.form.as_ref().is_some_and(|f| f.picker.is_some()) {
handle_picker_key(app, key);
return;
}
if app.form.as_ref().is_some_and(|f| f.body.menu.is_some()) && handle_menu_key(app, key) {
return;
}
match key.code {
KeyCode::Esc => app.close_form(),
KeyCode::Tab => {
if let Some(form) = &mut app.form {
form.focus_next();
}
}
KeyCode::BackTab => {
if let Some(form) = &mut app.form {
form.focus_prev();
}
}
KeyCode::Enter
if key
.modifiers
.intersects(KeyModifiers::SUPER | KeyModifiers::CONTROL) =>
{
let url = app
.form
.as_ref()
.filter(|f| f.field == Field::Body)
.and_then(|f| f.body.link_url_at_cursor());
if let Some(url) = url {
match crate::open::open_url(&url) {
Ok(()) => app.info(format!("Opened {url}")),
Err(err) => app.error(err),
}
}
}
KeyCode::Enter => {
let Some(form) = &mut app.form else { return };
match form.field {
Field::Title | Field::Importance => form.focus_next(),
Field::Due => form.open_due_picker(),
Field::Body if form.body.selected_image().is_some() => {
if let Some(err) = form.open_image_preview() {
app.error(err);
}
}
Field::Body => {
form.before_edit(EditKind::Atomic);
let _ = form.body.newline();
}
}
}
_ => {
let Some(form) = &mut app.form else { return };
match form.field {
Field::Body
if ctrl && matches!(key.code, KeyCode::Char('d') | KeyCode::Char('D')) =>
{
form.before_edit(EditKind::Atomic);
form.body.toggle();
}
Field::Body => {
if let Some(mut kind) = content_edit_kind(key) {
if form.body.has_selection() {
kind = EditKind::Atomic;
}
form.before_edit(kind);
} else {
form.break_coalesce();
}
edit_body(&mut form.body, key);
}
Field::Importance => match key.code {
KeyCode::Left | KeyCode::Down => {
form.set_importance(form.importance.saturating_sub(1))
}
KeyCode::Right | KeyCode::Up | KeyCode::Char(' ') => form.cycle_importance(),
KeyCode::Backspace | KeyCode::Delete => form.set_importance(0),
KeyCode::Char(c) if c.is_ascii_digit() => form.set_importance(c as u8 - b'0'),
_ => form.break_coalesce(),
},
Field::Due => match key.code {
KeyCode::Char(_) => form.open_due_picker(),
KeyCode::Backspace | KeyCode::Delete => form.clear_due(),
_ => form.break_coalesce(),
},
Field::Title => {
if let Some(mut kind) = content_edit_kind(key) {
if form.title.has_selection() {
kind = EditKind::Atomic;
}
form.before_edit(kind);
} else {
form.break_coalesce();
}
edit_line(&mut form.title, key);
}
}
}
}
}
fn handle_picker_key(app: &mut App, key: KeyEvent) {
use crate::duepicker::PickerFocus;
let Some(form) = &mut app.form else { return };
match key.code {
KeyCode::Esc => {
form.picker = None;
return;
}
KeyCode::Char('x') | KeyCode::Delete => {
form.clear_due();
return;
}
KeyCode::Enter => {
form.take_due_picker();
return;
}
_ => {}
}
let Some(picker) = &mut form.picker else {
return;
};
match key.code {
KeyCode::Tab => picker.focus_next(),
KeyCode::BackTab => picker.focus_prev(),
KeyCode::Char('t') => {
picker.today();
picker.now_time();
}
KeyCode::Left => match picker.focus {
PickerFocus::Calendar => picker.move_days(-1),
PickerFocus::Hour => picker.bump_hour(-1),
PickerFocus::Minute => picker.bump_minute(-5),
},
KeyCode::Right => match picker.focus {
PickerFocus::Calendar => picker.move_days(1),
PickerFocus::Hour => picker.bump_hour(1),
PickerFocus::Minute => picker.bump_minute(5),
},
KeyCode::Up => match picker.focus {
PickerFocus::Calendar => picker.move_days(-7),
PickerFocus::Hour => picker.bump_hour(1),
PickerFocus::Minute => picker.bump_minute(5),
},
KeyCode::Down => match picker.focus {
PickerFocus::Calendar => picker.move_days(7),
PickerFocus::Hour => picker.bump_hour(-1),
PickerFocus::Minute => picker.bump_minute(-5),
},
KeyCode::PageUp => match picker.focus {
PickerFocus::Calendar => picker.move_months(-1),
PickerFocus::Hour => picker.bump_hour(1),
PickerFocus::Minute => picker.bump_minute(15),
},
KeyCode::PageDown => match picker.focus {
PickerFocus::Calendar => picker.move_months(1),
PickerFocus::Hour => picker.bump_hour(-1),
PickerFocus::Minute => picker.bump_minute(-15),
},
KeyCode::Char(' ') if picker.focus != PickerFocus::Calendar => picker.now_time(),
KeyCode::Char(c) if c.is_ascii_digit() => picker.type_digit(c as u8 - b'0'),
_ => {}
}
}
fn handle_menu_key(app: &mut App, key: KeyEvent) -> bool {
let menu_open = app.form.as_ref().is_some_and(|f| f.body.menu.is_some());
if !menu_open {
return false;
}
let outcome = {
let form = app.form.as_mut().unwrap();
if matches!(key.code, KeyCode::Enter | KeyCode::Tab) {
form.before_edit(EditKind::Atomic);
}
body_menu_key(&mut form.body, key)
};
match outcome {
MenuKey::Ignored => false,
MenuKey::Handled => true,
MenuKey::Copy(payload) => {
finish_copy(app, payload);
true
}
}
}
enum MenuKey {
Ignored,
Handled,
Copy(crate::body::CopyPayload),
}
fn body_menu_key(body: &mut crate::body::BodyEditor, key: KeyEvent) -> MenuKey {
match key.code {
KeyCode::Up => {
body.menu_prev();
MenuKey::Handled
}
KeyCode::Down => {
body.menu_next();
MenuKey::Handled
}
KeyCode::Esc => {
body.close_menu();
MenuKey::Handled
}
KeyCode::Tab | KeyCode::Enter => match body.menu_selected() {
Some(command) => match body.apply(command) {
Some(payload) => MenuKey::Copy(payload),
None => MenuKey::Handled,
},
None => {
body.close_menu();
MenuKey::Handled
}
},
_ => MenuKey::Ignored,
}
}
fn task_clipboard_text(task: &crate::model::Task) -> String {
let body = crate::body::BodyEditor::new(&task.body).text_for_copy();
if body.is_empty() {
task.title.clone()
} else {
format!("{}\n\n{body}", task.title)
}
}
fn finish_copy(app: &mut App, payload: crate::body::CopyPayload) {
match payload {
crate::body::CopyPayload::Text(text) => {
if text.is_empty() {
app.info("Nothing to copy");
return;
}
match arboard::Clipboard::new().and_then(|mut c| c.set_text(text)) {
Ok(()) => app.info("Copied text to clipboard"),
Err(err) => app.error(format!("Could not copy: {err}")),
}
}
crate::body::CopyPayload::Image(path) => match copy_image_file(&path) {
Ok(()) => app.info("Copied image to clipboard"),
Err(err) => app.error(format!("Could not copy image: {err}")),
},
crate::body::CopyPayload::All(lines) => {
if lines.is_empty() {
app.info("Nothing to copy");
return;
}
match copy_all(&lines) {
Ok(()) => app.info("Copied text and pictures"),
Err(err) => app.error(format!("Could not copy: {err}")),
}
}
}
}
fn copy_image_file(path: &std::path::Path) -> Result<(), String> {
let rgba = crate::image::load_dynamic(path)?.into_rgba8();
let (width, height) = rgba.dimensions();
let data = arboard::ImageData {
width: width as usize,
height: height as usize,
bytes: rgba.into_raw().into(),
};
arboard::Clipboard::new()
.and_then(|mut c| c.set_image(data))
.map_err(|e| e.to_string())
}
fn copy_all(lines: &[crate::body::CopyLine]) -> Result<(), String> {
use crate::body::CopyLine;
let mut html = String::new();
let mut plain = String::new();
for (i, line) in lines.iter().enumerate() {
if i > 0 {
plain.push('\n');
}
match line {
CopyLine::Text(text) => {
plain.push_str(text);
html.push_str("<div>");
html.push_str(&escape_html(text));
html.push_str("</div>");
}
CopyLine::Link(url) => {
plain.push_str(url);
let href = escape_html(url);
html.push_str("<div><a href=\"");
html.push_str(&href);
html.push_str("\">");
html.push_str(&href);
html.push_str("</a></div>");
}
CopyLine::Image(path) => {
plain.push_str(&format!("[image: {}]", path.display()));
match image_data_url(path) {
Ok(url) => {
html.push_str(r#"<div><img src=""#);
html.push_str(&url);
html.push_str(r#"" /></div>"#);
}
Err(_) => {
html.push_str("<div>");
html.push_str(&escape_html(&format!("[image: {}]", path.display())));
html.push_str("</div>");
}
}
}
}
}
arboard::Clipboard::new()
.and_then(|mut c| c.set_html(html.as_str(), Some(plain.as_str())))
.map_err(|e| e.to_string())?;
Ok(())
}
fn image_data_url(path: &std::path::Path) -> Result<String, String> {
use base64::Engine;
use std::io::Cursor;
let img = crate::image::load_dynamic(path)?;
let mut png = Vec::new();
img.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png)
.map_err(|e| format!("{}: {e}", path.display()))?;
let b64 = base64::engine::general_purpose::STANDARD.encode(png);
Ok(format!("data:image/png;base64,{b64}"))
}
fn escape_html(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
_ => out.push(c),
}
}
out
}
fn handle_settings_key(app: &mut App, key: KeyEvent) {
match key.code {
KeyCode::Esc => app.mode = Mode::Normal,
KeyCode::Up => {
app.settings_index = app.settings_index.saturating_sub(1);
}
KeyCode::Down => {
app.settings_index = (app.settings_index + 1).min(crate::app::SETTINGS_ITEMS.len() - 1);
}
KeyCode::Right | KeyCode::Tab => app.cycle_setting(app.settings_index, 1),
KeyCode::Left | KeyCode::BackTab => app.cycle_setting(app.settings_index, -1),
_ => {}
}
}
fn handle_mouse(app: &mut App, m: MouseEvent) {
if app.mode == Mode::TaskForm {
if click_on_panels(app, m) {
app.close_form();
} else {
handle_form_mouse(app, m);
return;
}
}
if app.mode == Mode::CategoryForm {
if click_on_panels(app, m) {
app.close_category_form();
} else {
if let (MouseEventKind::Down(MouseButton::Left), Some(form)) =
(m.kind, &mut app.category_form)
{
if contains(form.name_area, m.column, m.row) {
form.on_description = false;
form.name
.set_cursor_from_col((m.column - form.name_area.x) as usize);
} else if contains(form.description_area, m.column, m.row) {
form.on_description = true;
form.description.click(
m.row - form.description_area.y,
(m.column - form.description_area.x) as usize,
);
}
}
return;
}
}
if app.mode.is_overlay() {
return;
}
match m.kind {
MouseEventKind::ScrollUp | MouseEventKind::ScrollDown => {
let delta = if m.kind == MouseEventKind::ScrollUp {
-1
} else {
1
};
app.pending = None;
if contains(app.areas.tasks, m.column, m.row) {
app.move_task_selection(delta);
} else if contains(app.areas.sidebar, m.column, m.row) && !app.searching {
app.move_category_selection(delta);
}
}
MouseEventKind::Down(MouseButton::Left) => {
let (x, y) = (m.column, m.row);
let sidebar = app.areas.sidebar;
let tasks = app.areas.tasks;
if contains(sidebar, x, y) {
if app.searching {
return;
}
app.focus = Focus::Sidebar;
let row = app.cat_state.offset() + (y - sidebar.y) as usize;
if row >= app.categories.len() {
return;
}
app.select_category(row);
if clicked_again(app, Focus::Sidebar, row) {
app.open_edit_category();
}
} else if contains(tasks, x, y) {
app.focus = Focus::Tasks;
let visual = app.task_state.offset() + (y - tasks.y) as usize;
let Some(row) = app.task_at_visual_row(visual) else {
return;
};
let on_flags = app.areas.flag_x.is_some_and(|at| x >= at);
let on_done = app
.areas
.done_x
.is_some_and(|at| x >= at && x < at + crate::ui::DONE_MARK_WIDTH);
if on_flags {
app.cycle_importance(row);
} else if on_done {
app.toggle_done(row);
} else {
app.select_task(row);
if clicked_again(app, Focus::Tasks, row) {
app.open_edit_task();
}
}
} else if contains(app.areas.preview, x, y) && app.selected_task().is_some() {
app.focus = Focus::Tasks;
app.open_edit_task();
}
}
_ => {}
}
}
fn click_on_panels(app: &App, m: MouseEvent) -> bool {
if m.kind != MouseEventKind::Down(MouseButton::Left) {
return false;
}
let (x, y) = (m.column, m.row);
if !contains(app.areas.sidebar, x, y) && !contains(app.areas.tasks, x, y) {
return false;
}
if let Some(form) = &app.form {
if form.areas.field_at(x, y).is_some() {
return false;
}
if form.picker.as_ref().is_some_and(|p| p.contains(x, y)) {
return false;
}
if form.body_menu_area.is_some_and(|r| contains(r, x, y)) {
return false;
}
}
if let Some(form) = &app.category_form
&& (contains(form.name_area, x, y) || contains(form.description_area, x, y))
{
return false;
}
true
}
fn handle_form_mouse(app: &mut App, m: MouseEvent) {
if matches!(
m.kind,
MouseEventKind::ScrollUp | MouseEventKind::ScrollDown
) && app.form.as_ref().is_some_and(|f| f.picker.is_some())
{
let up = matches!(m.kind, MouseEventKind::ScrollUp);
if let Some(form) = &mut app.form
&& let Some(picker) = &mut form.picker
{
let _ = picker.scroll(m.column, m.row, up);
}
return;
}
if matches!(
m.kind,
MouseEventKind::ScrollUp | MouseEventKind::ScrollDown
) && app.form.as_ref().is_some_and(|f| f.body.menu.is_some())
{
let up = matches!(m.kind, MouseEventKind::ScrollUp);
if let Some(form) = &mut app.form {
if up {
form.body.menu_prev();
} else {
form.body.menu_next();
}
}
return;
}
if m.kind != MouseEventKind::Down(MouseButton::Left) {
return;
}
if app.form.as_ref().is_some_and(|f| f.preview) {
if let Some(form) = &mut app.form {
form.preview_click();
}
return;
}
if app.form.as_ref().is_some_and(|f| f.picker.is_some()) {
let Some(form) = &mut app.form else { return };
let handled = form
.picker
.as_mut()
.is_some_and(|p| p.click(m.column, m.row));
if handled {
return;
}
if !form.areas.due.contains(ratatui::layout::Position {
x: m.column,
y: m.row,
}) {
form.picker = None;
}
}
if app.form.as_ref().is_some_and(|f| f.body.menu.is_some()) {
match click_body_slash_menu(app, m.column, m.row) {
MenuClick::Handled | MenuClick::CopyDone => return,
MenuClick::Miss => {
}
}
}
enum AfterClick {
None,
OpenUrl(String),
PreviewErr(String),
}
let after = {
let Some(form) = &mut app.form else { return };
let Some(field) = form.areas.field_at(m.column, m.row) else {
form.last_body_click = None;
return;
};
form.set_field(field);
let area = form.areas.rect(field);
let col = (m.column - area.x) as usize;
let row = (m.row - area.y) as usize;
match field {
Field::Title => {
form.title.set_cursor_from_col(col);
form.last_body_click = None;
AfterClick::None
}
Field::Due => {
form.open_due_picker();
form.last_body_click = None;
AfterClick::None
}
Field::Body => {
let hit = form.body.click(row as u16, col);
if !hit {
form.last_body_click = None;
AfterClick::None
} else if let Some(url) = form.body.link_url_at_cursor() {
form.last_body_click = None;
AfterClick::OpenUrl(url)
} else if form.body.selected_image().is_some() {
let line = form.body.cursor_line();
if !form.image_hit_at(line, m.column, m.row) {
form.body.abandon_image_selection();
form.last_body_click = None;
AfterClick::None
} else {
let now = Instant::now();
let again = form.last_body_click.is_some_and(|(at, last)| {
last == line && now.duration_since(at) < DOUBLE_CLICK
});
if again {
form.last_body_click = None;
match form.open_image_preview() {
Some(err) => AfterClick::PreviewErr(err),
None => AfterClick::None,
}
} else {
form.last_body_click = Some((now, line));
AfterClick::None
}
}
} else {
form.last_body_click = None;
AfterClick::None
}
}
Field::Importance => {
form.cycle_importance();
form.last_body_click = None;
AfterClick::None
}
}
};
match after {
AfterClick::None => {}
AfterClick::OpenUrl(url) => match crate::open::open_url(&url) {
Ok(()) => app.info(format!("Opened {url}")),
Err(err) => app.error(err),
},
AfterClick::PreviewErr(err) => app.error(err),
}
}
enum MenuClick {
Handled,
CopyDone,
Miss,
}
fn click_body_slash_menu(app: &mut App, x: u16, y: u16) -> MenuClick {
let Some(form) = app.form.as_ref() else {
return MenuClick::Miss;
};
let Some(rect) = form.body_menu_area else {
return MenuClick::Miss;
};
if !contains(rect, x, y) {
return MenuClick::Miss;
}
let commands = form.body.menu_commands();
if commands.is_empty() {
return MenuClick::Handled;
}
if y <= rect.y || y >= rect.bottom().saturating_sub(1) {
return MenuClick::Handled;
}
let idx = (y - rect.y - 1) as usize;
if idx >= commands.len() {
return MenuClick::Handled;
}
let command = commands[idx];
let form = app.form.as_mut().unwrap();
if let Some(menu) = &mut form.body.menu {
menu.index = idx;
}
form.before_edit(EditKind::Atomic);
match form.body.apply(command) {
Some(payload) => {
finish_copy(app, payload);
MenuClick::CopyDone
}
None => MenuClick::Handled,
}
}
fn clicked_again(app: &mut App, panel: Focus, row: usize) -> bool {
let now = Instant::now();
let again = app.last_click.is_some_and(|(at, last_panel, last_row)| {
last_panel == panel && last_row == row && now.duration_since(at) < DOUBLE_CLICK
});
app.last_click = (!again).then_some((now, panel, row));
again
}
fn contains(area: ratatui::layout::Rect, x: u16, y: u16) -> bool {
area.contains(ratatui::layout::Position { x, y })
}