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 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<LogMsg, Logger>,
key_bindings_list: Option<ListState<Infallible>>,
}
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<FairMutex<()>>,
) -> 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();
}
}

View file

@ -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<M: Msg> Reaction<M> {
}
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(
&mut self,
terminal: &mut Terminal,

View file

@ -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<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(
&mut self,
terminal: &mut Terminal,
@ -93,9 +194,9 @@ impl<M: Msg, S: MsgStore<M>> InnerTreeViewState<M, S> {
} => 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<M: Msg, S: MsgStore<M>> InnerTreeViewState<M, S> {
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<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);
}
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<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(
&mut self,
terminal: &mut Terminal,

View file

@ -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<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 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,

View file

@ -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<FairMutex<()>>,
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
}
}