Migrate input handling to new bindings

This commit is contained in:
Joscha 2023-04-29 00:24:33 +02:00
parent 202969c7a9
commit 9bc6931fae
18 changed files with 748 additions and 1222 deletions

2
Cargo.lock generated
View file

@ -259,7 +259,6 @@ dependencies = [
"cove-input", "cove-input",
"crossterm", "crossterm",
"directories", "directories",
"edit",
"euphoxide", "euphoxide",
"linkify", "linkify",
"log", "log",
@ -294,6 +293,7 @@ version = "0.6.1"
dependencies = [ dependencies = [
"cove-macro", "cove-macro",
"crossterm", "crossterm",
"edit",
"parking_lot", "parking_lot",
"serde", "serde",
"serde_either", "serde_either",

View file

@ -7,8 +7,10 @@ edition = { workspace = true }
cove-macro = { path = "../cove-macro" } cove-macro = { path = "../cove-macro" }
crossterm = { workspace = true } crossterm = { workspace = true }
parking_lot = {workspace = true} parking_lot = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
serde_either = { workspace = true } serde_either = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
toss = {workspace = true} toss = { workspace = true }
edit = "0.1.4"

View file

@ -1,11 +1,12 @@
mod keys; mod keys;
use std::io;
use std::sync::Arc; use std::sync::Arc;
pub use cove_macro::KeyGroup; pub use cove_macro::KeyGroup;
use crossterm::event::{Event, KeyEvent}; use crossterm::event::{Event, KeyEvent};
use parking_lot::FairMutex; use parking_lot::FairMutex;
use toss::Terminal; use toss::{Frame, Terminal, WidthDb};
pub use crate::keys::*; pub use crate::keys::*;
@ -53,4 +54,22 @@ impl<'a> InputEvent<'a> {
None => false, 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<String> {
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
}
} }

View file

@ -17,7 +17,6 @@ async-trait = "0.1.68"
clap = { version = "4.2.1", features = ["derive", "deprecated"] } clap = { version = "4.2.1", features = ["derive", "deprecated"] }
cookie = "0.17.0" cookie = "0.17.0"
directories = "5.0.0" directories = "5.0.0"
edit = "0.1.4"
linkify = "0.9.0" linkify = "0.9.0"
log = { version = "0.4.17", features = ["std"] } log = { version = "0.4.17", features = ["std"] }
once_cell = "1.17.1" once_cell = "1.17.1"

View file

@ -1,6 +1,5 @@
mod chat; mod chat;
mod euph; mod euph;
mod input;
mod key_bindings; mod key_bindings;
mod rooms; mod rooms;
mod util; mod util;
@ -12,6 +11,7 @@ use std::sync::{Arc, Weak};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use cove_config::Config; use cove_config::Config;
use cove_input::InputEvent;
use parking_lot::FairMutex; use parking_lot::FairMutex;
use tokio::sync::mpsc::error::TryRecvError; use tokio::sync::mpsc::error::TryRecvError;
use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender}; use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender};
@ -26,7 +26,6 @@ use crate::vault::Vault;
pub use self::chat::ChatMsg; pub use self::chat::ChatMsg;
use self::chat::ChatState; use self::chat::ChatState;
use self::input::{key, InputEvent, KeyBindingsList};
use self::rooms::Rooms; use self::rooms::Rooms;
use self::widgets::ListState; 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( async fn handle_event(
&mut self, &mut self,
terminal: &mut Terminal, terminal: &mut Terminal,
@ -232,7 +220,7 @@ impl Ui {
UiEvent::LogChanged => EventHandleResult::Continue, UiEvent::LogChanged => EventHandleResult::Continue,
UiEvent::Term(crossterm::event::Event::Resize(_, _)) => EventHandleResult::Redraw, UiEvent::Term(crossterm::event::Event::Resize(_, _)) => EventHandleResult::Redraw,
UiEvent::Term(event) => { UiEvent::Term(event) => {
self.handle_term_event(terminal, crossterm_lock, event) self.handle_term_event(terminal, crossterm_lock.clone(), event)
.await .await
} }
UiEvent::Euph(event) => { UiEvent::Euph(event) => {
@ -248,74 +236,60 @@ impl Ui {
async fn handle_term_event( async fn handle_term_event(
&mut self, &mut self,
terminal: &mut Terminal, terminal: &mut Terminal,
crossterm_lock: &Arc<FairMutex<()>>, crossterm_lock: Arc<FairMutex<()>>,
event: crossterm::event::Event, event: crossterm::event::Event,
) -> EventHandleResult { ) -> 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 { if event.matches(&keys.general.exit) {
// Exit unconditionally on ctrl+c. Previously, shift+q would also
// unconditionally exit, but that interfered with typing text in
// inline editors.
return EventHandleResult::Stop; return EventHandleResult::Stop;
} }
// Key bindings list overrides any other bindings if visible // Key bindings list overrides any other bindings if visible
if self.key_bindings_visible { if self.key_bindings_visible {
match event { if event.matches(&keys.general.abort) {
key!(Esc) | key!(F 1) | key!('?') => self.key_bindings_visible = false, self.key_bindings_visible = false;
key!('k') | key!(Up) => self.key_bindings_list.scroll_up(1), return EventHandleResult::Redraw;
key!('j') | key!(Down) => self.key_bindings_list.scroll_down(1),
_ => return EventHandleResult::Continue,
} }
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; return EventHandleResult::Redraw;
} }
match event { match self.mode {
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 {
Mode::Main => { Mode::Main => {
self.rooms if self.rooms.handle_input_event(&mut event, keys).await {
.handle_input_event(terminal, crossterm_lock, &event) return EventHandleResult::Redraw;
.await }
} }
Mode::Log => { Mode::Log => {
let reaction = self let reaction = self
.log_chat .log_chat
.handle_input_event(terminal, crossterm_lock, &event, false) .handle_input_event(&mut event, keys, false)
.await; .await;
let reaction = logging_unwrap!(reaction); let reaction = logging_unwrap!(reaction);
reaction.handled() if reaction.handled() {
} return EventHandleResult::Redraw;
}; }
// 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 handled { EventHandleResult::Continue
EventHandleResult::Redraw
} else {
EventHandleResult::Continue
}
} }
} }

View file

@ -4,20 +4,17 @@ mod renderer;
mod tree; mod tree;
mod widgets; mod widgets;
use std::io; use cove_config::Keys;
use std::sync::Arc; use cove_input::InputEvent;
use parking_lot::FairMutex;
use time::OffsetDateTime; use time::OffsetDateTime;
use toss::widgets::{BoxedAsync, EditorState}; use toss::widgets::{BoxedAsync, EditorState};
use toss::{Styled, Terminal, WidgetExt}; use toss::{Styled, WidgetExt};
use crate::store::{Msg, MsgStore}; use crate::store::{Msg, MsgStore};
use self::cursor::Cursor; use self::cursor::Cursor;
use self::tree::TreeViewState; use self::tree::TreeViewState;
use super::input::{InputEvent, KeyBindingsList};
use super::UiError; use super::UiError;
pub trait ChatMsg { pub trait ChatMsg {
@ -76,19 +73,10 @@ impl<M: Msg, S: MsgStore<M>> ChatState<M, S> {
} }
} }
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( pub async fn handle_input_event(
&mut self, &mut self,
terminal: &mut Terminal, event: &mut InputEvent<'_>,
crossterm_lock: &Arc<FairMutex<()>>, keys: &Keys,
event: &InputEvent,
can_compose: bool, can_compose: bool,
) -> Result<Reaction<M>, S::Error> ) -> Result<Reaction<M>, S::Error>
where where
@ -101,9 +89,8 @@ impl<M: Msg, S: MsgStore<M>> ChatState<M, S> {
Mode::Tree => { Mode::Tree => {
self.tree self.tree
.handle_input_event( .handle_input_event(
terminal,
crossterm_lock,
event, event,
keys,
&mut self.cursor, &mut self.cursor,
&mut self.editor, &mut self.editor,
can_compose, can_compose,
@ -147,7 +134,6 @@ pub enum Reaction<M: Msg> {
parent: Option<M::Id>, parent: Option<M::Id>,
content: String, content: String,
}, },
ComposeError(io::Error),
} }
impl<M: Msg> Reaction<M> { impl<M: Msg> Reaction<M> {

View file

@ -7,15 +7,14 @@ mod scroll;
mod widgets; mod widgets;
use std::collections::HashSet; use std::collections::HashSet;
use std::sync::Arc;
use async_trait::async_trait; use async_trait::async_trait;
use parking_lot::FairMutex; use cove_config::Keys;
use cove_input::InputEvent;
use toss::widgets::EditorState; 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::store::{Msg, MsgStore};
use crate::ui::input::{key, InputEvent, KeyBindingsList};
use crate::ui::{util, ChatMsg, UiError}; use crate::ui::{util, ChatMsg, UiError};
use crate::util::InfallibleExt; use crate::util::InfallibleExt;
@ -49,25 +48,10 @@ impl<M: Msg, S: MsgStore<M>> TreeViewState<M, S> {
} }
} }
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( async fn handle_movement_input_event(
&mut self, &mut self,
frame: &mut Frame, event: &mut InputEvent<'_>,
event: &InputEvent, keys: &Keys,
cursor: &mut Cursor<M::Id>, cursor: &mut Cursor<M::Id>,
editor: &mut EditorState, editor: &mut EditorState,
) -> Result<bool, S::Error> ) -> Result<bool, S::Error>
@ -77,152 +61,188 @@ impl<M: Msg, S: MsgStore<M>> TreeViewState<M, S> {
S: Send + Sync, S: Send + Sync,
S::Error: Send, S::Error: Send,
{ {
let chat_height: i32 = (frame.size().height - 3).into(); let chat_height: i32 = (event.frame().size().height - 3).into();
let widthdb = frame.widthdb();
match event { // Basic cursor movement
key!('k') | key!(Up) => cursor.move_up_in_tree(&self.store, &self.folded).await?, if event.matches(&keys.cursor.up) {
key!('j') | key!(Down) => cursor.move_down_in_tree(&self.store, &self.folded).await?, cursor.move_up_in_tree(&self.store, &self.folded).await?;
key!('K') | key!(Ctrl + Up) => cursor.move_to_prev_sibling(&self.store).await?, return Ok(true);
key!('J') | key!(Ctrl + Down) => cursor.move_to_next_sibling(&self.store).await?, }
key!('p') => cursor.move_to_parent(&self.store).await?, if event.matches(&keys.cursor.down) {
key!('P') => cursor.move_to_root(&self.store).await?, cursor.move_down_in_tree(&self.store, &self.folded).await?;
key!('h') | key!(Left) => cursor.move_to_older_msg(&self.store).await?, return Ok(true);
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?, if event.matches(&keys.cursor.to_top) {
key!('L') | key!(Ctrl + Right) => cursor.move_to_newer_unseen_msg(&self.store).await?, cursor.move_to_top(&self.store).await?;
key!('g') | key!(Home) => cursor.move_to_top(&self.store).await?, return Ok(true);
key!('G') | key!(End) => cursor.move_to_bottom(), }
key!(Ctrl + 'y') => self.scroll_by(cursor, editor, widthdb, 1).await?, if event.matches(&keys.cursor.to_bottom) {
key!(Ctrl + 'e') => self.scroll_by(cursor, editor, widthdb, -1).await?, cursor.move_to_bottom();
key!(Ctrl + 'u') => { return Ok(true);
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),
} }
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) { // Scrolling
bindings.binding("space", "fold current message's subtree"); if event.matches(&keys.scroll.up_line) {
bindings.binding("s", "toggle current message's seen status"); self.scroll_by(cursor, editor, event.widthdb(), 1).await?;
bindings.binding("S", "mark all visible messages as seen"); return Ok(true);
bindings.binding("ctrl+s", "mark all older messages as seen"); }
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( async fn handle_action_input_event(
&mut self, &mut self,
event: &InputEvent, event: &mut InputEvent<'_>,
keys: &Keys,
id: Option<&M::Id>, id: Option<&M::Id>,
) -> Result<bool, S::Error> { ) -> Result<bool, S::Error> {
match event { if event.matches(&keys.tree.action.fold_tree) {
key!(' ') => { if let Some(id) = id {
if let Some(id) = id { if !self.folded.remove(id) {
if !self.folded.remove(id) { self.folded.insert(id.clone());
self.folded.insert(id.clone());
}
return Ok(true);
} }
} }
key!('s') => { return Ok(true);
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);
}
_ => {}
} }
Ok(false)
}
pub fn list_edit_initiating_key_bindings(&self, bindings: &mut KeyBindingsList) { if event.matches(&keys.tree.action.toggle_seen) {
bindings.binding("r", "reply to message (inline if possible, else directly)"); if let Some(id) = id {
bindings.binding("R", "reply to message (opposite of R)"); if let Some(msg) = self.store.tree(id).await?.msg(id) {
bindings.binding("t", "start a new thread"); 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( async fn handle_edit_initiating_input_event(
&mut self, &mut self,
event: &InputEvent, event: &mut InputEvent<'_>,
keys: &Keys,
cursor: &mut Cursor<M::Id>, cursor: &mut Cursor<M::Id>,
id: Option<M::Id>, id: Option<M::Id>,
) -> Result<bool, S::Error> { ) -> Result<bool, S::Error> {
match event { if event.matches(&keys.tree.action.reply) {
key!('r') => { if let Some(parent) = cursor.parent_for_normal_tree_reply(&self.store).await? {
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') => {
*cursor = Cursor::Editor { *cursor = Cursor::Editor {
coming_from: id, coming_from: id,
parent: None, parent,
}; };
} }
_ => return Ok(false), return Ok(true);
} }
Ok(true) if event.matches(&keys.tree.action.reply_alternate) {
} if let Some(parent) = cursor.parent_for_alternate_tree_reply(&self.store).await? {
*cursor = Cursor::Editor {
pub fn list_normal_key_bindings(&self, bindings: &mut KeyBindingsList, can_compose: bool) { coming_from: id,
self.list_movement_key_bindings(bindings); parent,
bindings.empty(); };
self.list_action_key_bindings(bindings); }
if can_compose { return Ok(true);
bindings.empty();
self.list_edit_initiating_key_bindings(bindings);
} }
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( async fn handle_normal_input_event(
&mut self, &mut self,
frame: &mut Frame, event: &mut InputEvent<'_>,
event: &InputEvent, keys: &Keys,
cursor: &mut Cursor<M::Id>, cursor: &mut Cursor<M::Id>,
editor: &mut EditorState, editor: &mut EditorState,
can_compose: bool, can_compose: bool,
@ -234,102 +254,73 @@ impl<M: Msg, S: MsgStore<M>> TreeViewState<M, S> {
S: Send + Sync, S: Send + Sync,
S::Error: Send, S::Error: Send,
{ {
#[allow(clippy::if_same_then_else)] if self
Ok( .handle_movement_input_event(event, keys, cursor, editor)
if self .await?
.handle_movement_input_event(frame, event, cursor, editor) {
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? .await?
{ {
true return Ok(true);
} else if self.handle_action_input_event(event, id.as_ref()).await? { }
true
} else if can_compose { Ok(false)
self.handle_edit_initiating_input_event(event, cursor, id)
.await?
} else {
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( fn handle_editor_input_event(
&mut self, &mut self,
terminal: &mut Terminal, event: &mut InputEvent<'_>,
crossterm_lock: &Arc<FairMutex<()>>, keys: &Keys,
event: &InputEvent,
cursor: &mut Cursor<M::Id>, cursor: &mut Cursor<M::Id>,
editor: &mut EditorState, editor: &mut EditorState,
coming_from: Option<M::Id>, coming_from: Option<M::Id>,
parent: Option<M::Id>, parent: Option<M::Id>,
) -> Reaction<M> { ) -> Reaction<M> {
// 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 { // Send message
key!(Esc) => { if event.matches(&keys.general.confirm) {
*cursor = coming_from.map(Cursor::Msg).unwrap_or(Cursor::Bottom); let content = editor.text().to_string();
if content.trim().is_empty() {
return Reaction::Handled; return Reaction::Handled;
} }
*cursor = Cursor::Pseudo {
key!(Enter) => { coming_from,
let content = editor.text().to_string(); parent: parent.clone(),
if !content.trim().is_empty() { };
*cursor = Cursor::Pseudo { return Reaction::Composed { parent, content };
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),
}
}
} }
Reaction::Handled // TODO Tab-completion
}
pub fn list_key_bindings( // Editing
&self, if util::handle_editor_input_event(editor, event, keys, |_| true) {
bindings: &mut KeyBindingsList, return Reaction::Handled;
cursor: &Cursor<M::Id>,
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);
}
} }
Reaction::NotHandled
} }
pub async fn handle_input_event( pub async fn handle_input_event(
&mut self, &mut self,
terminal: &mut Terminal, event: &mut InputEvent<'_>,
crossterm_lock: &Arc<FairMutex<()>>, keys: &Keys,
event: &InputEvent,
cursor: &mut Cursor<M::Id>, cursor: &mut Cursor<M::Id>,
editor: &mut EditorState, editor: &mut EditorState,
can_compose: bool, can_compose: bool,
@ -343,14 +334,7 @@ impl<M: Msg, S: MsgStore<M>> TreeViewState<M, S> {
Ok(match cursor { Ok(match cursor {
Cursor::Bottom => { Cursor::Bottom => {
if self if self
.handle_normal_input_event( .handle_normal_input_event(event, keys, cursor, editor, can_compose, None)
terminal.frame(),
event,
cursor,
editor,
can_compose,
None,
)
.await? .await?
{ {
Reaction::Handled Reaction::Handled
@ -361,14 +345,7 @@ impl<M: Msg, S: MsgStore<M>> TreeViewState<M, S> {
Cursor::Msg(id) => { Cursor::Msg(id) => {
let id = id.clone(); let id = id.clone();
if self if self
.handle_normal_input_event( .handle_normal_input_event(event, keys, cursor, editor, can_compose, Some(id))
terminal.frame(),
event,
cursor,
editor,
can_compose,
Some(id),
)
.await? .await?
{ {
Reaction::Handled Reaction::Handled
@ -382,19 +359,11 @@ impl<M: Msg, S: MsgStore<M>> TreeViewState<M, S> {
} => { } => {
let coming_from = coming_from.clone(); let coming_from = coming_from.clone();
let parent = parent.clone(); let parent = parent.clone();
self.handle_editor_input_event( self.handle_editor_input_event(event, keys, cursor, editor, coming_from, parent)
terminal,
crossterm_lock,
event,
cursor,
editor,
coming_from,
parent,
)
} }
Cursor::Pseudo { .. } => { Cursor::Pseudo { .. } => {
if self if self
.handle_movement_input_event(terminal.frame(), event, cursor, editor) .handle_movement_input_event(event, keys, cursor, editor)
.await? .await?
{ {
Reaction::Handled Reaction::Handled

View file

@ -1,14 +1,17 @@
use cove_config::Keys;
use cove_input::InputEvent;
use crossterm::style::Stylize; use crossterm::style::Stylize;
use euphoxide::api::PersonalAccountView; use euphoxide::api::PersonalAccountView;
use euphoxide::conn; use euphoxide::conn;
use toss::widgets::{EditorState, Empty, Join3, Join4, Text}; use toss::widgets::{EditorState, Empty, Join3, Join4, Join5, Text};
use toss::{Style, Terminal, Widget, WidgetExt}; use toss::{Style, Widget, WidgetExt};
use crate::euph::{self, Room}; use crate::euph::{self, Room};
use crate::ui::input::{key, InputEvent, KeyBindingsList};
use crate::ui::widgets::Popup; use crate::ui::widgets::Popup;
use crate::ui::{util, UiError}; use crate::ui::{util, UiError};
use super::popup::PopupResult;
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Focus { enum Focus {
Email, Email,
@ -65,7 +68,7 @@ pub struct LoggedIn(PersonalAccountView);
impl LoggedIn { impl LoggedIn {
fn widget(&self) -> impl Widget<UiError> { fn widget(&self) -> impl Widget<UiError> {
let bold = Style::new().bold(); let bold = Style::new().bold();
Join3::vertical( Join5::vertical(
Text::new(("Logged in", bold.green())).segment(), Text::new(("Logged in", bold.green())).segment(),
Empty::new().with_height(1).segment(), Empty::new().with_height(1).segment(),
Join3::horizontal( Join3::horizontal(
@ -76,6 +79,8 @@ impl LoggedIn {
Text::new((&self.0.email,)).segment(), Text::new((&self.0.email,)).segment(),
) )
.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), LoggedIn(LoggedIn),
} }
pub enum EventResult {
NotHandled,
Handled,
ResetState,
}
impl AccountUiState { impl AccountUiState {
pub fn new() -> Self { pub fn new() -> Self {
Self::LoggedOut(LoggedOut::new()) Self::LoggedOut(LoggedOut::new())
@ -121,94 +120,74 @@ impl AccountUiState {
Popup::new(inner, "Account") 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( pub fn handle_input_event(
&mut self, &mut self,
terminal: &mut Terminal, event: &mut InputEvent<'_>,
event: &InputEvent, keys: &Keys,
room: &Option<Room>, room: &Option<Room>,
) -> EventResult { ) -> PopupResult {
if let key!(Esc) = event { if event.matches(&keys.general.abort) {
return EventResult::ResetState; return PopupResult::Close;
} }
match self { match self {
Self::LoggedOut(logged_out) => { Self::LoggedOut(logged_out) => {
if let key!(Tab) = event { if event.matches(&keys.general.focus) {
logged_out.focus = match logged_out.focus { logged_out.focus = match logged_out.focus {
Focus::Email => Focus::Password, Focus::Email => Focus::Password,
Focus::Password => Focus::Email, Focus::Password => Focus::Email,
}; };
return EventResult::Handled; return PopupResult::Handled;
} }
match logged_out.focus { match logged_out.focus {
Focus::Email => { Focus::Email => {
if let key!(Enter) = event { if event.matches(&keys.general.confirm) {
logged_out.focus = Focus::Password; logged_out.focus = Focus::Password;
return EventResult::Handled; return PopupResult::Handled;
} }
if util::handle_editor_input_event( if util::handle_editor_input_event(
&mut logged_out.email, &mut logged_out.email,
terminal,
event, event,
keys,
|c| c != '\n', |c| c != '\n',
) { ) {
EventResult::Handled return PopupResult::Handled;
} else {
EventResult::NotHandled
} }
} }
Focus::Password => { Focus::Password => {
if let key!(Enter) = event { if event.matches(&keys.general.confirm) {
if let Some(room) = room { if let Some(room) = room {
let _ = room.login( let _ = room.login(
logged_out.email.text().to_string(), logged_out.email.text().to_string(),
logged_out.password.text().to_string(), logged_out.password.text().to_string(),
); );
} }
return EventResult::Handled; return PopupResult::Handled;
} }
if util::handle_editor_input_event( if util::handle_editor_input_event(
&mut logged_out.password, &mut logged_out.password,
terminal,
event, event,
keys,
|c| c != '\n', |c| c != '\n',
) { ) {
EventResult::Handled return PopupResult::Handled;
} else {
EventResult::NotHandled
} }
} }
} }
} }
Self::LoggedIn(_) => { Self::LoggedIn(_) => {
if let key!('L') = event { if event.matches(&keys.general.confirm) {
if let Some(room) = room { if let Some(room) = room {
let _ = room.logout(); let _ = room.logout();
} }
EventResult::Handled return PopupResult::Handled;
} else {
EventResult::NotHandled
} }
} }
} }
PopupResult::NotHandled
} }
} }

View file

@ -1,11 +1,14 @@
use cove_config::Keys;
use cove_input::InputEvent;
use toss::widgets::EditorState; use toss::widgets::EditorState;
use toss::{Terminal, Widget}; use toss::Widget;
use crate::euph::Room; use crate::euph::Room;
use crate::ui::input::{key, InputEvent, KeyBindingsList};
use crate::ui::widgets::Popup; use crate::ui::widgets::Popup;
use crate::ui::{util, UiError}; use crate::ui::{util, UiError};
use super::popup::PopupResult;
pub fn new() -> EditorState { pub fn new() -> EditorState {
EditorState::new() EditorState::new()
} }
@ -17,38 +20,26 @@ pub fn widget(editor: &mut EditorState) -> impl Widget<UiError> + '_ {
) )
} }
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( pub fn handle_input_event(
terminal: &mut Terminal, event: &mut InputEvent<'_>,
event: &InputEvent, keys: &Keys,
room: &Option<Room>, room: &Option<Room>,
editor: &mut EditorState, editor: &mut EditorState,
) -> EventResult { ) -> PopupResult {
match event { if event.matches(&keys.general.abort) {
key!(Esc) => EventResult::ResetState, return PopupResult::Close;
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
}
}
} }
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
} }

View file

@ -1,13 +1,16 @@
use cove_config::Keys;
use cove_input::InputEvent;
use crossterm::style::Stylize; use crossterm::style::Stylize;
use euphoxide::api::{Message, NickEvent, SessionView}; use euphoxide::api::{Message, NickEvent, SessionView};
use euphoxide::conn::SessionInfo; use euphoxide::conn::SessionInfo;
use toss::widgets::Text; use toss::widgets::Text;
use toss::{Style, Styled, Widget}; use toss::{Style, Styled, Widget};
use crate::ui::input::{key, InputEvent, KeyBindingsList};
use crate::ui::widgets::Popup; use crate::ui::widgets::Popup;
use crate::ui::UiError; use crate::ui::UiError;
use super::popup::PopupResult;
macro_rules! line { macro_rules! line {
( $text:ident, $name:expr, $val:expr ) => { ( $text:ident, $name:expr, $val:expr ) => {
$text = $text $text = $text
@ -122,18 +125,10 @@ pub fn message_widget(msg: &Message) -> impl Widget<UiError> {
Popup::new(Text::new(text), "Inspect message") Popup::new(Text::new(text), "Inspect message")
} }
pub fn list_key_bindings(bindings: &mut KeyBindingsList) { pub fn handle_input_event(event: &mut InputEvent<'_>, keys: &Keys) -> PopupResult {
bindings.binding("esc", "close"); if event.matches(&keys.general.abort) {
} return PopupResult::Close;
pub enum EventResult {
NotHandled,
Close,
}
pub fn handle_input_event(event: &InputEvent) -> EventResult {
match event {
key!(Esc) => EventResult::Close,
_ => EventResult::NotHandled,
} }
PopupResult::NotHandled
} }

View file

@ -1,26 +1,21 @@
use std::io; use cove_config::Keys;
use cove_input::InputEvent;
use crossterm::event::KeyCode;
use crossterm::style::Stylize; use crossterm::style::Stylize;
use linkify::{LinkFinder, LinkKind}; use linkify::{LinkFinder, LinkKind};
use toss::widgets::Text; use toss::widgets::Text;
use toss::{Style, Styled, Widget}; use toss::{Style, Styled, Widget};
use crate::ui::input::{key, InputEvent, KeyBindingsList};
use crate::ui::widgets::{ListBuilder, ListState, Popup}; use crate::ui::widgets::{ListBuilder, ListState, Popup};
use crate::ui::UiError; use crate::ui::{util, UiError};
use super::popup::PopupResult;
pub struct LinksState { pub struct LinksState {
links: Vec<String>, links: Vec<String>,
list: ListState<usize>, list: ListState<usize>,
} }
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']; const NUMBER_KEYS: [char; 10] = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0'];
impl LinksState { impl LinksState {
@ -77,7 +72,7 @@ impl LinksState {
Popup::new(list_builder.build(&mut self.list), "Links") 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) { if let Some(link) = self.links.get(id) {
// The `http://` or `https://` schema is necessary for open::that to // The `http://` or `https://` schema is necessary for open::that to
// successfully open the link in the browser. // successfully open the link in the browser.
@ -88,53 +83,52 @@ impl LinksState {
}; };
if let Err(error) = open::that(&link) { 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() { if let Some(id) = self.list.selected() {
self.open_link_by_id(*id) self.open_link_by_id(*id)
} else { } else {
EventResult::Handled PopupResult::Handled
} }
} }
pub fn list_key_bindings(&self, bindings: &mut KeyBindingsList) { pub fn handle_input_event(&mut self, event: &mut InputEvent<'_>, keys: &Keys) -> PopupResult {
bindings.binding("esc", "close links popup"); if event.matches(&keys.general.abort) {
bindings.binding("j/k, ↓/↑", "move cursor up/down"); return PopupResult::Close;
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,
} }
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
} }
} }

View file

@ -1,12 +1,15 @@
use cove_config::Keys;
use cove_input::InputEvent;
use euphoxide::conn::Joined; use euphoxide::conn::Joined;
use toss::widgets::EditorState; use toss::widgets::EditorState;
use toss::{Style, Terminal, Widget}; use toss::{Style, Widget};
use crate::euph::{self, Room}; use crate::euph::{self, Room};
use crate::ui::input::{key, InputEvent, KeyBindingsList};
use crate::ui::widgets::Popup; use crate::ui::widgets::Popup;
use crate::ui::{util, UiError}; use crate::ui::{util, UiError};
use super::popup::PopupResult;
pub fn new(joined: Joined) -> EditorState { pub fn new(joined: Joined) -> EditorState {
EditorState::with_initial_text(joined.session.name) EditorState::with_initial_text(joined.session.name)
} }
@ -19,42 +22,26 @@ pub fn widget(editor: &mut EditorState) -> impl Widget<UiError> + '_ {
Popup::new(inner, "Choose nick") 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( pub fn handle_input_event(
terminal: &mut Terminal, event: &mut InputEvent<'_>,
event: &InputEvent, keys: &Keys,
room: &Option<Room>, room: &Option<Room>,
editor: &mut EditorState, editor: &mut EditorState,
) -> EventResult { ) -> PopupResult {
match event { if event.matches(&keys.general.abort) {
key!(Esc) => EventResult::ResetState, return PopupResult::Close;
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
}
}
} }
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
} }

View file

@ -1,3 +1,5 @@
use std::io;
use crossterm::style::Stylize; use crossterm::style::Stylize;
use toss::widgets::Text; use toss::widgets::Text;
use toss::{Style, Styled, Widget}; use toss::{Style, Styled, Widget};
@ -30,3 +32,10 @@ impl RoomPopup {
} }
} }
} }
pub enum PopupResult {
NotHandled,
Handled,
Close,
ErrorOpeningLink { link: String, error: io::Error },
}

View file

@ -1,27 +1,26 @@
use std::collections::VecDeque; use std::collections::VecDeque;
use std::sync::Arc;
use cove_config::Keys;
use cove_input::InputEvent;
use crossterm::style::Stylize; use crossterm::style::Stylize;
use euphoxide::api::{Data, Message, MessageId, PacketType, SessionId}; use euphoxide::api::{Data, Message, MessageId, PacketType, SessionId};
use euphoxide::bot::instance::{Event, ServerConfig}; use euphoxide::bot::instance::{Event, ServerConfig};
use euphoxide::conn::{self, Joined, Joining, SessionInfo}; use euphoxide::conn::{self, Joined, Joining, SessionInfo};
use parking_lot::FairMutex;
use tokio::sync::oneshot::error::TryRecvError; use tokio::sync::oneshot::error::TryRecvError;
use tokio::sync::{mpsc, oneshot}; use tokio::sync::{mpsc, oneshot};
use toss::widgets::{BoxedAsync, EditorState, Join2, Layer, Text}; 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::euph;
use crate::macros::logging_unwrap; use crate::macros::logging_unwrap;
use crate::ui::chat::{ChatState, Reaction}; use crate::ui::chat::{ChatState, Reaction};
use crate::ui::input::{key, InputEvent, KeyBindingsList};
use crate::ui::widgets::ListState; use crate::ui::widgets::ListState;
use crate::ui::{util, UiError, UiEvent}; use crate::ui::{util, UiError, UiEvent};
use crate::vault::EuphRoomVault; use crate::vault::EuphRoomVault;
use super::account::{self, AccountUiState}; use super::account::AccountUiState;
use super::links::{self, LinksState}; use super::links::LinksState;
use super::popup::RoomPopup; use super::popup::{PopupResult, RoomPopup};
use super::{auth, inspect, nick, nick_list}; use super::{auth, inspect, nick, nick_list};
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@ -316,29 +315,13 @@ impl EuphRoom {
Text::new(info).padding().with_horizontal(1).border() Text::new(info).padding().with_horizontal(1).border()
} }
async fn list_chat_key_bindings(&self, bindings: &mut KeyBindingsList) { 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(_)))
);
self.chat.list_key_bindings(bindings, can_compose).await;
}
async fn handle_chat_input_event(
&mut self,
terminal: &mut Terminal,
crossterm_lock: &Arc<FairMutex<()>>,
event: &InputEvent,
) -> bool {
let can_compose = matches!( let can_compose = matches!(
self.room_state(), self.room_state(),
Some(euph::State::Connected(_, conn::State::Joined(_))) Some(euph::State::Connected(_, conn::State::Joined(_)))
); );
let reaction = self let reaction = self.chat.handle_input_event(event, keys, can_compose).await;
.chat
.handle_input_event(terminal, crossterm_lock, event, can_compose)
.await;
let reaction = logging_unwrap!(reaction); let reaction = logging_unwrap!(reaction);
match reaction { match reaction {
@ -353,19 +336,12 @@ impl EuphRoom {
return true; 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 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() { match self.room_state() {
// Authenticating // Authenticating
Some(euph::State::Connected( Some(euph::State::Connected(
@ -374,138 +350,95 @@ impl EuphRoom {
bounce: Some(_), .. bounce: Some(_), ..
}), }),
)) => { )) => {
bindings.binding("a", "authenticate"); if event.matches(&keys.room.action.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 {
self.state = State::Auth(auth::new()); self.state = State::Auth(auth::new());
return true; return true;
} }
} }
// Joined // Joined
Some(euph::State::Connected(_, conn::State::Joined(joined))) => match event { Some(euph::State::Connected(_, conn::State::Joined(joined))) => {
key!('n') | key!('N') => { if event.matches(&keys.room.action.nick) {
self.state = State::Nick(nick::new(joined.clone())); self.state = State::Nick(nick::new(joined.clone()));
return true; return true;
} }
key!('m') => { if event.matches(&keys.room.action.more_messages) {
if let Some(room) = &self.room { if let Some(room) = &self.room {
let _ = room.log(); let _ = room.log();
} }
return true; return true;
} }
key!('A') => { if event.matches(&keys.room.action.account) {
self.state = State::Account(AccountUiState::new()); self.state = State::Account(AccountUiState::new());
return true; return true;
} }
_ => {} }
},
// Otherwise // Otherwise
_ => {} _ => {}
} }
// Always applicable // Always applicable
match event { if event.matches(&keys.room.action.present) {
key!('i') => { let link = format!("https://plugh.de/present/{}/", self.name());
if let Some(id) = self.chat.cursor() { if let Err(error) = open::that(&link) {
if let Some(msg) = logging_unwrap!(self.vault().full_msg(*id).await) { self.popups.push_front(RoomPopup::Error {
self.state = State::InspectMessage(msg); description: format!("Failed to open link: {link}"),
} reason: format!("{error}"),
} });
return true;
} }
key!('I') => { return true;
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;
}
_ => {}
} }
false 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( async fn handle_chat_focus_input_event(
&mut self, &mut self,
terminal: &mut Terminal, event: &mut InputEvent<'_>,
crossterm_lock: &Arc<FairMutex<()>>, keys: &Keys,
event: &InputEvent,
) -> bool { ) -> bool {
// We need to handle chat input first, otherwise the other // We need to handle chat input first, otherwise the other
// key bindings will shadow characters in the editor. // key bindings will shadow characters in the editor.
if self if self.handle_chat_input_event(event, keys).await {
.handle_chat_input_event(terminal, crossterm_lock, event)
.await
{
return true; 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; return true;
} }
false false
} }
fn list_nick_list_focus_key_bindings(&self, bindings: &mut KeyBindingsList) { fn handle_nick_list_focus_input_event(
util::list_list_key_bindings(bindings); &mut self,
event: &mut InputEvent<'_>,
bindings.binding("i", "inspect session"); keys: &Keys,
} ) -> bool {
if util::handle_list_input_event(&mut self.nick_list, event, keys) {
fn handle_nick_list_focus_input_event(&mut self, event: &InputEvent) -> bool {
if util::handle_list_input_event(&mut self.nick_list, event) {
return true; 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(euph::State::Connected(_, conn::State::Joined(joined))) = self.room_state()
{ {
if let Some(id) = self.nick_list.selected() { if let Some(id) = self.nick_list.selected() {
@ -523,58 +456,27 @@ impl EuphRoom {
false false
} }
pub async fn list_normal_key_bindings(&self, bindings: &mut KeyBindingsList) { async fn handle_normal_input_event(&mut self, event: &mut InputEvent<'_>, keys: &Keys) -> bool {
// Handled in rooms list, not here
bindings.binding("esc", "leave room");
match self.focus { match self.focus {
Focus::Chat => { Focus::Chat => {
if let Some(euph::State::Connected(_, conn::State::Joined(_))) = self.room_state() { if self.handle_chat_focus_input_event(event, keys).await {
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<FairMutex<()>>,
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
{
return true; return true;
} }
if let Some(euph::State::Connected(_, conn::State::Joined(_))) = self.room_state() { 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; self.focus = Focus::NickList;
return true; return true;
} }
} }
} }
Focus::NickList => { Focus::NickList => {
if let key!(Tab) | key!(Esc) = event { if event.matches(&keys.general.abort) || event.matches(&keys.general.focus) {
self.focus = Focus::Chat; self.focus = Focus::Chat;
return true; return true;
} }
if self.handle_nick_list_focus_input_event(event) { if self.handle_nick_list_focus_input_event(event, keys) {
return true; return true;
} }
} }
@ -583,100 +485,40 @@ impl EuphRoom {
false false
} }
pub async fn list_key_bindings(&self, bindings: &mut KeyBindingsList) { pub async fn handle_input_event(&mut self, event: &mut InputEvent<'_>, keys: &Keys) -> bool {
bindings.heading("Room");
if !self.popups.is_empty() { if !self.popups.is_empty() {
bindings.binding("esc", "close popup"); if event.matches(&keys.general.abort) {
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<FairMutex<()>>,
event: &InputEvent,
) -> bool {
if !self.popups.is_empty() {
if matches!(event, key!(Esc)) {
self.popups.pop_back(); self.popups.pop_back();
return true; return true;
} }
// Prevent event from reaching anything below the popup
return false; return false;
} }
// TODO Use a common EventResult let result = match &mut self.state {
State::Normal => return self.handle_normal_input_event(event, keys).await,
match &mut self.state { State::Auth(editor) => auth::handle_input_event(event, keys, &self.room, editor),
State::Normal => { State::Nick(editor) => nick::handle_input_event(event, keys, &self.room, editor),
self.handle_normal_input_event(terminal, crossterm_lock, event) State::Account(account) => account.handle_input_event(event, keys, &self.room),
.await State::Links(links) => links.handle_input_event(event, keys),
}
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
}
},
State::InspectMessage(_) | State::InspectSession(_) => { State::InspectMessage(_) | State::InspectSession(_) => {
match inspect::handle_input_event(event) { inspect::handle_input_event(event, keys)
inspect::EventResult::NotHandled => false, }
inspect::EventResult::Close => { };
self.state = State::Normal;
true 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
} }
} }
} }

View file

@ -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<Self> {
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<crossterm::event::KeyEvent> 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<Row>);
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<UiError> {
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<Infallible>) -> impl Widget<UiError> + '_ {
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()));
}
}

View file

@ -2,22 +2,21 @@ use std::collections::{HashMap, HashSet};
use std::iter; use std::iter;
use std::sync::{Arc, Mutex}; 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 crossterm::style::Stylize;
use euphoxide::api::SessionType; use euphoxide::api::SessionType;
use euphoxide::bot::instance::{Event, ServerConfig}; use euphoxide::bot::instance::{Event, ServerConfig};
use euphoxide::conn::{self, Joined}; use euphoxide::conn::{self, Joined};
use parking_lot::FairMutex;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use toss::widgets::{BoxedAsync, EditorState, Empty, Join2, Text}; 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::euph;
use crate::macros::logging_unwrap; use crate::macros::logging_unwrap;
use crate::vault::Vault; use crate::vault::Vault;
use super::euph::room::EuphRoom; use super::euph::room::EuphRoom;
use super::input::{key, InputEvent, KeyBindingsList};
use super::widgets::{ListBuilder, ListState, Popup}; use super::widgets::{ListBuilder, ListState, Popup};
use super::{util, UiError, UiEvent}; use super::{util, UiError, UiEvent};
@ -357,6 +356,7 @@ impl Rooms {
order: Order, order: Order,
) { ) {
if euph_rooms.is_empty() { if euph_rooms.is_empty() {
// TODO Use configured key binding
list_builder.add_unsel(Text::new(( list_builder.add_unsel(Text::new((
"Press F1 for key bindings", "Press F1 for key bindings",
Style::new().grey().italic(), Style::new().grey().italic(),
@ -409,198 +409,137 @@ impl Rooms {
c.is_ascii_alphanumeric() || c == '_' c.is_ascii_alphanumeric() || c == '_'
} }
fn list_showlist_key_bindings(bindings: &mut KeyBindingsList) { fn handle_showlist_input_event(&mut self, event: &mut InputEvent<'_>, keys: &Keys) -> bool {
bindings.heading("Rooms"); // Open room
util::list_list_key_bindings(bindings); if event.matches(&keys.general.confirm) {
bindings.empty(); if let Some(name) = self.list.selected() {
bindings.binding("enter", "enter selected room"); self.state = State::ShowRoom(name.clone());
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) {
return true; return true;
} }
match event { // Move cursor and scroll
key!(Enter) => { if util::handle_list_input_event(&mut self.list, event, keys) {
if let Some(name) = self.list.selected() { return true;
self.state = State::ShowRoom(name.clone()); }
}
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') => { return true;
if let Some(name) = self.list.selected() { }
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()); self.connect_to_room(name.clone());
} }
return true;
} }
key!('C') => { return true;
self.connect_to_all_rooms(); }
return true; if event.matches(&keys.rooms.action.disconnect_non_autojoin) {
} for (name, room) in &mut self.euph_rooms {
key!('d') => { let autojoin = self
if let Some(name) = self.list.selected() { .config
self.disconnect_from_room(&name.clone()); .euph
.rooms
.get(name)
.map(|r| r.autojoin)
.unwrap_or(false);
if !autojoin {
room.disconnect();
} }
return true;
} }
key!('D') => { return true;
self.disconnect_from_all_rooms(); }
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') => { return true;
for (name, options) in &self.config.euph.rooms { }
if options.autojoin { if event.matches(&keys.rooms.action.change_sort_order) {
self.connect_to_room(name.clone()); self.order = match self.order {
} Order::Alphabet => Order::Importance,
} Order::Importance => Order::Alphabet,
return true; };
} 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;
}
_ => {}
} }
false false
} }
pub async fn list_key_bindings(&self, bindings: &mut KeyBindingsList) { pub async fn handle_input_event(&mut self, event: &mut InputEvent<'_>, keys: &Keys) -> bool {
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<FairMutex<()>>,
event: &InputEvent,
) -> bool {
self.stabilize_rooms().await; self.stabilize_rooms().await;
match &mut self.state { match &mut self.state {
State::ShowList => { State::ShowList => {
if self.handle_showlist_input_event(event) { if self.handle_showlist_input_event(event, keys) {
return true; return true;
} }
} }
State::ShowRoom(name) => { State::ShowRoom(name) => {
if let Some(room) = self.euph_rooms.get_mut(name) { if let Some(room) = self.euph_rooms.get_mut(name) {
if room if room.handle_input_event(event, keys).await {
.handle_input_event(terminal, crossterm_lock, event)
.await
{
return true; return true;
} }
if event.matches(&keys.general.abort) {
if let key!(Esc) = event {
self.state = State::ShowList; self.state = State::ShowList;
return true; return true;
} }
} }
} }
State::Connect(ed) => match event { State::Connect(editor) => {
key!(Esc) => { if event.matches(&keys.general.abort) {
self.state = State::ShowList; self.state = State::ShowList;
return true; return true;
} }
key!(Enter) => { if event.matches(&keys.general.confirm) {
let name = ed.text().to_string(); let name = editor.text().to_string();
if !name.is_empty() { if !name.is_empty() {
self.connect_to_room(name.clone()); self.connect_to_room(name.clone());
self.state = State::ShowRoom(name); self.state = State::ShowRoom(name);
} }
return true; return true;
} }
_ => { if util::handle_editor_input_event(editor, event, keys, Self::room_char) {
if util::handle_editor_input_event(ed, terminal, event, Self::room_char) { return true;
return true;
}
} }
}, }
State::Delete(name, editor) => match event { State::Delete(name, editor) => {
key!(Esc) => { if event.matches(&keys.general.abort) {
self.state = State::ShowList; self.state = State::ShowList;
return true; return true;
} }
key!(Enter) if editor.text() == *name => { if event.matches(&keys.general.confirm) {
self.euph_rooms.remove(name); self.euph_rooms.remove(name);
logging_unwrap!(self.vault.euph().room(name.clone()).delete().await); logging_unwrap!(self.vault.euph().room(name.clone()).delete().await);
self.state = State::ShowList; self.state = State::ShowList;
return true; return true;
} }
_ => { if util::handle_editor_input_event(editor, event, keys, Self::room_char) {
if util::handle_editor_input_event(editor, terminal, event, Self::room_char) { return true;
return true;
}
} }
}, }
} }
false false

View file

@ -1,191 +1,191 @@
use std::io; use cove_config::Keys;
use std::sync::Arc; use cove_input::InputEvent;
use crossterm::event::{KeyCode, KeyModifiers};
use parking_lot::FairMutex;
use toss::widgets::EditorState; use toss::widgets::EditorState;
use toss::Terminal;
use super::input::{key, InputEvent, KeyBindingsList};
use super::widgets::ListState; use super::widgets::ListState;
pub fn prompt(
terminal: &mut Terminal,
crossterm_lock: &Arc<FairMutex<()>>,
initial_text: &str,
) -> io::Result<String> {
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 // // List //
////////// //////////
pub fn list_list_key_bindings(bindings: &mut KeyBindingsList) { pub fn handle_list_input_event<Id: Clone>(
bindings.binding("j/k, ↓/↑", "move cursor up/down"); list: &mut ListState<Id>,
bindings.binding("g, home", "move cursor to top"); event: &InputEvent<'_>,
bindings.binding("G, end", "move cursor to bottom"); keys: &Keys,
bindings.binding("ctrl+y/e", "scroll up/down"); ) -> bool {
} // Cursor movement
if event.matches(&keys.cursor.up) {
pub fn handle_list_input_event<Id: Clone>(list: &mut ListState<Id>, event: &InputEvent) -> bool { list.move_cursor_up();
match event { return true;
key!('k') | key!(Up) => list.move_cursor_up(), }
key!('j') | key!(Down) => list.move_cursor_down(), if event.matches(&keys.cursor.down) {
key!('g') | key!(Home) => list.move_cursor_to_top(), list.move_cursor_down();
key!('G') | key!(End) => list.move_cursor_to_bottom(), return true;
key!(Ctrl + 'y') => list.scroll_up(1), }
key!(Ctrl + 'e') => list.scroll_down(1), if event.matches(&keys.cursor.to_top) {
_ => return false, 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 // // Editor //
//////////// ////////////
fn list_editor_editing_key_bindings(
bindings: &mut KeyBindingsList,
char_filter: impl Fn(char) -> bool,
) {
if char_filter('\n') {
bindings.binding("enter+<any modifier>", "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( fn edit_externally(
editor: &mut EditorState, editor: &mut EditorState,
terminal: &mut Terminal, event: &mut InputEvent<'_>,
crossterm_lock: &Arc<FairMutex<()>>, char_filter: impl Fn(char) -> bool,
) -> io::Result<()> { ) {
let text = prompt(terminal, crossterm_lock, editor.text())?; let Ok(text) = event.prompt(editor.text()) else {
// Something went wrong during editing, let's abort the edit.
return;
};
if text.trim().is_empty() { if text.trim().is_empty() {
// The user likely wanted to abort the edit and has deleted the // The user likely wanted to abort the edit and has deleted the
// entire text (bar whitespace left over by some editors). // entire text (bar whitespace left over by some editors).
return Ok(()); return;
} }
if let Some(text) = text.strip_suffix('\n') { let text = text
// Some editors like vim add a trailing newline that would look out of .strip_suffix('\n')
// place in cove's editors. To intentionally add a trailing newline, .unwrap_or(&text)
// simply add two in-editor. .chars()
editor.set_text(terminal.widthdb(), text.to_string()); .filter(|c| char_filter(*c))
} else { .collect::<String>();
editor.set_text(terminal.widthdb(), text);
}
Ok(()) editor.set_text(event.widthdb(), text);
} }
pub fn list_editor_key_bindings_allowing_external_editing( fn char_modifier(modifiers: KeyModifiers) -> bool {
bindings: &mut KeyBindingsList, modifiers == KeyModifiers::NONE || modifiers == KeyModifiers::SHIFT
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);
} }
pub fn handle_editor_input_event_allowing_external_editing( pub fn handle_editor_input_event(
editor: &mut EditorState, editor: &mut EditorState,
terminal: &mut Terminal, event: &mut InputEvent<'_>,
crossterm_lock: &Arc<FairMutex<()>>, keys: &Keys,
event: &InputEvent,
char_filter: impl Fn(char) -> bool, char_filter: impl Fn(char) -> bool,
) -> io::Result<bool> { ) -> bool {
if let key!(Ctrl + 'x') = event { // Cursor movement
edit_externally(editor, terminal, crossterm_lock)?; if event.matches(&keys.editor.cursor.left) {
Ok(true) editor.move_cursor_left(event.widthdb());
} else { return true;
Ok(handle_editor_input_event(
editor,
terminal,
event,
char_filter,
))
} }
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::<String>();
editor.insert_str(event.widthdb(), &text);
return true;
}
false
} }

View file

@ -170,6 +170,22 @@ impl<Id: Clone> ListState<Id> {
self.scroll_to(self.offset.saturating_add(lines)); 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 /// Scroll so that the cursor is in the center of the widget, or at least as
/// close as possible. /// close as possible.
pub fn center_cursor(&mut self) { pub fn center_cursor(&mut self) {