Show available key bindings with F1/?

This commit is contained in:
Joscha 2022-08-04 16:53:28 +02:00
parent a51bb60342
commit 6c1ce49236
6 changed files with 355 additions and 87 deletions

View file

@ -5,6 +5,7 @@ mod rooms;
mod util; mod util;
mod widgets; mod widgets;
use std::convert::Infallible;
use std::sync::{Arc, Weak}; use std::sync::{Arc, Weak};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
@ -20,8 +21,10 @@ 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, KeyEvent}; use self::input::{key, KeyBindingsList, KeyEvent};
use self::rooms::Rooms; use self::rooms::Rooms;
use self::widgets::layer::Layer;
use self::widgets::list::ListState;
use self::widgets::BoxedWidget; use self::widgets::BoxedWidget;
/// Time to spend batch processing events before redrawing the screen. /// Time to spend batch processing events before redrawing the screen.
@ -50,6 +53,7 @@ pub struct Ui {
rooms: Rooms, rooms: Rooms,
log_chat: ChatState<LogMsg, Logger>, log_chat: ChatState<LogMsg, Logger>,
key_bindings_list: Option<ListState<Infallible>>,
} }
impl Ui { impl Ui {
@ -85,6 +89,7 @@ impl Ui {
mode: Mode::Main, mode: Mode::Main,
rooms: Rooms::new(vault, event_tx.clone()), rooms: Rooms::new(vault, event_tx.clone()),
log_chat: ChatState::new(logger), log_chat: ChatState::new(logger),
key_bindings_list: None,
}; };
tokio::select! { tokio::select! {
e = ui.run_main(terminal, event_rx, crossterm_lock) => Ok(e), e = ui.run_main(terminal, event_rx, crossterm_lock) => Ok(e),
@ -183,9 +188,34 @@ impl Ui {
} }
async fn widget(&mut self) -> BoxedWidget { async fn widget(&mut self) -> BoxedWidget {
match self.mode { let widget = match self.mode {
Mode::Main => self.rooms.widget().await, Mode::Main => self.rooms.widget().await,
Mode::Log => self.log_chat.widget(String::new()).into(), 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, terminal: &mut Terminal,
crossterm_lock: &Arc<FairMutex<()>>, crossterm_lock: &Arc<FairMutex<()>>,
) -> EventHandleResult { ) -> EventHandleResult {
match event { if let key!(Ctrl + 'c') = event {
// Exit unconditionally on ctrl+c. Previously, shift+q would also // Exit unconditionally on ctrl+c. Previously, shift+q would also
// unconditionally exit, but that interfered with typing text in // unconditionally exit, but that interfered with typing text in
// inline editors. // inline editors.
key!(Ctrl + 'c') => return EventHandleResult::Stop, return EventHandleResult::Stop;
key!(F 1) => self.mode = Mode::Main, }
key!(F 2) => self.mode = Mode::Log,
// 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 => { Mode::Main => {
self.rooms self.rooms
.handle_key_event(terminal, crossterm_lock, event) .handle_key_event(terminal, crossterm_lock, event)
.await .await
} }
Mode::Log => { Mode::Log => self
self.log_chat .log_chat
.handle_key_event(terminal, crossterm_lock, event, false) .handle_key_event(terminal, crossterm_lock, event, false)
.await; .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();
} }
} }

View file

@ -14,7 +14,7 @@ use crate::store::{Msg, MsgStore};
use self::tree::{TreeView, TreeViewState}; use self::tree::{TreeView, TreeViewState};
use super::input::KeyEvent; use super::input::{KeyBindingsList, KeyEvent};
use super::widgets::Widget; use super::widgets::Widget;
/////////// ///////////
@ -84,6 +84,12 @@ impl<M: Msg> Reaction<M> {
} }
impl<M: Msg, S: MsgStore<M>> ChatState<M, S> { 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, can_compose).await,
}
}
pub async fn handle_key_event( pub async fn handle_key_event(
&mut self, &mut self,
terminal: &mut Terminal, terminal: &mut Terminal,

View file

@ -13,7 +13,7 @@ use toss::frame::{Frame, Pos, Size};
use toss::terminal::Terminal; use toss::terminal::Terminal;
use crate::store::{Msg, MsgStore}; 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::editor::EditorState;
use crate::ui::widgets::Widget; use crate::ui::widgets::Widget;
@ -59,6 +59,107 @@ impl<M: Msg, S: MsgStore<M>> InnerTreeViewState<M, S> {
} }
} }
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<M::Id>,
) -> 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<M::Id>,
) -> 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( fn handle_editor_key_event(
&mut self, &mut self,
terminal: &mut Terminal, terminal: &mut Terminal,
@ -93,9 +194,9 @@ impl<M: Msg, S: MsgStore<M>> InnerTreeViewState<M, S> {
} => self.editor.insert_char('\n'), } => self.editor.insert_char('\n'),
key!(Char ch) => self.editor.insert_char(ch), key!(Char ch) => self.editor.insert_char(ch),
key!(Backspace) => self.editor.backspace(),
key!(Left) => self.editor.move_cursor_left(), key!(Left) => self.editor.move_cursor_left(),
key!(Right) => self.editor.move_cursor_right(), key!(Right) => self.editor.move_cursor_right(),
key!(Backspace) => self.editor.backspace(),
key!(Delete) => self.editor.delete(), key!(Delete) => self.editor.delete(),
key!(Ctrl + 'e') => self.editor.edit_externally(terminal, crossterm_lock), key!(Ctrl + 'e') => self.editor.edit_externally(terminal, crossterm_lock),
key!(Ctrl + 'l') => self.editor.clear(), key!(Ctrl + 'l') => self.editor.clear(),
@ -106,69 +207,16 @@ impl<M: Msg, S: MsgStore<M>> InnerTreeViewState<M, S> {
Reaction::Handled Reaction::Handled
} }
async fn handle_movement_key_event(&mut self, frame: &mut Frame, event: KeyEvent) -> bool { pub fn list_key_bindings(&self, bindings: &mut KeyBindingsList, can_compose: bool) {
let chat_height = frame.size().height - 3; bindings.heading("Chat");
match &self.cursor {
match event { Cursor::Bottom | Cursor::Msg(_) => {
key!('k') | key!(Up) => self.move_cursor_up().await, self.list_normal_key_bindings(bindings, can_compose);
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,
} }
Cursor::Editor { .. } => self.list_editor_key_bindings(bindings),
true Cursor::Pseudo { .. } => {
self.list_movement_key_bindings(bindings);
} }
async fn handle_edit_initiating_key_event(
&mut self,
event: KeyEvent,
id: Option<M::Id>,
) -> 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
}
async fn handle_normal_key_event(
&mut self,
frame: &mut Frame,
event: KeyEvent,
can_compose: bool,
id: Option<M::Id>,
) -> 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<M: Msg, S: MsgStore<M>> TreeViewState<M, S> {
} }
} }
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( pub async fn handle_key_event(
&mut self, &mut self,
terminal: &mut Terminal, terminal: &mut Terminal,

View file

@ -1,4 +1,20 @@
use std::convert::Infallible;
use crossterm::event::{KeyCode, KeyModifiers}; 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 /// A key event data type that is a bit easier to pattern match on than
/// [`crossterm::event::KeyEvent`]. /// [`crossterm::event::KeyEvent`].
@ -46,3 +62,62 @@ macro_rules! key {
( Alt + $key:ident ) => { KeyEvent { code: KeyCode::$key, shift: false, ctrl: false, alt: true, } }; ( Alt + $key:ident ) => { KeyEvent { code: KeyCode::$key, shift: false, ctrl: false, alt: true, } };
} }
pub(crate) use key; pub(crate) use key;
/// Helper wrapper around a list widget for a more consistent key binding style.
pub struct KeyBindingsList(List<Infallible>);
impl KeyBindingsList {
pub fn new(state: &ListState<Infallible>) -> 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);
}
}

