From 9bc6931fae84769c2fba35ec06b48d3dfea0d8c2 Mon Sep 17 00:00:00 2001 From: Joscha Date: Sat, 29 Apr 2023 00:24:33 +0200 Subject: [PATCH] Migrate input handling to new bindings --- Cargo.lock | 2 +- cove-input/Cargo.toml | 6 +- cove-input/src/lib.rs | 21 +- cove/Cargo.toml | 1 - cove/src/ui.rs | 96 +++----- cove/src/ui/chat.rs | 26 +-- cove/src/ui/chat/tree.rs | 441 +++++++++++++++++------------------- cove/src/ui/euph/account.rs | 77 +++---- cove/src/ui/euph/auth.rs | 55 ++--- cove/src/ui/euph/inspect.rs | 23 +- cove/src/ui/euph/links.rs | 92 ++++---- cove/src/ui/euph/nick.rs | 59 ++--- cove/src/ui/euph/popup.rs | 9 + cove/src/ui/euph/room.rs | 324 +++++++------------------- cove/src/ui/input.rs | 175 -------------- cove/src/ui/rooms.rs | 231 +++++++------------ cove/src/ui/util.rs | 316 +++++++++++++------------- cove/src/ui/widgets/list.rs | 16 ++ 18 files changed, 748 insertions(+), 1222 deletions(-) delete mode 100644 cove/src/ui/input.rs diff --git a/Cargo.lock b/Cargo.lock index 71de4e0..c59459d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -259,7 +259,6 @@ dependencies = [ "cove-input", "crossterm", "directories", - "edit", "euphoxide", "linkify", "log", @@ -294,6 +293,7 @@ version = "0.6.1" dependencies = [ "cove-macro", "crossterm", + "edit", "parking_lot", "serde", "serde_either", diff --git a/cove-input/Cargo.toml b/cove-input/Cargo.toml index 63567d6..f3dcc64 100644 --- a/cove-input/Cargo.toml +++ b/cove-input/Cargo.toml @@ -7,8 +7,10 @@ edition = { workspace = true } cove-macro = { path = "../cove-macro" } crossterm = { workspace = true } -parking_lot = {workspace = true} +parking_lot = { workspace = true } serde = { workspace = true } serde_either = { workspace = true } thiserror = { workspace = true } -toss = {workspace = true} +toss = { workspace = true } + +edit = "0.1.4" diff --git a/cove-input/src/lib.rs b/cove-input/src/lib.rs index 62063e6..fe578fd 100644 --- a/cove-input/src/lib.rs +++ b/cove-input/src/lib.rs @@ -1,11 +1,12 @@ mod keys; +use std::io; use std::sync::Arc; pub use cove_macro::KeyGroup; use crossterm::event::{Event, KeyEvent}; use parking_lot::FairMutex; -use toss::Terminal; +use toss::{Frame, Terminal, WidthDb}; pub use crate::keys::*; @@ -53,4 +54,22 @@ impl<'a> InputEvent<'a> { None => false, } } + + pub fn frame(&mut self) -> &mut Frame { + self.terminal.frame() + } + + pub fn widthdb(&mut self) -> &mut WidthDb { + self.terminal.widthdb() + } + + pub fn prompt(&mut self, initial_text: &str) -> io::Result { + let guard = self.crossterm_lock.lock(); + self.terminal.suspend().expect("failed to suspend"); + let content = edit::edit(initial_text); + self.terminal.unsuspend().expect("fauled to unsuspend"); + drop(guard); + + content + } } diff --git a/cove/Cargo.toml b/cove/Cargo.toml index 2cc76d3..0d1a1f9 100644 --- a/cove/Cargo.toml +++ b/cove/Cargo.toml @@ -17,7 +17,6 @@ async-trait = "0.1.68" clap = { version = "4.2.1", features = ["derive", "deprecated"] } cookie = "0.17.0" directories = "5.0.0" -edit = "0.1.4" linkify = "0.9.0" log = { version = "0.4.17", features = ["std"] } once_cell = "1.17.1" diff --git a/cove/src/ui.rs b/cove/src/ui.rs index 4443fe9..7ac483e 100644 --- a/cove/src/ui.rs +++ b/cove/src/ui.rs @@ -1,6 +1,5 @@ mod chat; mod euph; -mod input; mod key_bindings; mod rooms; mod util; @@ -12,6 +11,7 @@ use std::sync::{Arc, Weak}; use std::time::{Duration, Instant}; use cove_config::Config; +use cove_input::InputEvent; use parking_lot::FairMutex; use tokio::sync::mpsc::error::TryRecvError; use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender}; @@ -26,7 +26,6 @@ use crate::vault::Vault; pub use self::chat::ChatMsg; use self::chat::ChatState; -use self::input::{key, InputEvent, KeyBindingsList}; use self::rooms::Rooms; use self::widgets::ListState; @@ -209,17 +208,6 @@ impl Ui { } } - async fn list_key_bindings(&self, bindings: &mut KeyBindingsList) { - bindings.binding("ctrl+c", "quit cove"); - bindings.binding("F1, ?", "show this menu"); - bindings.binding("F12", "toggle log"); - bindings.empty(); - match self.mode { - Mode::Main => self.rooms.list_key_bindings(bindings).await, - Mode::Log => self.log_chat.list_key_bindings(bindings, false).await, - } - } - async fn handle_event( &mut self, terminal: &mut Terminal, @@ -232,7 +220,7 @@ impl Ui { UiEvent::LogChanged => EventHandleResult::Continue, UiEvent::Term(crossterm::event::Event::Resize(_, _)) => EventHandleResult::Redraw, UiEvent::Term(event) => { - self.handle_term_event(terminal, crossterm_lock, event) + self.handle_term_event(terminal, crossterm_lock.clone(), event) .await } UiEvent::Euph(event) => { @@ -248,74 +236,60 @@ impl Ui { async fn handle_term_event( &mut self, terminal: &mut Terminal, - crossterm_lock: &Arc>, + crossterm_lock: Arc>, event: crossterm::event::Event, ) -> EventHandleResult { - let event = some_or_return!(InputEvent::from_event(event), EventHandleResult::Continue); + let mut event = InputEvent::new(event, terminal, crossterm_lock); + let keys = &self.config.keys; - if let key!(Ctrl + 'c') = event { - // Exit unconditionally on ctrl+c. Previously, shift+q would also - // unconditionally exit, but that interfered with typing text in - // inline editors. + if event.matches(&keys.general.exit) { return EventHandleResult::Stop; } // Key bindings list overrides any other bindings if visible if self.key_bindings_visible { - match event { - key!(Esc) | key!(F 1) | key!('?') => self.key_bindings_visible = false, - key!('k') | key!(Up) => self.key_bindings_list.scroll_up(1), - key!('j') | key!(Down) => self.key_bindings_list.scroll_down(1), - _ => return EventHandleResult::Continue, + if event.matches(&keys.general.abort) { + self.key_bindings_visible = false; + return EventHandleResult::Redraw; } + if util::handle_list_input_event(&mut self.key_bindings_list, &event, keys) { + return EventHandleResult::Redraw; + } + // ... and does not let anything below the popup receive events + return EventHandleResult::Continue; + } + + // Other general bindings that override any other bindings + if event.matches(&keys.general.help) { + self.key_bindings_visible = true; + return EventHandleResult::Redraw; + } + if event.matches(&keys.general.log) { + self.mode = match self.mode { + Mode::Main => Mode::Log, + Mode::Log => Mode::Main, + }; return EventHandleResult::Redraw; } - match event { - key!(F 1) => { - self.key_bindings_visible = true; - return EventHandleResult::Redraw; - } - key!(F 12) => { - self.mode = match self.mode { - Mode::Main => Mode::Log, - Mode::Log => Mode::Main, - }; - return EventHandleResult::Redraw; - } - _ => {} - } - - let mut handled = match self.mode { + match self.mode { Mode::Main => { - self.rooms - .handle_input_event(terminal, crossterm_lock, &event) - .await + if self.rooms.handle_input_event(&mut event, keys).await { + return EventHandleResult::Redraw; + } } Mode::Log => { let reaction = self .log_chat - .handle_input_event(terminal, crossterm_lock, &event, false) + .handle_input_event(&mut event, keys, false) .await; let reaction = logging_unwrap!(reaction); - reaction.handled() - } - }; - - // Pressing '?' should only open the key bindings list if it doesn't - // interfere with any part of the main UI, such as entering text in a - // text editor. - if !handled { - if let key!('?') = event { - self.key_bindings_visible = true; - handled = true; + if reaction.handled() { + return EventHandleResult::Redraw; + } } } - if handled { - EventHandleResult::Redraw - } else { - EventHandleResult::Continue - } + EventHandleResult::Continue } } diff --git a/cove/src/ui/chat.rs b/cove/src/ui/chat.rs index a8acdea..24ea82e 100644 --- a/cove/src/ui/chat.rs +++ b/cove/src/ui/chat.rs @@ -4,20 +4,17 @@ mod renderer; mod tree; mod widgets; -use std::io; -use std::sync::Arc; - -use parking_lot::FairMutex; +use cove_config::Keys; +use cove_input::InputEvent; use time::OffsetDateTime; use toss::widgets::{BoxedAsync, EditorState}; -use toss::{Styled, Terminal, WidgetExt}; +use toss::{Styled, WidgetExt}; use crate::store::{Msg, MsgStore}; use self::cursor::Cursor; use self::tree::TreeViewState; -use super::input::{InputEvent, KeyBindingsList}; use super::UiError; pub trait ChatMsg { @@ -76,19 +73,10 @@ impl> ChatState { } } - pub async fn list_key_bindings(&self, bindings: &mut KeyBindingsList, can_compose: bool) { - match self.mode { - Mode::Tree => self - .tree - .list_key_bindings(bindings, &self.cursor, can_compose), - } - } - pub async fn handle_input_event( &mut self, - terminal: &mut Terminal, - crossterm_lock: &Arc>, - event: &InputEvent, + event: &mut InputEvent<'_>, + keys: &Keys, can_compose: bool, ) -> Result, S::Error> where @@ -101,9 +89,8 @@ impl> ChatState { Mode::Tree => { self.tree .handle_input_event( - terminal, - crossterm_lock, event, + keys, &mut self.cursor, &mut self.editor, can_compose, @@ -147,7 +134,6 @@ pub enum Reaction { parent: Option, content: String, }, - ComposeError(io::Error), } impl Reaction { diff --git a/cove/src/ui/chat/tree.rs b/cove/src/ui/chat/tree.rs index ad7e51d..297429b 100644 --- a/cove/src/ui/chat/tree.rs +++ b/cove/src/ui/chat/tree.rs @@ -7,15 +7,14 @@ mod scroll; mod widgets; use std::collections::HashSet; -use std::sync::Arc; use async_trait::async_trait; -use parking_lot::FairMutex; +use cove_config::Keys; +use cove_input::InputEvent; use toss::widgets::EditorState; -use toss::{AsyncWidget, Frame, Pos, Size, Terminal, WidgetExt, WidthDb}; +use toss::{AsyncWidget, Frame, Pos, Size, WidgetExt, WidthDb}; use crate::store::{Msg, MsgStore}; -use crate::ui::input::{key, InputEvent, KeyBindingsList}; use crate::ui::{util, ChatMsg, UiError}; use crate::util::InfallibleExt; @@ -49,25 +48,10 @@ impl> TreeViewState { } } - pub fn list_movement_key_bindings(&self, bindings: &mut KeyBindingsList) { - bindings.binding("j/k, ↓/↑", "move cursor up/down"); - bindings.binding("J/K, ctrl+↓/↑", "move cursor to prev/next sibling"); - bindings.binding("p/P", "move cursor to parent/root"); - bindings.binding("h/l, ←/→", "move cursor chronologically"); - bindings.binding("H/L, ctrl+←/→", "move cursor to prev/next unseen message"); - bindings.binding("g, home", "move cursor to top"); - bindings.binding("G, end", "move cursor to bottom"); - bindings.binding("ctrl+y/e", "scroll up/down a line"); - bindings.binding("ctrl+u/d", "scroll up/down half a screen"); - bindings.binding("ctrl+b/f, page up/down", "scroll up/down one screen"); - bindings.binding("z", "center cursor on screen"); - // TODO Bindings inspired by vim's ()/[]/{} bindings? - } - async fn handle_movement_input_event( &mut self, - frame: &mut Frame, - event: &InputEvent, + event: &mut InputEvent<'_>, + keys: &Keys, cursor: &mut Cursor, editor: &mut EditorState, ) -> Result @@ -77,152 +61,188 @@ impl> TreeViewState { S: Send + Sync, S::Error: Send, { - let chat_height: i32 = (frame.size().height - 3).into(); - let widthdb = frame.widthdb(); + let chat_height: i32 = (event.frame().size().height - 3).into(); - match event { - key!('k') | key!(Up) => cursor.move_up_in_tree(&self.store, &self.folded).await?, - key!('j') | key!(Down) => cursor.move_down_in_tree(&self.store, &self.folded).await?, - key!('K') | key!(Ctrl + Up) => cursor.move_to_prev_sibling(&self.store).await?, - key!('J') | key!(Ctrl + Down) => cursor.move_to_next_sibling(&self.store).await?, - key!('p') => cursor.move_to_parent(&self.store).await?, - key!('P') => cursor.move_to_root(&self.store).await?, - key!('h') | key!(Left) => cursor.move_to_older_msg(&self.store).await?, - key!('l') | key!(Right) => cursor.move_to_newer_msg(&self.store).await?, - key!('H') | key!(Ctrl + Left) => cursor.move_to_older_unseen_msg(&self.store).await?, - key!('L') | key!(Ctrl + Right) => cursor.move_to_newer_unseen_msg(&self.store).await?, - key!('g') | key!(Home) => cursor.move_to_top(&self.store).await?, - key!('G') | key!(End) => cursor.move_to_bottom(), - key!(Ctrl + 'y') => self.scroll_by(cursor, editor, widthdb, 1).await?, - key!(Ctrl + 'e') => self.scroll_by(cursor, editor, widthdb, -1).await?, - key!(Ctrl + 'u') => { - let delta = chat_height / 2; - self.scroll_by(cursor, editor, widthdb, delta).await?; - } - key!(Ctrl + 'd') => { - let delta = -(chat_height / 2); - self.scroll_by(cursor, editor, widthdb, delta).await?; - } - key!(Ctrl + 'b') | key!(PageUp) => { - let delta = chat_height.saturating_sub(1); - self.scroll_by(cursor, editor, widthdb, delta).await?; - } - key!(Ctrl + 'f') | key!(PageDown) => { - let delta = -chat_height.saturating_sub(1); - self.scroll_by(cursor, editor, widthdb, delta).await?; - } - key!('z') => self.center_cursor(cursor, editor, widthdb).await?, - _ => return Ok(false), + // Basic cursor movement + if event.matches(&keys.cursor.up) { + cursor.move_up_in_tree(&self.store, &self.folded).await?; + return Ok(true); + } + if event.matches(&keys.cursor.down) { + cursor.move_down_in_tree(&self.store, &self.folded).await?; + return Ok(true); + } + if event.matches(&keys.cursor.to_top) { + cursor.move_to_top(&self.store).await?; + return Ok(true); + } + if event.matches(&keys.cursor.to_bottom) { + cursor.move_to_bottom(); + return Ok(true); } - Ok(true) - } + // Tree cursor movement + if event.matches(&keys.tree.cursor.to_above_sibling) { + cursor.move_to_prev_sibling(&self.store).await?; + return Ok(true); + } + if event.matches(&keys.tree.cursor.to_below_sibling) { + cursor.move_to_next_sibling(&self.store).await?; + return Ok(true); + } + if event.matches(&keys.tree.cursor.to_parent) { + cursor.move_to_parent(&self.store).await?; + return Ok(true); + } + if event.matches(&keys.tree.cursor.to_root) { + cursor.move_to_root(&self.store).await?; + return Ok(true); + } + if event.matches(&keys.tree.cursor.to_older_message) { + cursor.move_to_older_msg(&self.store).await?; + return Ok(true); + } + if event.matches(&keys.tree.cursor.to_newer_message) { + cursor.move_to_newer_msg(&self.store).await?; + return Ok(true); + } + if event.matches(&keys.tree.cursor.to_older_unseen_message) { + cursor.move_to_older_unseen_msg(&self.store).await?; + return Ok(true); + } + if event.matches(&keys.tree.cursor.to_newer_unseen_message) { + cursor.move_to_newer_unseen_msg(&self.store).await?; + return Ok(true); + } - pub fn list_action_key_bindings(&self, bindings: &mut KeyBindingsList) { - bindings.binding("space", "fold current message's subtree"); - bindings.binding("s", "toggle current message's seen status"); - bindings.binding("S", "mark all visible messages as seen"); - bindings.binding("ctrl+s", "mark all older messages as seen"); + // Scrolling + if event.matches(&keys.scroll.up_line) { + self.scroll_by(cursor, editor, event.widthdb(), 1).await?; + return Ok(true); + } + if event.matches(&keys.scroll.down_line) { + self.scroll_by(cursor, editor, event.widthdb(), -1).await?; + return Ok(true); + } + if event.matches(&keys.scroll.up_half) { + let delta = chat_height / 2; + self.scroll_by(cursor, editor, event.widthdb(), delta) + .await?; + return Ok(true); + } + if event.matches(&keys.scroll.down_half) { + let delta = -(chat_height / 2); + self.scroll_by(cursor, editor, event.widthdb(), delta) + .await?; + return Ok(true); + } + if event.matches(&keys.scroll.up_full) { + let delta = chat_height.saturating_sub(1); + self.scroll_by(cursor, editor, event.widthdb(), delta) + .await?; + return Ok(true); + } + if event.matches(&keys.scroll.down_full) { + let delta = -chat_height.saturating_sub(1); + self.scroll_by(cursor, editor, event.widthdb(), delta) + .await?; + return Ok(true); + } + if event.matches(&keys.scroll.center_cursor) { + self.center_cursor(cursor, editor, event.widthdb()).await?; + return Ok(true); + } + + Ok(false) } async fn handle_action_input_event( &mut self, - event: &InputEvent, + event: &mut InputEvent<'_>, + keys: &Keys, id: Option<&M::Id>, ) -> Result { - match event { - key!(' ') => { - if let Some(id) = id { - if !self.folded.remove(id) { - self.folded.insert(id.clone()); - } - return Ok(true); + if event.matches(&keys.tree.action.fold_tree) { + if let Some(id) = id { + if !self.folded.remove(id) { + self.folded.insert(id.clone()); } } - key!('s') => { - if let Some(id) = id { - if let Some(msg) = self.store.tree(id).await?.msg(id) { - self.store.set_seen(id, !msg.seen()).await?; - } - return Ok(true); - } - } - key!('S') => { - for id in &self.last_visible_msgs { - self.store.set_seen(id, true).await?; - } - return Ok(true); - } - key!(Ctrl + 's') => { - if let Some(id) = id { - self.store.set_older_seen(id, true).await?; - } else { - self.store - .set_older_seen(&M::last_possible_id(), true) - .await?; - } - return Ok(true); - } - _ => {} + return Ok(true); } - Ok(false) - } - pub fn list_edit_initiating_key_bindings(&self, bindings: &mut KeyBindingsList) { - bindings.binding("r", "reply to message (inline if possible, else directly)"); - bindings.binding("R", "reply to message (opposite of R)"); - bindings.binding("t", "start a new thread"); + if event.matches(&keys.tree.action.toggle_seen) { + if let Some(id) = id { + if let Some(msg) = self.store.tree(id).await?.msg(id) { + self.store.set_seen(id, !msg.seen()).await?; + } + } + return Ok(true); + } + + if event.matches(&keys.tree.action.mark_visible_seen) { + for id in &self.last_visible_msgs { + self.store.set_seen(id, true).await?; + } + return Ok(true); + } + + if event.matches(&keys.tree.action.mark_older_seen) { + if let Some(id) = id { + self.store.set_older_seen(id, true).await?; + } else { + self.store + .set_older_seen(&M::last_possible_id(), true) + .await?; + } + return Ok(true); + } + + Ok(false) } async fn handle_edit_initiating_input_event( &mut self, - event: &InputEvent, + event: &mut InputEvent<'_>, + keys: &Keys, cursor: &mut Cursor, id: Option, ) -> Result { - match event { - key!('r') => { - if let Some(parent) = cursor.parent_for_normal_tree_reply(&self.store).await? { - *cursor = Cursor::Editor { - coming_from: id, - parent, - }; - } - } - key!('R') => { - if let Some(parent) = cursor.parent_for_alternate_tree_reply(&self.store).await? { - *cursor = Cursor::Editor { - coming_from: id, - parent, - }; - } - } - key!('t') | key!('T') => { + if event.matches(&keys.tree.action.reply) { + if let Some(parent) = cursor.parent_for_normal_tree_reply(&self.store).await? { *cursor = Cursor::Editor { coming_from: id, - parent: None, + parent, }; } - _ => return Ok(false), + return Ok(true); } - Ok(true) - } - - pub fn list_normal_key_bindings(&self, bindings: &mut KeyBindingsList, can_compose: bool) { - self.list_movement_key_bindings(bindings); - bindings.empty(); - self.list_action_key_bindings(bindings); - if can_compose { - bindings.empty(); - self.list_edit_initiating_key_bindings(bindings); + if event.matches(&keys.tree.action.reply_alternate) { + if let Some(parent) = cursor.parent_for_alternate_tree_reply(&self.store).await? { + *cursor = Cursor::Editor { + coming_from: id, + parent, + }; + } + return Ok(true); } + + if event.matches(&keys.tree.action.new_thread) { + *cursor = Cursor::Editor { + coming_from: id, + parent: None, + }; + return Ok(true); + } + + Ok(false) } async fn handle_normal_input_event( &mut self, - frame: &mut Frame, - event: &InputEvent, + event: &mut InputEvent<'_>, + keys: &Keys, cursor: &mut Cursor, editor: &mut EditorState, can_compose: bool, @@ -234,102 +254,73 @@ impl> TreeViewState { S: Send + Sync, S::Error: Send, { - #[allow(clippy::if_same_then_else)] - Ok( - if self - .handle_movement_input_event(frame, event, cursor, editor) + if self + .handle_movement_input_event(event, keys, cursor, editor) + .await? + { + return Ok(true); + } + + if self + .handle_action_input_event(event, keys, id.as_ref()) + .await? + { + return Ok(true); + } + + if can_compose + && self + .handle_edit_initiating_input_event(event, keys, cursor, id) .await? - { - true - } else if self.handle_action_input_event(event, id.as_ref()).await? { - true - } else if can_compose { - self.handle_edit_initiating_input_event(event, cursor, id) - .await? - } else { - false - }, - ) + { + return Ok(true); + } + + Ok(false) } - fn list_editor_key_bindings(&self, bindings: &mut KeyBindingsList) { - bindings.binding("esc", "close editor"); - bindings.binding("enter", "send message"); - util::list_editor_key_bindings_allowing_external_editing(bindings, |_| true); - } - - #[allow(clippy::too_many_arguments)] fn handle_editor_input_event( &mut self, - terminal: &mut Terminal, - crossterm_lock: &Arc>, - event: &InputEvent, + event: &mut InputEvent<'_>, + keys: &Keys, cursor: &mut Cursor, editor: &mut EditorState, coming_from: Option, parent: Option, ) -> Reaction { - // TODO Tab-completion + // Abort edit + if event.matches(&keys.general.abort) { + *cursor = coming_from.map(Cursor::Msg).unwrap_or(Cursor::Bottom); + return Reaction::Handled; + } - match event { - key!(Esc) => { - *cursor = coming_from.map(Cursor::Msg).unwrap_or(Cursor::Bottom); + // Send message + if event.matches(&keys.general.confirm) { + let content = editor.text().to_string(); + if content.trim().is_empty() { return Reaction::Handled; } - - key!(Enter) => { - let content = editor.text().to_string(); - if !content.trim().is_empty() { - *cursor = Cursor::Pseudo { - coming_from, - parent: parent.clone(), - }; - return Reaction::Composed { parent, content }; - } - } - - _ => { - let handled = util::handle_editor_input_event_allowing_external_editing( - editor, - terminal, - crossterm_lock, - event, - |_| true, - ); - match handled { - Ok(true) => {} - Ok(false) => return Reaction::NotHandled, - Err(e) => return Reaction::ComposeError(e), - } - } + *cursor = Cursor::Pseudo { + coming_from, + parent: parent.clone(), + }; + return Reaction::Composed { parent, content }; } - Reaction::Handled - } + // TODO Tab-completion - pub fn list_key_bindings( - &self, - bindings: &mut KeyBindingsList, - cursor: &Cursor, - can_compose: bool, - ) { - bindings.heading("Chat"); - match cursor { - Cursor::Bottom | Cursor::Msg(_) => { - self.list_normal_key_bindings(bindings, can_compose); - } - Cursor::Editor { .. } => self.list_editor_key_bindings(bindings), - Cursor::Pseudo { .. } => { - self.list_normal_key_bindings(bindings, false); - } + // Editing + if util::handle_editor_input_event(editor, event, keys, |_| true) { + return Reaction::Handled; } + + Reaction::NotHandled } pub async fn handle_input_event( &mut self, - terminal: &mut Terminal, - crossterm_lock: &Arc>, - event: &InputEvent, + event: &mut InputEvent<'_>, + keys: &Keys, cursor: &mut Cursor, editor: &mut EditorState, can_compose: bool, @@ -343,14 +334,7 @@ impl> TreeViewState { Ok(match cursor { Cursor::Bottom => { if self - .handle_normal_input_event( - terminal.frame(), - event, - cursor, - editor, - can_compose, - None, - ) + .handle_normal_input_event(event, keys, cursor, editor, can_compose, None) .await? { Reaction::Handled @@ -361,14 +345,7 @@ impl> TreeViewState { Cursor::Msg(id) => { let id = id.clone(); if self - .handle_normal_input_event( - terminal.frame(), - event, - cursor, - editor, - can_compose, - Some(id), - ) + .handle_normal_input_event(event, keys, cursor, editor, can_compose, Some(id)) .await? { Reaction::Handled @@ -382,19 +359,11 @@ impl> TreeViewState { } => { let coming_from = coming_from.clone(); let parent = parent.clone(); - self.handle_editor_input_event( - terminal, - crossterm_lock, - event, - cursor, - editor, - coming_from, - parent, - ) + self.handle_editor_input_event(event, keys, cursor, editor, coming_from, parent) } Cursor::Pseudo { .. } => { if self - .handle_movement_input_event(terminal.frame(), event, cursor, editor) + .handle_movement_input_event(event, keys, cursor, editor) .await? { Reaction::Handled diff --git a/cove/src/ui/euph/account.rs b/cove/src/ui/euph/account.rs index c398662..359e9d5 100644 --- a/cove/src/ui/euph/account.rs +++ b/cove/src/ui/euph/account.rs @@ -1,14 +1,17 @@ +use cove_config::Keys; +use cove_input::InputEvent; use crossterm::style::Stylize; use euphoxide::api::PersonalAccountView; use euphoxide::conn; -use toss::widgets::{EditorState, Empty, Join3, Join4, Text}; -use toss::{Style, Terminal, Widget, WidgetExt}; +use toss::widgets::{EditorState, Empty, Join3, Join4, Join5, Text}; +use toss::{Style, Widget, WidgetExt}; use crate::euph::{self, Room}; -use crate::ui::input::{key, InputEvent, KeyBindingsList}; use crate::ui::widgets::Popup; use crate::ui::{util, UiError}; +use super::popup::PopupResult; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Focus { Email, @@ -65,7 +68,7 @@ pub struct LoggedIn(PersonalAccountView); impl LoggedIn { fn widget(&self) -> impl Widget { let bold = Style::new().bold(); - Join3::vertical( + Join5::vertical( Text::new(("Logged in", bold.green())).segment(), Empty::new().with_height(1).segment(), Join3::horizontal( @@ -76,6 +79,8 @@ impl LoggedIn { Text::new((&self.0.email,)).segment(), ) .segment(), + Empty::new().with_height(1).segment(), + Text::new(("Log out", Style::new().black().on_white())).segment(), ) } } @@ -85,12 +90,6 @@ pub enum AccountUiState { LoggedIn(LoggedIn), } -pub enum EventResult { - NotHandled, - Handled, - ResetState, -} - impl AccountUiState { pub fn new() -> Self { Self::LoggedOut(LoggedOut::new()) @@ -121,94 +120,74 @@ impl AccountUiState { Popup::new(inner, "Account") } - pub fn list_key_bindings(&self, bindings: &mut KeyBindingsList) { - bindings.binding("esc", "close account ui"); - - match self { - Self::LoggedOut(logged_out) => { - match logged_out.focus { - Focus::Email => bindings.binding("enter", "focus on password"), - Focus::Password => bindings.binding("enter", "log in"), - } - bindings.binding("tab", "switch focus"); - util::list_editor_key_bindings(bindings, |c| c != '\n'); - } - Self::LoggedIn(_) => bindings.binding("L", "log out"), - } - } - pub fn handle_input_event( &mut self, - terminal: &mut Terminal, - event: &InputEvent, + event: &mut InputEvent<'_>, + keys: &Keys, room: &Option, - ) -> EventResult { - if let key!(Esc) = event { - return EventResult::ResetState; + ) -> PopupResult { + if event.matches(&keys.general.abort) { + return PopupResult::Close; } match self { Self::LoggedOut(logged_out) => { - if let key!(Tab) = event { + if event.matches(&keys.general.focus) { logged_out.focus = match logged_out.focus { Focus::Email => Focus::Password, Focus::Password => Focus::Email, }; - return EventResult::Handled; + return PopupResult::Handled; } match logged_out.focus { Focus::Email => { - if let key!(Enter) = event { + if event.matches(&keys.general.confirm) { logged_out.focus = Focus::Password; - return EventResult::Handled; + return PopupResult::Handled; } if util::handle_editor_input_event( &mut logged_out.email, - terminal, event, + keys, |c| c != '\n', ) { - EventResult::Handled - } else { - EventResult::NotHandled + return PopupResult::Handled; } } Focus::Password => { - if let key!(Enter) = event { + if event.matches(&keys.general.confirm) { if let Some(room) = room { let _ = room.login( logged_out.email.text().to_string(), logged_out.password.text().to_string(), ); } - return EventResult::Handled; + return PopupResult::Handled; } if util::handle_editor_input_event( &mut logged_out.password, - terminal, event, + keys, |c| c != '\n', ) { - EventResult::Handled - } else { - EventResult::NotHandled + return PopupResult::Handled; } } } } Self::LoggedIn(_) => { - if let key!('L') = event { + if event.matches(&keys.general.confirm) { if let Some(room) = room { let _ = room.logout(); } - EventResult::Handled - } else { - EventResult::NotHandled + return PopupResult::Handled; } } } + + PopupResult::NotHandled } } diff --git a/cove/src/ui/euph/auth.rs b/cove/src/ui/euph/auth.rs index 8cc58f9..b938ff1 100644 --- a/cove/src/ui/euph/auth.rs +++ b/cove/src/ui/euph/auth.rs @@ -1,11 +1,14 @@ +use cove_config::Keys; +use cove_input::InputEvent; use toss::widgets::EditorState; -use toss::{Terminal, Widget}; +use toss::Widget; use crate::euph::Room; -use crate::ui::input::{key, InputEvent, KeyBindingsList}; use crate::ui::widgets::Popup; use crate::ui::{util, UiError}; +use super::popup::PopupResult; + pub fn new() -> EditorState { EditorState::new() } @@ -17,38 +20,26 @@ pub fn widget(editor: &mut EditorState) -> impl Widget + '_ { ) } -pub fn list_key_bindings(bindings: &mut KeyBindingsList) { - bindings.binding("esc", "abort"); - bindings.binding("enter", "authenticate"); - util::list_editor_key_bindings(bindings, |_| true); -} - -pub enum EventResult { - NotHandled, - Handled, - ResetState, -} - pub fn handle_input_event( - terminal: &mut Terminal, - event: &InputEvent, + event: &mut InputEvent<'_>, + keys: &Keys, room: &Option, editor: &mut EditorState, -) -> EventResult { - match event { - key!(Esc) => EventResult::ResetState, - key!(Enter) => { - if let Some(room) = &room { - let _ = room.auth(editor.text().to_string()); - } - EventResult::ResetState - } - _ => { - if util::handle_editor_input_event(editor, terminal, event, |_| true) { - EventResult::Handled - } else { - EventResult::NotHandled - } - } +) -> PopupResult { + if event.matches(&keys.general.abort) { + return PopupResult::Close; } + + if event.matches(&keys.general.confirm) { + if let Some(room) = &room { + let _ = room.auth(editor.text().to_string()); + } + return PopupResult::Close; + } + + if util::handle_editor_input_event(editor, event, keys, |_| true) { + return PopupResult::Handled; + } + + PopupResult::NotHandled } diff --git a/cove/src/ui/euph/inspect.rs b/cove/src/ui/euph/inspect.rs index 7cbf054..25620a2 100644 --- a/cove/src/ui/euph/inspect.rs +++ b/cove/src/ui/euph/inspect.rs @@ -1,13 +1,16 @@ +use cove_config::Keys; +use cove_input::InputEvent; use crossterm::style::Stylize; use euphoxide::api::{Message, NickEvent, SessionView}; use euphoxide::conn::SessionInfo; use toss::widgets::Text; use toss::{Style, Styled, Widget}; -use crate::ui::input::{key, InputEvent, KeyBindingsList}; use crate::ui::widgets::Popup; use crate::ui::UiError; +use super::popup::PopupResult; + macro_rules! line { ( $text:ident, $name:expr, $val:expr ) => { $text = $text @@ -122,18 +125,10 @@ pub fn message_widget(msg: &Message) -> impl Widget { Popup::new(Text::new(text), "Inspect message") } -pub fn list_key_bindings(bindings: &mut KeyBindingsList) { - bindings.binding("esc", "close"); -} - -pub enum EventResult { - NotHandled, - Close, -} - -pub fn handle_input_event(event: &InputEvent) -> EventResult { - match event { - key!(Esc) => EventResult::Close, - _ => EventResult::NotHandled, +pub fn handle_input_event(event: &mut InputEvent<'_>, keys: &Keys) -> PopupResult { + if event.matches(&keys.general.abort) { + return PopupResult::Close; } + + PopupResult::NotHandled } diff --git a/cove/src/ui/euph/links.rs b/cove/src/ui/euph/links.rs index 3cebf7d..00d9d7d 100644 --- a/cove/src/ui/euph/links.rs +++ b/cove/src/ui/euph/links.rs @@ -1,26 +1,21 @@ -use std::io; - +use cove_config::Keys; +use cove_input::InputEvent; +use crossterm::event::KeyCode; use crossterm::style::Stylize; use linkify::{LinkFinder, LinkKind}; use toss::widgets::Text; use toss::{Style, Styled, Widget}; -use crate::ui::input::{key, InputEvent, KeyBindingsList}; use crate::ui::widgets::{ListBuilder, ListState, Popup}; -use crate::ui::UiError; +use crate::ui::{util, UiError}; + +use super::popup::PopupResult; pub struct LinksState { links: Vec, list: ListState, } -pub enum EventResult { - NotHandled, - Handled, - Close, - ErrorOpeningLink { link: String, error: io::Error }, -} - const NUMBER_KEYS: [char; 10] = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0']; impl LinksState { @@ -77,7 +72,7 @@ impl LinksState { Popup::new(list_builder.build(&mut self.list), "Links") } - fn open_link_by_id(&self, id: usize) -> EventResult { + fn open_link_by_id(&self, id: usize) -> PopupResult { if let Some(link) = self.links.get(id) { // The `http://` or `https://` schema is necessary for open::that to // successfully open the link in the browser. @@ -88,53 +83,52 @@ impl LinksState { }; if let Err(error) = open::that(&link) { - return EventResult::ErrorOpeningLink { link, error }; + return PopupResult::ErrorOpeningLink { link, error }; } } - EventResult::Handled + PopupResult::Handled } - fn open_link(&self) -> EventResult { + fn open_link(&self) -> PopupResult { if let Some(id) = self.list.selected() { self.open_link_by_id(*id) } else { - EventResult::Handled + PopupResult::Handled } } - pub fn list_key_bindings(&self, bindings: &mut KeyBindingsList) { - bindings.binding("esc", "close links popup"); - bindings.binding("j/k, ↓/↑", "move cursor up/down"); - bindings.binding("g, home", "move cursor to top"); - bindings.binding("G, end", "move cursor to bottom"); - bindings.binding("ctrl+y/e", "scroll up/down"); - bindings.empty(); - bindings.binding("enter", "open selected link"); - bindings.binding("1,2,...", "open link by position"); - } - - pub fn handle_input_event(&mut self, event: &InputEvent) -> EventResult { - match event { - key!(Esc) => return EventResult::Close, - key!('k') | key!(Up) => self.list.move_cursor_up(), - key!('j') | key!(Down) => self.list.move_cursor_down(), - key!('g') | key!(Home) => self.list.move_cursor_to_top(), - key!('G') | key!(End) => self.list.move_cursor_to_bottom(), - key!(Ctrl + 'y') => self.list.scroll_up(1), - key!(Ctrl + 'e') => self.list.scroll_down(1), - key!(Enter) => return self.open_link(), - key!('1') => return self.open_link_by_id(0), - key!('2') => return self.open_link_by_id(1), - key!('3') => return self.open_link_by_id(2), - key!('4') => return self.open_link_by_id(3), - key!('5') => return self.open_link_by_id(4), - key!('6') => return self.open_link_by_id(5), - key!('7') => return self.open_link_by_id(6), - key!('8') => return self.open_link_by_id(7), - key!('9') => return self.open_link_by_id(8), - key!('0') => return self.open_link_by_id(9), - _ => return EventResult::NotHandled, + pub fn handle_input_event(&mut self, event: &mut InputEvent<'_>, keys: &Keys) -> PopupResult { + if event.matches(&keys.general.abort) { + return PopupResult::Close; } - EventResult::Handled + + if event.matches(&keys.general.confirm) { + return self.open_link(); + } + + if util::handle_list_input_event(&mut self.list, event, keys) { + return PopupResult::Handled; + } + + // TODO Mention that this is possible in the UI + if let Some(key_event) = event.key_event() { + if key_event.modifiers.is_empty() { + match key_event.code { + KeyCode::Char('1') => return self.open_link_by_id(0), + KeyCode::Char('2') => return self.open_link_by_id(1), + KeyCode::Char('3') => return self.open_link_by_id(2), + KeyCode::Char('4') => return self.open_link_by_id(3), + KeyCode::Char('5') => return self.open_link_by_id(4), + KeyCode::Char('6') => return self.open_link_by_id(5), + KeyCode::Char('7') => return self.open_link_by_id(6), + KeyCode::Char('8') => return self.open_link_by_id(7), + KeyCode::Char('9') => return self.open_link_by_id(8), + KeyCode::Char('0') => return self.open_link_by_id(9), + _ => {} + } + } + } + + PopupResult::NotHandled } } diff --git a/cove/src/ui/euph/nick.rs b/cove/src/ui/euph/nick.rs index c928297..0bb1062 100644 --- a/cove/src/ui/euph/nick.rs +++ b/cove/src/ui/euph/nick.rs @@ -1,12 +1,15 @@ +use cove_config::Keys; +use cove_input::InputEvent; use euphoxide::conn::Joined; use toss::widgets::EditorState; -use toss::{Style, Terminal, Widget}; +use toss::{Style, Widget}; use crate::euph::{self, Room}; -use crate::ui::input::{key, InputEvent, KeyBindingsList}; use crate::ui::widgets::Popup; use crate::ui::{util, UiError}; +use super::popup::PopupResult; + pub fn new(joined: Joined) -> EditorState { EditorState::with_initial_text(joined.session.name) } @@ -19,42 +22,26 @@ pub fn widget(editor: &mut EditorState) -> impl Widget + '_ { Popup::new(inner, "Choose nick") } -fn nick_char(c: char) -> bool { - c != '\n' -} - -pub fn list_key_bindings(bindings: &mut KeyBindingsList) { - bindings.binding("esc", "abort"); - bindings.binding("enter", "set nick"); - util::list_editor_key_bindings(bindings, nick_char); -} - -pub enum EventResult { - NotHandled, - Handled, - ResetState, -} - pub fn handle_input_event( - terminal: &mut Terminal, - event: &InputEvent, + event: &mut InputEvent<'_>, + keys: &Keys, room: &Option, editor: &mut EditorState, -) -> EventResult { - match event { - key!(Esc) => EventResult::ResetState, - key!(Enter) => { - if let Some(room) = &room { - let _ = room.nick(editor.text().to_string()); - } - EventResult::ResetState - } - _ => { - if util::handle_editor_input_event(editor, terminal, event, nick_char) { - EventResult::Handled - } else { - EventResult::NotHandled - } - } +) -> PopupResult { + if event.matches(&keys.general.abort) { + return PopupResult::Close; } + + if event.matches(&keys.general.confirm) { + if let Some(room) = &room { + let _ = room.nick(editor.text().to_string()); + } + return PopupResult::Close; + } + + if util::handle_editor_input_event(editor, event, keys, |c| c != '\n') { + return PopupResult::Handled; + } + + PopupResult::NotHandled } diff --git a/cove/src/ui/euph/popup.rs b/cove/src/ui/euph/popup.rs index 2ab8278..f70e999 100644 --- a/cove/src/ui/euph/popup.rs +++ b/cove/src/ui/euph/popup.rs @@ -1,3 +1,5 @@ +use std::io; + use crossterm::style::Stylize; use toss::widgets::Text; use toss::{Style, Styled, Widget}; @@ -30,3 +32,10 @@ impl RoomPopup { } } } + +pub enum PopupResult { + NotHandled, + Handled, + Close, + ErrorOpeningLink { link: String, error: io::Error }, +} diff --git a/cove/src/ui/euph/room.rs b/cove/src/ui/euph/room.rs index 8dc6ed0..f539a0e 100644 --- a/cove/src/ui/euph/room.rs +++ b/cove/src/ui/euph/room.rs @@ -1,27 +1,26 @@ use std::collections::VecDeque; -use std::sync::Arc; +use cove_config::Keys; +use cove_input::InputEvent; use crossterm::style::Stylize; use euphoxide::api::{Data, Message, MessageId, PacketType, SessionId}; use euphoxide::bot::instance::{Event, ServerConfig}; use euphoxide::conn::{self, Joined, Joining, SessionInfo}; -use parking_lot::FairMutex; use tokio::sync::oneshot::error::TryRecvError; use tokio::sync::{mpsc, oneshot}; use toss::widgets::{BoxedAsync, EditorState, Join2, Layer, Text}; -use toss::{Style, Styled, Terminal, Widget, WidgetExt}; +use toss::{Style, Styled, Widget, WidgetExt}; use crate::euph; use crate::macros::logging_unwrap; use crate::ui::chat::{ChatState, Reaction}; -use crate::ui::input::{key, InputEvent, KeyBindingsList}; use crate::ui::widgets::ListState; use crate::ui::{util, UiError, UiEvent}; use crate::vault::EuphRoomVault; -use super::account::{self, AccountUiState}; -use super::links::{self, LinksState}; -use super::popup::RoomPopup; +use super::account::AccountUiState; +use super::links::LinksState; +use super::popup::{PopupResult, RoomPopup}; use super::{auth, inspect, nick, nick_list}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -316,29 +315,13 @@ impl EuphRoom { Text::new(info).padding().with_horizontal(1).border() } - async fn list_chat_key_bindings(&self, bindings: &mut KeyBindingsList) { - let can_compose = matches!( - self.room_state(), - Some(euph::State::Connected(_, conn::State::Joined(_))) - ); - self.chat.list_key_bindings(bindings, can_compose).await; - } - - async fn handle_chat_input_event( - &mut self, - terminal: &mut Terminal, - crossterm_lock: &Arc>, - event: &InputEvent, - ) -> bool { + async fn handle_chat_input_event(&mut self, event: &mut InputEvent<'_>, keys: &Keys) -> bool { let can_compose = matches!( self.room_state(), Some(euph::State::Connected(_, conn::State::Joined(_))) ); - let reaction = self - .chat - .handle_input_event(terminal, crossterm_lock, event, can_compose) - .await; + let reaction = self.chat.handle_input_event(event, keys, can_compose).await; let reaction = logging_unwrap!(reaction); match reaction { @@ -353,19 +336,12 @@ impl EuphRoom { return true; } } - Reaction::ComposeError(e) => { - self.popups.push_front(RoomPopup::Error { - description: "Failed to use external editor".to_string(), - reason: format!("{e}"), - }); - return true; - } } false } - fn list_room_key_bindings(&self, bindings: &mut KeyBindingsList) { + async fn handle_room_input_event(&mut self, event: &mut InputEvent<'_>, keys: &Keys) -> bool { match self.room_state() { // Authenticating Some(euph::State::Connected( @@ -374,138 +350,95 @@ impl EuphRoom { bounce: Some(_), .. }), )) => { - bindings.binding("a", "authenticate"); - } - - // Connected - Some(euph::State::Connected(_, conn::State::Joined(_))) => { - bindings.binding("n", "change nick"); - bindings.binding("m", "download more messages"); - bindings.binding("A", "show account ui"); - } - - // Otherwise - _ => {} - } - - // Inspecting messages - bindings.binding("i", "inspect message"); - bindings.binding("I", "show message links"); - bindings.binding("ctrl+p", "open room's plugh.de/present page"); - } - - async fn handle_room_input_event(&mut self, event: &InputEvent) -> bool { - match self.room_state() { - // Authenticating - Some(euph::State::Connected( - _, - conn::State::Joining(Joining { - bounce: Some(_), .. - }), - )) => { - if let key!('a') = event { + if event.matches(&keys.room.action.authenticate) { self.state = State::Auth(auth::new()); return true; } } // Joined - Some(euph::State::Connected(_, conn::State::Joined(joined))) => match event { - key!('n') | key!('N') => { + Some(euph::State::Connected(_, conn::State::Joined(joined))) => { + if event.matches(&keys.room.action.nick) { self.state = State::Nick(nick::new(joined.clone())); return true; } - key!('m') => { + if event.matches(&keys.room.action.more_messages) { if let Some(room) = &self.room { let _ = room.log(); } return true; } - key!('A') => { + if event.matches(&keys.room.action.account) { self.state = State::Account(AccountUiState::new()); return true; } - _ => {} - }, + } // Otherwise _ => {} } // Always applicable - match event { - key!('i') => { - if let Some(id) = self.chat.cursor() { - if let Some(msg) = logging_unwrap!(self.vault().full_msg(*id).await) { - self.state = State::InspectMessage(msg); - } - } - return true; + if event.matches(&keys.room.action.present) { + let link = format!("https://plugh.de/present/{}/", self.name()); + if let Err(error) = open::that(&link) { + self.popups.push_front(RoomPopup::Error { + description: format!("Failed to open link: {link}"), + reason: format!("{error}"), + }); } - key!('I') => { - if let Some(id) = self.chat.cursor() { - if let Some(msg) = logging_unwrap!(self.vault().msg(*id).await) { - self.state = State::Links(LinksState::new(&msg.content)); - } - } - return true; - } - key!(Ctrl + 'p') => { - let link = format!("https://plugh.de/present/{}/", self.name()); - if let Err(error) = open::that(&link) { - self.popups.push_front(RoomPopup::Error { - description: format!("Failed to open link: {link}"), - reason: format!("{error}"), - }); - } - return true; - } - _ => {} + return true; } false } - async fn list_chat_focus_key_bindings(&self, bindings: &mut KeyBindingsList) { - self.list_room_key_bindings(bindings); - bindings.empty(); - self.list_chat_key_bindings(bindings).await; - } - async fn handle_chat_focus_input_event( &mut self, - terminal: &mut Terminal, - crossterm_lock: &Arc>, - event: &InputEvent, + event: &mut InputEvent<'_>, + keys: &Keys, ) -> bool { // We need to handle chat input first, otherwise the other // key bindings will shadow characters in the editor. - if self - .handle_chat_input_event(terminal, crossterm_lock, event) - .await - { + if self.handle_chat_input_event(event, keys).await { return true; } - if self.handle_room_input_event(event).await { + if self.handle_room_input_event(event, keys).await { + return true; + } + + if event.matches(&keys.tree.action.inspect) { + if let Some(id) = self.chat.cursor() { + if let Some(msg) = logging_unwrap!(self.vault().full_msg(*id).await) { + self.state = State::InspectMessage(msg); + } + } + return true; + } + + if event.matches(&keys.tree.action.links) { + if let Some(id) = self.chat.cursor() { + if let Some(msg) = logging_unwrap!(self.vault().msg(*id).await) { + self.state = State::Links(LinksState::new(&msg.content)); + } + } return true; } false } - fn list_nick_list_focus_key_bindings(&self, bindings: &mut KeyBindingsList) { - util::list_list_key_bindings(bindings); - - bindings.binding("i", "inspect session"); - } - - fn handle_nick_list_focus_input_event(&mut self, event: &InputEvent) -> bool { - if util::handle_list_input_event(&mut self.nick_list, event) { + fn handle_nick_list_focus_input_event( + &mut self, + event: &mut InputEvent<'_>, + keys: &Keys, + ) -> bool { + if util::handle_list_input_event(&mut self.nick_list, event, keys) { return true; } - if let key!('i') = event { + if event.matches(&keys.tree.action.inspect) { if let Some(euph::State::Connected(_, conn::State::Joined(joined))) = self.room_state() { if let Some(id) = self.nick_list.selected() { @@ -523,58 +456,27 @@ impl EuphRoom { false } - pub async fn list_normal_key_bindings(&self, bindings: &mut KeyBindingsList) { - // Handled in rooms list, not here - bindings.binding("esc", "leave room"); - + async fn handle_normal_input_event(&mut self, event: &mut InputEvent<'_>, keys: &Keys) -> bool { match self.focus { Focus::Chat => { - if let Some(euph::State::Connected(_, conn::State::Joined(_))) = self.room_state() { - bindings.binding("tab", "focus on nick list"); - } - - self.list_chat_focus_key_bindings(bindings).await; - } - Focus::NickList => { - bindings.binding("tab, esc", "focus on chat"); - bindings.empty(); - bindings.heading("Nick list"); - self.list_nick_list_focus_key_bindings(bindings); - } - } - } - - async fn handle_normal_input_event( - &mut self, - terminal: &mut Terminal, - crossterm_lock: &Arc>, - event: &InputEvent, - ) -> bool { - match self.focus { - Focus::Chat => { - // Needs to be handled first or the tab key may be shadowed - // during editing. - if self - .handle_chat_focus_input_event(terminal, crossterm_lock, event) - .await - { + if self.handle_chat_focus_input_event(event, keys).await { return true; } if let Some(euph::State::Connected(_, conn::State::Joined(_))) = self.room_state() { - if let key!(Tab) = event { + if event.matches(&keys.general.focus) { self.focus = Focus::NickList; return true; } } } Focus::NickList => { - if let key!(Tab) | key!(Esc) = event { + if event.matches(&keys.general.abort) || event.matches(&keys.general.focus) { self.focus = Focus::Chat; return true; } - if self.handle_nick_list_focus_input_event(event) { + if self.handle_nick_list_focus_input_event(event, keys) { return true; } } @@ -583,100 +485,40 @@ impl EuphRoom { false } - pub async fn list_key_bindings(&self, bindings: &mut KeyBindingsList) { - bindings.heading("Room"); - + pub async fn handle_input_event(&mut self, event: &mut InputEvent<'_>, keys: &Keys) -> bool { if !self.popups.is_empty() { - bindings.binding("esc", "close popup"); - return; - } - - match &self.state { - State::Normal => self.list_normal_key_bindings(bindings).await, - State::Auth(_) => auth::list_key_bindings(bindings), - State::Nick(_) => nick::list_key_bindings(bindings), - State::Account(account) => account.list_key_bindings(bindings), - State::Links(links) => links.list_key_bindings(bindings), - State::InspectMessage(_) | State::InspectSession(_) => { - inspect::list_key_bindings(bindings) - } - } - } - - pub async fn handle_input_event( - &mut self, - terminal: &mut Terminal, - crossterm_lock: &Arc>, - event: &InputEvent, - ) -> bool { - if !self.popups.is_empty() { - if matches!(event, key!(Esc)) { + if event.matches(&keys.general.abort) { self.popups.pop_back(); return true; } + // Prevent event from reaching anything below the popup return false; } - // TODO Use a common EventResult - - match &mut self.state { - State::Normal => { - self.handle_normal_input_event(terminal, crossterm_lock, event) - .await - } - State::Auth(editor) => { - match auth::handle_input_event(terminal, event, &self.room, editor) { - auth::EventResult::NotHandled => false, - auth::EventResult::Handled => true, - auth::EventResult::ResetState => { - self.state = State::Normal; - true - } - } - } - State::Nick(editor) => { - match nick::handle_input_event(terminal, event, &self.room, editor) { - nick::EventResult::NotHandled => false, - nick::EventResult::Handled => true, - nick::EventResult::ResetState => { - self.state = State::Normal; - true - } - } - } - State::Account(account) => { - match account.handle_input_event(terminal, event, &self.room) { - account::EventResult::NotHandled => false, - account::EventResult::Handled => true, - account::EventResult::ResetState => { - self.state = State::Normal; - true - } - } - } - State::Links(links) => match links.handle_input_event(event) { - links::EventResult::NotHandled => false, - links::EventResult::Handled => true, - links::EventResult::Close => { - self.state = State::Normal; - true - } - links::EventResult::ErrorOpeningLink { link, error } => { - self.popups.push_front(RoomPopup::Error { - description: format!("Failed to open link: {link}"), - reason: format!("{error}"), - }); - true - } - }, + let result = match &mut self.state { + State::Normal => return self.handle_normal_input_event(event, keys).await, + State::Auth(editor) => auth::handle_input_event(event, keys, &self.room, editor), + State::Nick(editor) => nick::handle_input_event(event, keys, &self.room, editor), + State::Account(account) => account.handle_input_event(event, keys, &self.room), + State::Links(links) => links.handle_input_event(event, keys), State::InspectMessage(_) | State::InspectSession(_) => { - match inspect::handle_input_event(event) { - inspect::EventResult::NotHandled => false, - inspect::EventResult::Close => { - self.state = State::Normal; - true - } - } + inspect::handle_input_event(event, keys) + } + }; + + match result { + PopupResult::NotHandled => false, + PopupResult::Handled => true, + PopupResult::Close => { + self.state = State::Normal; + true + } + PopupResult::ErrorOpeningLink { link, error } => { + self.popups.push_front(RoomPopup::Error { + description: format!("Failed to open link: {link}"), + reason: format!("{error}"), + }); + true } } } diff --git a/cove/src/ui/input.rs b/cove/src/ui/input.rs deleted file mode 100644 index 85b5f1f..0000000 --- a/cove/src/ui/input.rs +++ /dev/null @@ -1,175 +0,0 @@ -use std::convert::Infallible; - -use crossterm::event::{Event, KeyCode, KeyModifiers}; -use crossterm::style::Stylize; -use toss::widgets::{Empty, Join2, Text}; -use toss::{Style, Styled, Widget, WidgetExt}; - -use super::widgets::{ListBuilder, ListState}; -use super::UiError; - -#[derive(Debug, Clone)] -pub enum InputEvent { - Key(KeyEvent), - Paste(String), -} - -impl InputEvent { - pub fn from_event(event: Event) -> Option { - match event { - crossterm::event::Event::Key(key) => Some(Self::Key(key.into())), - crossterm::event::Event::Paste(text) => Some(Self::Paste(text)), - _ => None, - } - } -} - -/// A key event data type that is a bit easier to pattern match on than -/// [`crossterm::event::KeyEvent`]. -#[derive(Debug, Clone, Copy)] -pub struct KeyEvent { - pub code: KeyCode, - pub shift: bool, - pub ctrl: bool, - pub alt: bool, -} - -impl From for KeyEvent { - fn from(event: crossterm::event::KeyEvent) -> Self { - Self { - code: event.code, - shift: event.modifiers.contains(KeyModifiers::SHIFT), - ctrl: event.modifiers.contains(KeyModifiers::CONTROL), - alt: event.modifiers.contains(KeyModifiers::ALT), - } - } -} - -#[rustfmt::skip] -#[allow(unused_macro_rules)] -macro_rules! key { - // key!(Paste text) - ( Paste $text:ident ) => { crate::ui::input::InputEvent::Paste($text) }; - - // key!('a') - ( $key:literal ) => { crate::ui::input::InputEvent::Key(crate::ui::input::KeyEvent { code: crossterm::event::KeyCode::Char($key), shift: _, ctrl: false, alt: false, }) }; - ( Ctrl + $key:literal ) => { crate::ui::input::InputEvent::Key(crate::ui::input::KeyEvent { code: crossterm::event::KeyCode::Char($key), shift: _, ctrl: true, alt: false, }) }; - ( Alt + $key:literal ) => { crate::ui::input::InputEvent::Key(crate::ui::input::KeyEvent { code: crossterm::event::KeyCode::Char($key), shift: _, ctrl: false, alt: true, }) }; - - // key!(Char c) - ( Char $key:pat ) => { crate::ui::input::InputEvent::Key(crate::ui::input::KeyEvent { code: crossterm::event::KeyCode::Char($key), shift: _, ctrl: false, alt: false, }) }; - ( Ctrl + Char $key:pat ) => { crate::ui::input::InputEvent::Key(crate::ui::input::KeyEvent { code: crossterm::event::KeyCode::Char($key), shift: _, ctrl: true, alt: false, }) }; - ( Alt + Char $key:pat ) => { crate::ui::input::InputEvent::Key(crate::ui::input::KeyEvent { code: crossterm::event::KeyCode::Char($key), shift: _, ctrl: false, alt: true, }) }; - - // key!(F n) - ( F $key:pat ) => { crate::ui::input::InputEvent::Key(crate::ui::input::KeyEvent { code: crossterm::event::KeyCode::F($key), shift: false, ctrl: false, alt: false, }) }; - ( Shift + F $key:pat ) => { crate::ui::input::InputEvent::Key(crate::ui::input::KeyEvent { code: crossterm::event::KeyCode::F($key), shift: true, ctrl: false, alt: false, }) }; - ( Ctrl + F $key:pat ) => { crate::ui::input::InputEvent::Key(crate::ui::input::KeyEvent { code: crossterm::event::KeyCode::F($key), shift: false, ctrl: true, alt: false, }) }; - ( Alt + F $key:pat ) => { crate::ui::input::InputEvent::Key(crate::ui::input::KeyEvent { code: crossterm::event::KeyCode::F($key), shift: false, ctrl: false, alt: true, }) }; - - // key!(other) - ( $key:ident ) => { crate::ui::input::InputEvent::Key(crate::ui::input::KeyEvent { code: crossterm::event::KeyCode::$key, shift: false, ctrl: false, alt: false, }) }; - ( Shift + $key:ident ) => { crate::ui::input::InputEvent::Key(crate::ui::input::KeyEvent { code: crossterm::event::KeyCode::$key, shift: true, ctrl: false, alt: false, }) }; - ( Ctrl + $key:ident ) => { crate::ui::input::InputEvent::Key(crate::ui::input::KeyEvent { code: crossterm::event::KeyCode::$key, shift: false, ctrl: true, alt: false, }) }; - ( Alt + $key:ident ) => { crate::ui::input::InputEvent::Key(crate::ui::input::KeyEvent { code: crossterm::event::KeyCode::$key, shift: false, ctrl: false, alt: true, }) }; -} -pub(crate) use key; - -enum Row { - Empty, - Heading(String), - Binding(String, String), - BindingContd(String), -} - -pub struct KeyBindingsList(Vec); - -impl KeyBindingsList { - /// Width of the left column of key bindings. - const BINDING_WIDTH: u16 = 24; - - pub fn new() -> Self { - Self(vec![]) - } - - fn binding_style() -> Style { - Style::new().cyan() - } - - fn row_widget(row: Row) -> impl Widget { - match row { - Row::Empty => Text::new("").first3(), - - Row::Heading(name) => Text::new((name, Style::new().bold())).first3(), - - Row::Binding(binding, description) => Join2::horizontal( - Text::new((binding, Self::binding_style())) - .padding() - .with_right(1) - .resize() - .with_min_width(Self::BINDING_WIDTH) - .segment() - .with_fixed(true), - Text::new(description).segment(), - ) - .second3(), - - Row::BindingContd(description) => Join2::horizontal( - Empty::new() - .with_width(Self::BINDING_WIDTH) - .segment() - .with_fixed(true), - Text::new(description).segment(), - ) - .third3(), - } - } - - pub fn widget(self, list_state: &mut ListState) -> impl Widget + '_ { - let binding_style = Self::binding_style(); - - let hint_text = Styled::new("jk/↓↑", binding_style) - .then_plain(" to scroll, ") - .then("esc", binding_style) - .then_plain(" to close"); - - let hint = Text::new(hint_text) - .padding() - .with_horizontal(1) - .float() - .with_horizontal(0.5) - .with_vertical(0.0); - - let mut list_builder = ListBuilder::new(); - for row in self.0 { - list_builder.add_unsel(Self::row_widget(row)); - } - - list_builder - .build(list_state) - .padding() - .with_horizontal(1) - .border() - .below(hint) - .background() - .float() - .with_center() - } - - pub fn empty(&mut self) { - self.0.push(Row::Empty); - } - - pub fn heading(&mut self, name: &str) { - self.0.push(Row::Heading(name.to_string())); - } - - pub fn binding(&mut self, binding: &str, description: &str) { - self.0 - .push(Row::Binding(binding.to_string(), description.to_string())); - } - - pub fn binding_ctd(&mut self, description: &str) { - self.0.push(Row::BindingContd(description.to_string())); - } -} diff --git a/cove/src/ui/rooms.rs b/cove/src/ui/rooms.rs index b401372..32f3941 100644 --- a/cove/src/ui/rooms.rs +++ b/cove/src/ui/rooms.rs @@ -2,22 +2,21 @@ use std::collections::{HashMap, HashSet}; use std::iter; use std::sync::{Arc, Mutex}; -use cove_config::{Config, RoomsSortOrder}; +use cove_config::{Config, Keys, RoomsSortOrder}; +use cove_input::InputEvent; use crossterm::style::Stylize; use euphoxide::api::SessionType; use euphoxide::bot::instance::{Event, ServerConfig}; use euphoxide::conn::{self, Joined}; -use parking_lot::FairMutex; use tokio::sync::mpsc; use toss::widgets::{BoxedAsync, EditorState, Empty, Join2, Text}; -use toss::{Style, Styled, Terminal, Widget, WidgetExt}; +use toss::{Style, Styled, Widget, WidgetExt}; use crate::euph; use crate::macros::logging_unwrap; use crate::vault::Vault; use super::euph::room::EuphRoom; -use super::input::{key, InputEvent, KeyBindingsList}; use super::widgets::{ListBuilder, ListState, Popup}; use super::{util, UiError, UiEvent}; @@ -357,6 +356,7 @@ impl Rooms { order: Order, ) { if euph_rooms.is_empty() { + // TODO Use configured key binding list_builder.add_unsel(Text::new(( "Press F1 for key bindings", Style::new().grey().italic(), @@ -409,198 +409,137 @@ impl Rooms { c.is_ascii_alphanumeric() || c == '_' } - fn list_showlist_key_bindings(bindings: &mut KeyBindingsList) { - bindings.heading("Rooms"); - util::list_list_key_bindings(bindings); - bindings.empty(); - bindings.binding("enter", "enter selected room"); - bindings.binding("c", "connect to selected room"); - bindings.binding("C", "connect to all rooms"); - bindings.binding("d", "disconnect from selected room"); - bindings.binding("D", "disconnect from all rooms"); - bindings.binding("a", "connect to all autojoin room"); - bindings.binding("A", "disconnect from all non-autojoin rooms"); - bindings.binding("n", "connect to new room"); - bindings.binding("X", "delete room"); - bindings.empty(); - bindings.binding("s", "change sort order"); - } - - fn handle_showlist_input_event(&mut self, event: &InputEvent) -> bool { - if util::handle_list_input_event(&mut self.list, event) { + fn handle_showlist_input_event(&mut self, event: &mut InputEvent<'_>, keys: &Keys) -> bool { + // Open room + if event.matches(&keys.general.confirm) { + if let Some(name) = self.list.selected() { + self.state = State::ShowRoom(name.clone()); + } return true; } - match event { - key!(Enter) => { - if let Some(name) = self.list.selected() { - self.state = State::ShowRoom(name.clone()); - } - return true; + // Move cursor and scroll + if util::handle_list_input_event(&mut self.list, event, keys) { + return true; + } + + // Room actions + if event.matches(&keys.rooms.action.connect) { + if let Some(name) = self.list.selected() { + self.connect_to_room(name.clone()); } - key!('c') => { - if let Some(name) = self.list.selected() { + return true; + } + if event.matches(&keys.rooms.action.connect_all) { + self.connect_to_all_rooms(); + return true; + } + if event.matches(&keys.rooms.action.disconnect) { + if let Some(name) = self.list.selected() { + self.disconnect_from_room(&name.clone()); + } + return true; + } + if event.matches(&keys.rooms.action.disconnect_all) { + self.disconnect_from_all_rooms(); + return true; + } + if event.matches(&keys.rooms.action.connect_autojoin) { + for (name, options) in &self.config.euph.rooms { + if options.autojoin { self.connect_to_room(name.clone()); } - return true; } - key!('C') => { - self.connect_to_all_rooms(); - return true; - } - key!('d') => { - if let Some(name) = self.list.selected() { - self.disconnect_from_room(&name.clone()); + return true; + } + if event.matches(&keys.rooms.action.disconnect_non_autojoin) { + for (name, room) in &mut self.euph_rooms { + let autojoin = self + .config + .euph + .rooms + .get(name) + .map(|r| r.autojoin) + .unwrap_or(false); + if !autojoin { + room.disconnect(); } - return true; } - key!('D') => { - self.disconnect_from_all_rooms(); - return true; + return true; + } + if event.matches(&keys.rooms.action.new) { + self.state = State::Connect(EditorState::new()); + return true; + } + if event.matches(&keys.rooms.action.delete) { + if let Some(name) = self.list.selected() { + self.state = State::Delete(name.clone(), EditorState::new()); } - key!('a') => { - for (name, options) in &self.config.euph.rooms { - if options.autojoin { - self.connect_to_room(name.clone()); - } - } - return true; - } - key!('A') => { - for (name, room) in &mut self.euph_rooms { - let autojoin = self - .config - .euph - .rooms - .get(name) - .map(|r| r.autojoin) - .unwrap_or(false); - if !autojoin { - room.disconnect(); - } - } - return true; - } - key!('n') => { - self.state = State::Connect(EditorState::new()); - return true; - } - key!('X') => { - if let Some(name) = self.list.selected() { - self.state = State::Delete(name.clone(), EditorState::new()); - } - return true; - } - key!('s') => { - self.order = match self.order { - Order::Alphabet => Order::Importance, - Order::Importance => Order::Alphabet, - }; - return true; - } - _ => {} + return true; + } + if event.matches(&keys.rooms.action.change_sort_order) { + self.order = match self.order { + Order::Alphabet => Order::Importance, + Order::Importance => Order::Alphabet, + }; + return true; } false } - pub async fn list_key_bindings(&self, bindings: &mut KeyBindingsList) { - match &self.state { - State::ShowList => Self::list_showlist_key_bindings(bindings), - State::ShowRoom(name) => { - // Key bindings for leaving the room are a part of the room's - // list_key_bindings function since they may be shadowed by the - // nick selector or message editor. - if let Some(room) = self.euph_rooms.get(name) { - room.list_key_bindings(bindings).await; - } else { - // There should always be a room here already but I don't - // really want to panic in case it is not. If I show a - // message like this, it'll hopefully be reported if - // somebody ever encounters it. - bindings.binding_ctd("oops, this text should never be visible") - } - } - State::Connect(_) => { - bindings.heading("Rooms"); - bindings.binding("esc", "abort"); - bindings.binding("enter", "connect to room"); - util::list_editor_key_bindings(bindings, Self::room_char); - } - State::Delete(_, _) => { - bindings.heading("Rooms"); - bindings.binding("esc", "abort"); - bindings.binding("enter", "delete room"); - util::list_editor_key_bindings(bindings, Self::room_char); - } - } - } - - pub async fn handle_input_event( - &mut self, - terminal: &mut Terminal, - crossterm_lock: &Arc>, - event: &InputEvent, - ) -> bool { + pub async fn handle_input_event(&mut self, event: &mut InputEvent<'_>, keys: &Keys) -> bool { self.stabilize_rooms().await; match &mut self.state { State::ShowList => { - if self.handle_showlist_input_event(event) { + if self.handle_showlist_input_event(event, keys) { return true; } } State::ShowRoom(name) => { if let Some(room) = self.euph_rooms.get_mut(name) { - if room - .handle_input_event(terminal, crossterm_lock, event) - .await - { + if room.handle_input_event(event, keys).await { return true; } - - if let key!(Esc) = event { + if event.matches(&keys.general.abort) { self.state = State::ShowList; return true; } } } - State::Connect(ed) => match event { - key!(Esc) => { + State::Connect(editor) => { + if event.matches(&keys.general.abort) { self.state = State::ShowList; return true; } - key!(Enter) => { - let name = ed.text().to_string(); + if event.matches(&keys.general.confirm) { + let name = editor.text().to_string(); if !name.is_empty() { self.connect_to_room(name.clone()); self.state = State::ShowRoom(name); } return true; } - _ => { - if util::handle_editor_input_event(ed, terminal, event, Self::room_char) { - return true; - } + if util::handle_editor_input_event(editor, event, keys, Self::room_char) { + return true; } - }, - State::Delete(name, editor) => match event { - key!(Esc) => { + } + State::Delete(name, editor) => { + if event.matches(&keys.general.abort) { self.state = State::ShowList; return true; } - key!(Enter) if editor.text() == *name => { + if event.matches(&keys.general.confirm) { self.euph_rooms.remove(name); logging_unwrap!(self.vault.euph().room(name.clone()).delete().await); self.state = State::ShowList; return true; } - _ => { - if util::handle_editor_input_event(editor, terminal, event, Self::room_char) { - return true; - } + if util::handle_editor_input_event(editor, event, keys, Self::room_char) { + return true; } - }, + } } false diff --git a/cove/src/ui/util.rs b/cove/src/ui/util.rs index d80b704..fa434fe 100644 --- a/cove/src/ui/util.rs +++ b/cove/src/ui/util.rs @@ -1,191 +1,191 @@ -use std::io; -use std::sync::Arc; - -use parking_lot::FairMutex; +use cove_config::Keys; +use cove_input::InputEvent; +use crossterm::event::{KeyCode, KeyModifiers}; use toss::widgets::EditorState; -use toss::Terminal; -use super::input::{key, InputEvent, KeyBindingsList}; use super::widgets::ListState; -pub fn prompt( - terminal: &mut Terminal, - crossterm_lock: &Arc>, - initial_text: &str, -) -> io::Result { - let content = { - let _guard = crossterm_lock.lock(); - terminal.suspend().expect("could not suspend"); - let content = edit::edit(initial_text); - terminal.unsuspend().expect("could not unsuspend"); - content - }; - - content -} - ////////// // List // ////////// -pub fn list_list_key_bindings(bindings: &mut KeyBindingsList) { - bindings.binding("j/k, ↓/↑", "move cursor up/down"); - bindings.binding("g, home", "move cursor to top"); - bindings.binding("G, end", "move cursor to bottom"); - bindings.binding("ctrl+y/e", "scroll up/down"); -} - -pub fn handle_list_input_event(list: &mut ListState, event: &InputEvent) -> bool { - match event { - key!('k') | key!(Up) => list.move_cursor_up(), - key!('j') | key!(Down) => list.move_cursor_down(), - key!('g') | key!(Home) => list.move_cursor_to_top(), - key!('G') | key!(End) => list.move_cursor_to_bottom(), - key!(Ctrl + 'y') => list.scroll_up(1), - key!(Ctrl + 'e') => list.scroll_down(1), - _ => return false, +pub fn handle_list_input_event( + list: &mut ListState, + event: &InputEvent<'_>, + keys: &Keys, +) -> bool { + // Cursor movement + if event.matches(&keys.cursor.up) { + list.move_cursor_up(); + return true; + } + if event.matches(&keys.cursor.down) { + list.move_cursor_down(); + return true; + } + if event.matches(&keys.cursor.to_top) { + list.move_cursor_to_top(); + return true; + } + if event.matches(&keys.cursor.to_bottom) { + list.move_cursor_to_bottom(); + return true; } - true + // Scrolling + if event.matches(&keys.scroll.up_line) { + list.scroll_up(1); + return true; + } + if event.matches(&keys.scroll.down_line) { + list.scroll_down(1); + return true; + } + if event.matches(&keys.scroll.up_half) { + list.scroll_up_half(); + return true; + } + if event.matches(&keys.scroll.down_half) { + list.scroll_down_half(); + return true; + } + if event.matches(&keys.scroll.up_full) { + list.scroll_up_full(); + return true; + } + if event.matches(&keys.scroll.down_full) { + list.scroll_down_full(); + return true; + } + if event.matches(&keys.scroll.center_cursor) { + list.center_cursor(); + return true; + } + + false } //////////// // Editor // //////////// -fn list_editor_editing_key_bindings( - bindings: &mut KeyBindingsList, - char_filter: impl Fn(char) -> bool, -) { - if char_filter('\n') { - bindings.binding("enter+", "insert newline"); - } - - bindings.binding("ctrl+h, backspace", "delete before cursor"); - bindings.binding("ctrl+d, delete", "delete after cursor"); - bindings.binding("ctrl+l", "clear editor contents"); -} - -fn list_editor_cursor_movement_key_bindings(bindings: &mut KeyBindingsList) { - bindings.binding("ctrl+b, ←", "move cursor left"); - bindings.binding("ctrl+f, →", "move cursor right"); - bindings.binding("alt+b, ctrl+←", "move cursor left a word"); - bindings.binding("alt+f, ctrl+→", "move cursor right a word"); - bindings.binding("ctrl+a, home", "move cursor to start of line"); - bindings.binding("ctrl+e, end", "move cursor to end of line"); - bindings.binding("↑/↓", "move cursor up/down"); -} - -pub fn list_editor_key_bindings( - bindings: &mut KeyBindingsList, - char_filter: impl Fn(char) -> bool, -) { - list_editor_editing_key_bindings(bindings, char_filter); - bindings.empty(); - list_editor_cursor_movement_key_bindings(bindings); -} - -pub fn handle_editor_input_event( - editor: &mut EditorState, - terminal: &mut Terminal, - event: &InputEvent, - char_filter: impl Fn(char) -> bool, -) -> bool { - match event { - // Enter with *any* modifier pressed - if ctrl and shift don't - // work, maybe alt does - key!(Enter) => return false, - InputEvent::Key(crate::ui::input::KeyEvent { - code: crossterm::event::KeyCode::Enter, - .. - }) if char_filter('\n') => editor.insert_char(terminal.widthdb(), '\n'), - - // Editing - key!(Char ch) if char_filter(*ch) => editor.insert_char(terminal.widthdb(), *ch), - key!(Paste str) => { - // It seems that when pasting, '\n' are converted into '\r' for some - // reason. I don't really know why, or at what point this happens. - // Vim converts any '\r' pasted via the terminal into '\n', so I - // decided to mirror that behaviour. - let str = str.replace('\r', "\n"); - if str.chars().all(char_filter) { - editor.insert_str(terminal.widthdb(), &str); - } else { - return false; - } - } - key!(Ctrl + 'h') | key!(Backspace) => editor.backspace(terminal.widthdb()), - key!(Ctrl + 'd') | key!(Delete) => editor.delete(), - key!(Ctrl + 'l') => editor.clear(), - // TODO Key bindings to delete words - - // Cursor movement - key!(Ctrl + 'b') | key!(Left) => editor.move_cursor_left(terminal.widthdb()), - key!(Ctrl + 'f') | key!(Right) => editor.move_cursor_right(terminal.widthdb()), - key!(Alt + 'b') | key!(Ctrl + Left) => editor.move_cursor_left_a_word(terminal.widthdb()), - key!(Alt + 'f') | key!(Ctrl + Right) => editor.move_cursor_right_a_word(terminal.widthdb()), - key!(Ctrl + 'a') | key!(Home) => editor.move_cursor_to_start_of_line(terminal.widthdb()), - key!(Ctrl + 'e') | key!(End) => editor.move_cursor_to_end_of_line(terminal.widthdb()), - key!(Up) => editor.move_cursor_up(terminal.widthdb()), - key!(Down) => editor.move_cursor_down(terminal.widthdb()), - - _ => return false, - } - - true -} - fn edit_externally( editor: &mut EditorState, - terminal: &mut Terminal, - crossterm_lock: &Arc>, -) -> io::Result<()> { - let text = prompt(terminal, crossterm_lock, editor.text())?; + event: &mut InputEvent<'_>, + char_filter: impl Fn(char) -> bool, +) { + let Ok(text) = event.prompt(editor.text()) else { + // Something went wrong during editing, let's abort the edit. + return; + }; if text.trim().is_empty() { // The user likely wanted to abort the edit and has deleted the // entire text (bar whitespace left over by some editors). - return Ok(()); + return; } - if let Some(text) = text.strip_suffix('\n') { - // Some editors like vim add a trailing newline that would look out of - // place in cove's editors. To intentionally add a trailing newline, - // simply add two in-editor. - editor.set_text(terminal.widthdb(), text.to_string()); - } else { - editor.set_text(terminal.widthdb(), text); - } + let text = text + .strip_suffix('\n') + .unwrap_or(&text) + .chars() + .filter(|c| char_filter(*c)) + .collect::(); - Ok(()) + editor.set_text(event.widthdb(), text); } -pub fn list_editor_key_bindings_allowing_external_editing( - bindings: &mut KeyBindingsList, - char_filter: impl Fn(char) -> bool, -) { - list_editor_editing_key_bindings(bindings, char_filter); - bindings.binding("ctrl+x", "edit in external editor"); - bindings.empty(); - list_editor_cursor_movement_key_bindings(bindings); +fn char_modifier(modifiers: KeyModifiers) -> bool { + modifiers == KeyModifiers::NONE || modifiers == KeyModifiers::SHIFT } -pub fn handle_editor_input_event_allowing_external_editing( +pub fn handle_editor_input_event( editor: &mut EditorState, - terminal: &mut Terminal, - crossterm_lock: &Arc>, - event: &InputEvent, + event: &mut InputEvent<'_>, + keys: &Keys, char_filter: impl Fn(char) -> bool, -) -> io::Result { - if let key!(Ctrl + 'x') = event { - edit_externally(editor, terminal, crossterm_lock)?; - Ok(true) - } else { - Ok(handle_editor_input_event( - editor, - terminal, - event, - char_filter, - )) +) -> bool { + // Cursor movement + if event.matches(&keys.editor.cursor.left) { + editor.move_cursor_left(event.widthdb()); + return true; } + if event.matches(&keys.editor.cursor.right) { + editor.move_cursor_right(event.widthdb()); + return true; + } + if event.matches(&keys.editor.cursor.left_word) { + editor.move_cursor_left_a_word(event.widthdb()); + return true; + } + if event.matches(&keys.editor.cursor.right_word) { + editor.move_cursor_right_a_word(event.widthdb()); + return true; + } + if event.matches(&keys.editor.cursor.start) { + editor.move_cursor_to_start_of_line(event.widthdb()); + return true; + } + if event.matches(&keys.editor.cursor.end) { + editor.move_cursor_to_end_of_line(event.widthdb()); + return true; + } + if event.matches(&keys.editor.cursor.up) { + editor.move_cursor_up(event.widthdb()); + return true; + } + if event.matches(&keys.editor.cursor.down) { + editor.move_cursor_down(event.widthdb()); + return true; + } + + // Editing + if event.matches(&keys.editor.action.backspace) { + editor.backspace(event.widthdb()); + return true; + } + if event.matches(&keys.editor.action.delete) { + editor.delete(); + return true; + } + if event.matches(&keys.editor.action.clear) { + editor.clear(); + return true; + } + if event.matches(&keys.editor.action.external) { + edit_externally(editor, event, char_filter); + return true; + } + + // Inserting individual characters + if let Some(key_event) = event.key_event() { + match key_event.code { + KeyCode::Enter if char_filter('\n') => { + editor.insert_char(event.widthdb(), '\n'); + return true; + } + KeyCode::Char(c) if char_modifier(key_event.modifiers) && char_filter(c) => { + editor.insert_char(event.widthdb(), c); + return true; + } + _ => {} + } + } + + // Pasting text + if let Some(text) = event.paste_event() { + // It seems that when pasting, '\n' are converted into '\r' for some + // reason. I don't really know why, or at what point this happens. Vim + // converts any '\r' pasted via the terminal into '\n', so I decided to + // mirror that behaviour. + let text = text + .chars() + .map(|c| if c == '\r' { '\n' } else { c }) + .filter(|c| char_filter(*c)) + .collect::(); + editor.insert_str(event.widthdb(), &text); + return true; + } + + false } diff --git a/cove/src/ui/widgets/list.rs b/cove/src/ui/widgets/list.rs index 88d08bd..bb27540 100644 --- a/cove/src/ui/widgets/list.rs +++ b/cove/src/ui/widgets/list.rs @@ -170,6 +170,22 @@ impl ListState { self.scroll_to(self.offset.saturating_add(lines)); } + pub fn scroll_up_half(&mut self) { + self.scroll_up((self.last_height / 2).into()); + } + + pub fn scroll_down_half(&mut self) { + self.scroll_down((self.last_height / 2).into()); + } + + pub fn scroll_up_full(&mut self) { + self.scroll_up(self.last_height.saturating_sub(1).into()); + } + + pub fn scroll_down_full(&mut self) { + self.scroll_down(self.last_height.saturating_sub(1).into()); + } + /// Scroll so that the cursor is in the center of the widget, or at least as /// close as possible. pub fn center_cursor(&mut self) {