From 6c1ce49236fe0a6c94b7e871d2cfe79012c22bae Mon Sep 17 00:00:00 2001 From: Joscha Date: Thu, 4 Aug 2022 16:53:28 +0200 Subject: [PATCH] Show available key bindings with F1/? --- src/ui.rs | 84 ++++++++++++++++++--- src/ui/chat.rs | 8 +- src/ui/chat/tree.rs | 178 ++++++++++++++++++++++++++++---------------- src/ui/input.rs | 75 +++++++++++++++++++ src/ui/room.rs | 33 +++++++- src/ui/rooms.rs | 64 +++++++++++++--- 6 files changed, 355 insertions(+), 87 deletions(-) diff --git a/src/ui.rs b/src/ui.rs index b47b42b..b428cb7 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -5,6 +5,7 @@ mod rooms; mod util; mod widgets; +use std::convert::Infallible; use std::sync::{Arc, Weak}; use std::time::{Duration, Instant}; @@ -20,8 +21,10 @@ use crate::vault::Vault; pub use self::chat::ChatMsg; use self::chat::ChatState; -use self::input::{key, KeyEvent}; +use self::input::{key, KeyBindingsList, KeyEvent}; use self::rooms::Rooms; +use self::widgets::layer::Layer; +use self::widgets::list::ListState; use self::widgets::BoxedWidget; /// Time to spend batch processing events before redrawing the screen. @@ -50,6 +53,7 @@ pub struct Ui { rooms: Rooms, log_chat: ChatState, + key_bindings_list: Option>, } impl Ui { @@ -85,6 +89,7 @@ impl Ui { mode: Mode::Main, rooms: Rooms::new(vault, event_tx.clone()), log_chat: ChatState::new(logger), + key_bindings_list: None, }; tokio::select! { e = ui.run_main(terminal, event_rx, crossterm_lock) => Ok(e), @@ -183,9 +188,34 @@ impl Ui { } async fn widget(&mut self) -> BoxedWidget { - match self.mode { + let widget = match self.mode { Mode::Main => self.rooms.widget().await, Mode::Log => self.log_chat.widget(String::new()).into(), + }; + + if let Some(key_bindings_list) = &self.key_bindings_list { + let mut bindings = KeyBindingsList::new(key_bindings_list); + self.list_key_bindings(&mut bindings).await; + Layer::new(vec![widget, bindings.widget()]).into() + } else { + widget + } + } + + fn show_key_bindings(&mut self) { + if self.key_bindings_list.is_none() { + self.key_bindings_list = Some(ListState::new()) + } + } + + 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, } } @@ -195,26 +225,58 @@ impl Ui { terminal: &mut Terminal, crossterm_lock: &Arc>, ) -> EventHandleResult { - match event { + 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. - key!(Ctrl + 'c') => return EventHandleResult::Stop, - key!(F 1) => self.mode = Mode::Main, - key!(F 2) => self.mode = Mode::Log, + return EventHandleResult::Stop; + } + + // Key bindings list overrides any other bindings if visible + if let Some(key_bindings_list) = &mut self.key_bindings_list { + match event { + key!(Esc) | key!(F 1) | key!('?') => self.key_bindings_list = None, + key!('k') | key!(Up) => key_bindings_list.scroll_up(1), + key!('j') | key!(Down) => key_bindings_list.scroll_down(1), + _ => {} + } + return EventHandleResult::Continue; + } + + match event { + key!(F 1) => { + self.key_bindings_list = Some(ListState::new()); + return EventHandleResult::Continue; + } + key!(F 12) => { + self.mode = match self.mode { + Mode::Main => Mode::Log, + Mode::Log => Mode::Main, + }; + return EventHandleResult::Continue; + } _ => {} } - match self.mode { + let handled = match self.mode { Mode::Main => { self.rooms .handle_key_event(terminal, crossterm_lock, event) .await } - Mode::Log => { - self.log_chat - .handle_key_event(terminal, crossterm_lock, event, false) - .await; + Mode::Log => self + .log_chat + .handle_key_event(terminal, crossterm_lock, event, false) + .await + .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.show_key_bindings(); } } diff --git a/src/ui/chat.rs b/src/ui/chat.rs index da15108..7040999 100644 --- a/src/ui/chat.rs +++ b/src/ui/chat.rs @@ -14,7 +14,7 @@ use crate::store::{Msg, MsgStore}; use self::tree::{TreeView, TreeViewState}; -use super::input::KeyEvent; +use super::input::{KeyBindingsList, KeyEvent}; use super::widgets::Widget; /////////// @@ -84,6 +84,12 @@ impl Reaction { } 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, can_compose).await, + } + } + pub async fn handle_key_event( &mut self, terminal: &mut Terminal, diff --git a/src/ui/chat/tree.rs b/src/ui/chat/tree.rs index 549598f..23b3012 100644 --- a/src/ui/chat/tree.rs +++ b/src/ui/chat/tree.rs @@ -13,7 +13,7 @@ use toss::frame::{Frame, Pos, Size}; use toss::terminal::Terminal; use crate::store::{Msg, MsgStore}; -use crate::ui::input::{key, KeyEvent}; +use crate::ui::input::{key, KeyBindingsList, KeyEvent}; use crate::ui::widgets::editor::EditorState; use crate::ui::widgets::Widget; @@ -59,6 +59,107 @@ impl> InnerTreeViewState { } } + pub fn list_movement_key_bindings(&self, bindings: &mut KeyBindingsList) { + bindings.binding("j/k, ↓/↑", "move cursor up/down"); + bindings.binding("h/l, ←/→", "move cursor chronologically"); + 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", "scroll up/down one screen"); + } + + async fn handle_movement_key_event(&mut self, frame: &mut Frame, event: KeyEvent) -> bool { + let chat_height = frame.size().height - 3; + + match event { + key!('k') | key!(Up) => self.move_cursor_up().await, + key!('j') | key!(Down) => self.move_cursor_down().await, + key!('h') | key!(Left) => self.move_cursor_older().await, + key!('l') | key!(Right) => self.move_cursor_newer().await, + key!('g') | key!(Home) => self.move_cursor_to_top().await, + key!('G') | key!(End) => self.move_cursor_to_bottom().await, + key!(Ctrl + 'y') => self.scroll_up(1), + key!(Ctrl + 'e') => self.scroll_down(1), + key!(Ctrl + 'u') => self.scroll_up((chat_height / 2).into()), + key!(Ctrl + 'd') => self.scroll_down((chat_height / 2).into()), + key!(Ctrl + 'b') => self.scroll_up(chat_height.saturating_sub(1).into()), + key!(Ctrl + 'f') => self.scroll_down(chat_height.saturating_sub(1).into()), + _ => return false, + } + + true + } + + pub fn list_edit_initiating_key_bindings(&self, bindings: &mut KeyBindingsList) { + bindings.empty(); + bindings.binding("r", "reply to message"); + bindings.binding_ctd("(inline if possible, otherwise directly)"); + bindings.binding("R", "reply to message (opposite of R)"); + bindings.binding("t", "start a new thread"); + } + + async fn handle_edit_initiating_key_event( + &mut self, + event: KeyEvent, + id: Option, + ) -> bool { + match event { + key!('r') => { + if let Some(parent) = self.parent_for_normal_reply().await { + self.cursor = Cursor::editor(id, parent); + self.correction = Some(Correction::MakeCursorVisible); + } + } + key!('R') => { + if let Some(parent) = self.parent_for_alternate_reply().await { + self.cursor = Cursor::editor(id, parent); + self.correction = Some(Correction::MakeCursorVisible); + } + } + key!('t') | key!('T') => { + self.cursor = Cursor::editor(id, None); + self.correction = Some(Correction::MakeCursorVisible); + } + _ => return false, + } + + true + } + + pub fn list_normal_key_bindings(&self, bindings: &mut KeyBindingsList, can_compose: bool) { + self.list_movement_key_bindings(bindings); + if can_compose { + self.list_edit_initiating_key_bindings(bindings); + } + } + + async fn handle_normal_key_event( + &mut self, + frame: &mut Frame, + event: KeyEvent, + can_compose: bool, + id: Option, + ) -> bool { + if self.handle_movement_key_event(frame, event).await { + true + } else if can_compose { + self.handle_edit_initiating_key_event(event, id).await + } else { + false + } + } + + pub fn list_editor_key_bindings(&self, bindings: &mut KeyBindingsList) { + bindings.binding("esc", "close editor"); + bindings.binding("enter", "send message"); + bindings.binding("←/→", "move cursor left/right"); + bindings.binding("backspace", "delete before cursor"); + bindings.binding("delete", "delete after cursor"); + bindings.binding("ctrl+e", "edit in $EDITOR"); + bindings.binding("ctrl+l", "clear editor contents"); + } + fn handle_editor_key_event( &mut self, terminal: &mut Terminal, @@ -93,9 +194,9 @@ impl> InnerTreeViewState { } => self.editor.insert_char('\n'), key!(Char ch) => self.editor.insert_char(ch), - key!(Backspace) => self.editor.backspace(), key!(Left) => self.editor.move_cursor_left(), key!(Right) => self.editor.move_cursor_right(), + key!(Backspace) => self.editor.backspace(), key!(Delete) => self.editor.delete(), key!(Ctrl + 'e') => self.editor.edit_externally(terminal, crossterm_lock), key!(Ctrl + 'l') => self.editor.clear(), @@ -106,69 +207,16 @@ impl> InnerTreeViewState { Reaction::Handled } - async fn handle_movement_key_event(&mut self, frame: &mut Frame, event: KeyEvent) -> bool { - let chat_height = frame.size().height - 3; - - match event { - key!('k') | key!(Up) => self.move_cursor_up().await, - key!('j') | key!(Down) => self.move_cursor_down().await, - key!('h') | key!(Left) => self.move_cursor_older().await, - key!('l') | key!(Right) => self.move_cursor_newer().await, - key!('g') | key!(Home) => self.move_cursor_to_top().await, - key!('G') | key!(End) => self.move_cursor_to_bottom().await, - key!(Ctrl + 'y') => self.scroll_up(1), - key!(Ctrl + 'e') => self.scroll_down(1), - key!(Ctrl + 'u') => self.scroll_up((chat_height / 2).into()), - key!(Ctrl + 'd') => self.scroll_down((chat_height / 2).into()), - key!(Ctrl + 'b') => self.scroll_up(chat_height.saturating_sub(1).into()), - key!(Ctrl + 'f') => self.scroll_down(chat_height.saturating_sub(1).into()), - _ => return false, - } - - true - } - - async fn handle_edit_initiating_key_event( - &mut self, - event: KeyEvent, - id: Option, - ) -> bool { - match event { - key!('r') => { - if let Some(parent) = self.parent_for_normal_reply().await { - self.cursor = Cursor::editor(id, parent); - self.correction = Some(Correction::MakeCursorVisible); - } + pub fn list_key_bindings(&self, bindings: &mut KeyBindingsList, can_compose: bool) { + bindings.heading("Chat"); + match &self.cursor { + Cursor::Bottom | Cursor::Msg(_) => { + self.list_normal_key_bindings(bindings, can_compose); } - key!('R') => { - if let Some(parent) = self.parent_for_alternate_reply().await { - self.cursor = Cursor::editor(id, parent); - self.correction = Some(Correction::MakeCursorVisible); - } + Cursor::Editor { .. } => self.list_editor_key_bindings(bindings), + Cursor::Pseudo { .. } => { + self.list_movement_key_bindings(bindings); } - key!('t') | key!('T') => { - self.cursor = Cursor::editor(id, None); - self.correction = Some(Correction::MakeCursorVisible); - } - _ => return false, - } - - true - } - - async fn handle_normal_key_event( - &mut self, - frame: &mut Frame, - event: KeyEvent, - can_compose: bool, - id: Option, - ) -> bool { - if self.handle_movement_key_event(frame, event).await { - true - } else if can_compose { - self.handle_edit_initiating_key_event(event, id).await - } else { - false } } @@ -254,6 +302,10 @@ impl> TreeViewState { } } + pub async fn list_key_bindings(&self, bindings: &mut KeyBindingsList, can_compose: bool) { + self.0.lock().await.list_key_bindings(bindings, can_compose); + } + pub async fn handle_key_event( &mut self, terminal: &mut Terminal, diff --git a/src/ui/input.rs b/src/ui/input.rs index f01ad21..b6cda4d 100644 --- a/src/ui/input.rs +++ b/src/ui/input.rs @@ -1,4 +1,20 @@ +use std::convert::Infallible; + use crossterm::event::{KeyCode, KeyModifiers}; +use crossterm::style::{ContentStyle, Stylize}; +use toss::styled::Styled; + +use super::widgets::background::Background; +use super::widgets::border::Border; +use super::widgets::empty::Empty; +use super::widgets::float::Float; +use super::widgets::join::{HJoin, Segment}; +use super::widgets::layer::Layer; +use super::widgets::list::{List, ListState}; +use super::widgets::padding::Padding; +use super::widgets::resize::Resize; +use super::widgets::text::Text; +use super::widgets::BoxedWidget; /// A key event data type that is a bit easier to pattern match on than /// [`crossterm::event::KeyEvent`]. @@ -46,3 +62,62 @@ macro_rules! key { ( Alt + $key:ident ) => { KeyEvent { code: KeyCode::$key, shift: false, ctrl: false, alt: true, } }; } pub(crate) use key; + +/// Helper wrapper around a list widget for a more consistent key binding style. +pub struct KeyBindingsList(List); + +impl KeyBindingsList { + pub fn new(state: &ListState) -> Self { + Self(state.widget()) + } + + fn binding_style() -> ContentStyle { + ContentStyle::default().cyan() + } + + pub fn widget(self) -> BoxedWidget { + let binding_style = Self::binding_style(); + Float::new(Layer::new(vec![ + Border::new(Background::new(Padding::new(self.0).horizontal(1))).into(), + Float::new( + Padding::new(Text::new( + Styled::new("jk/↓↑", binding_style) + .then_plain(" to scroll, ") + .then("esc", binding_style) + .then_plain(" to close"), + )) + .horizontal(1), + ) + .horizontal(0.5) + .into(), + ])) + .horizontal(0.5) + .vertical(0.5) + .into() + } + + pub fn empty(&mut self) { + self.0.add_unsel(Empty::new()); + } + + pub fn heading(&mut self, name: &str) { + self.0 + .add_unsel(Text::new((name, ContentStyle::default().bold()))); + } + + pub fn binding(&mut self, binding: &str, description: &str) { + let widget = HJoin::new(vec![ + Segment::new(Resize::new(Text::new((binding, Self::binding_style()))).min_width(16)), + Segment::new(Text::new(description)), + ]); + self.0.add_unsel(widget); + } + + pub fn binding_ctd(&mut self, description: &str) { + let widget = HJoin::new(vec![ + Segment::new(Resize::new(Empty::new()).min_width(16)), + Segment::new(Text::new(description)), + ]); + self.0.add_unsel(widget); + } +} diff --git a/src/ui/room.rs b/src/ui/room.rs index a0a1a8b..6609bbb 100644 --- a/src/ui/room.rs +++ b/src/ui/room.rs @@ -14,7 +14,7 @@ use crate::euph::{self, Joined, Status}; use crate::vault::EuphVault; use super::chat::{ChatState, Reaction}; -use super::input::{key, KeyEvent}; +use super::input::{key, KeyBindingsList, KeyEvent}; use super::widgets::background::Background; use super::widgets::border::Border; use super::widgets::editor::EditorState; @@ -302,6 +302,37 @@ impl EuphRoom { list.into() } + pub async fn list_key_bindings(&self, bindings: &mut KeyBindingsList) { + bindings.heading("Room"); + + match &self.state { + State::Normal => { + // TODO Use if-let chain + bindings.binding("esc", "leave room"); + let can_compose = if let Some(room) = &self.room { + if let Ok(Some(Status::Joined(_))) = room.status().await { + bindings.binding("n", "change nick"); + true + } else { + false + } + } else { + false + }; + + bindings.empty(); + self.chat.list_key_bindings(bindings, can_compose).await; + } + State::ChooseNick(_) => { + bindings.binding("esc", "abort"); + bindings.binding("enter", "set nick"); + bindings.binding("←/→", "move cursor left/right"); + bindings.binding("backspace", "delete before cursor"); + bindings.binding("delete", "delete after cursor"); + } + } + } + pub async fn handle_key_event( &mut self, terminal: &mut Terminal, diff --git a/src/ui/rooms.rs b/src/ui/rooms.rs index 4860537..e077eae 100644 --- a/src/ui/rooms.rs +++ b/src/ui/rooms.rs @@ -13,7 +13,7 @@ use crate::euph::api::SessionType; use crate::euph::{Joined, Status}; use crate::vault::Vault; -use super::input::{key, KeyEvent}; +use super::input::{key, KeyBindingsList, KeyEvent}; use super::room::EuphRoom; use super::widgets::background::Background; use super::widgets::border::Border; @@ -199,19 +199,54 @@ impl Rooms { list.into() } + pub async fn list_key_bindings(&self, bindings: &mut KeyBindingsList) { + match &self.state { + State::ShowList => { + bindings.heading("Rooms"); + 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", "enter selected room"); + bindings.binding("c", "connect to selected room"); + bindings.binding("C", "connect to new room"); + bindings.binding("d", "disconnect from selected room"); + bindings.binding("D", "delete room"); + } + 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"); + bindings.binding("←/→", "move cursor left/right"); + bindings.binding("backspace", "delete before cursor"); + bindings.binding("delete", "delete after cursor"); + } + } + } + pub async fn handle_key_event( &mut self, terminal: &mut Terminal, crossterm_lock: &Arc>, event: KeyEvent, - ) { + ) -> bool { match &self.state { State::ShowList => match event { - key!(Enter) => { - if let Some(name) = self.list.cursor() { - self.state = State::ShowRoom(name); - } - } 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(), @@ -219,6 +254,11 @@ impl Rooms { key!(Ctrl + 'y') => self.list.scroll_up(1), key!(Ctrl + 'e') => self.list.scroll_down(1), + key!(Enter) => { + if let Some(name) = self.list.cursor() { + self.state = State::ShowRoom(name); + } + } key!('c') => { if let Some(name) = self.list.cursor() { self.get_or_insert_room(name).connect(); @@ -237,7 +277,7 @@ impl Rooms { self.vault.euph(name.clone()).delete(); } } - _ => {} + _ => return false, }, State::ShowRoom(name) => { if self @@ -245,7 +285,7 @@ impl Rooms { .handle_key_event(terminal, crossterm_lock, event) .await { - return; + return true; } if let key!(Esc) = event { @@ -262,12 +302,14 @@ impl Rooms { } } key!(Char ch) if ch.is_ascii_alphanumeric() || ch == '_' => ed.insert_char(ch), - key!(Backspace) => ed.backspace(), key!(Left) => ed.move_cursor_left(), key!(Right) => ed.move_cursor_right(), + key!(Backspace) => ed.backspace(), key!(Delete) => ed.delete(), - _ => {} + _ => return false, }, } + + true } }