View file

@ -14,7 +14,7 @@ use crate::euph::{self, Joined, Status};
use crate::vault::EuphVault; use crate::vault::EuphVault;
use super::chat::{ChatState, Reaction}; use super::chat::{ChatState, Reaction};
use super::input::{key, KeyEvent}; use super::input::{key, KeyBindingsList, KeyEvent};
use super::widgets::background::Background; use super::widgets::background::Background;
use super::widgets::border::Border; use super::widgets::border::Border;
use super::widgets::editor::EditorState; use super::widgets::editor::EditorState;
@ -302,6 +302,37 @@ impl EuphRoom {
list.into() 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( pub async fn handle_key_event(
&mut self, &mut self,
terminal: &mut Terminal, terminal: &mut Terminal,

View file

@ -13,7 +13,7 @@ use crate::euph::api::SessionType;
use crate::euph::{Joined, Status}; use crate::euph::{Joined, Status};
use crate::vault::Vault; use crate::vault::Vault;
use super::input::{key, KeyEvent}; use super::input::{key, KeyBindingsList, KeyEvent};
use super::room::EuphRoom; use super::room::EuphRoom;
use super::widgets::background::Background; use super::widgets::background::Background;
use super::widgets::border::Border; use super::widgets::border::Border;
@ -199,19 +199,54 @@ impl Rooms {
list.into() 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( pub async fn handle_key_event(
&mut self, &mut self,
terminal: &mut Terminal, terminal: &mut Terminal,
crossterm_lock: &Arc<FairMutex<()>>, crossterm_lock: &Arc<FairMutex<()>>,
event: KeyEvent, event: KeyEvent,
) { ) -> bool {
match &self.state { match &self.state {
State::ShowList => match event { 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!('k') | key!(Up) => self.list.move_cursor_up(),
key!('j') | key!(Down) => self.list.move_cursor_down(), key!('j') | key!(Down) => self.list.move_cursor_down(),
key!('g') | key!(Home) => self.list.move_cursor_to_top(), 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 + 'y') => self.list.scroll_up(1),
key!(Ctrl + 'e') => self.list.scroll_down(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') => { key!('c') => {
if let Some(name) = self.list.cursor() { if let Some(name) = self.list.cursor() {
self.get_or_insert_room(name).connect(); self.get_or_insert_room(name).connect();
@ -237,7 +277,7 @@ impl Rooms {
self.vault.euph(name.clone()).delete(); self.vault.euph(name.clone()).delete();
} }
} }
_ => {} _ => return false,
}, },
State::ShowRoom(name) => { State::ShowRoom(name) => {
if self if self
@ -245,7 +285,7 @@ impl Rooms {
.handle_key_event(terminal, crossterm_lock, event) .handle_key_event(terminal, crossterm_lock, event)
.await .await
{ {
return; return true;
} }
if let key!(Esc) = event { 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!(Char ch) if ch.is_ascii_alphanumeric() || ch == '_' => ed.insert_char(ch),
key!(Backspace) => ed.backspace(),
key!(Left) => ed.move_cursor_left(), key!(Left) => ed.move_cursor_left(),
key!(Right) => ed.move_cursor_right(), key!(Right) => ed.move_cursor_right(),
key!(Backspace) => ed.backspace(),
key!(Delete) => ed.delete(), key!(Delete) => ed.delete(),
_ => {} _ => return false,
}, },
} }
true
} }
} }