Skip to content

Commit

Permalink
Add shortcuts to menu actions
Browse files Browse the repository at this point in the history
  • Loading branch information
LucasPickering committed Jan 9, 2025
1 parent 7ee2d18 commit 9e4468b
Show file tree
Hide file tree
Showing 8 changed files with 207 additions and 52 deletions.
170 changes: 154 additions & 16 deletions crates/tui/src/view/common/actions.rs
Original file line number Diff line number Diff line change
@@ -1,15 +1,24 @@
use crate::view::{
common::{list::List, modal::Modal},
component::Component,
context::UpdateContext,
draw::{Draw, DrawMetadata, ToStringGenerate},
event::{
Child, Emitter, Event, EventHandler, LocalEvent, OptionEvent, ToEmitter,
use crate::{
context::TuiContext,
view::{
common::{list::List, modal::Modal},
component::Component,
context::UpdateContext,
draw::{Draw, DrawMetadata, Generate},
event::{
Child, Emitter, Event, EventHandler, LocalEvent, OptionEvent,
ToEmitter,
},
state::select::{SelectState, SelectStateEvent, SelectStateEventType},
},
state::select::{SelectState, SelectStateEvent, SelectStateEventType},
};
use itertools::Itertools;
use ratatui::{layout::Constraint, text::Line, Frame};
use ratatui::{
layout::Constraint,
text::{Line, Span},
Frame,
};
use slumber_config::Action;
use std::fmt::Display;

/// Modal to list and trigger arbitrary actions. The user opens the action menu
Expand Down Expand Up @@ -75,11 +84,31 @@ impl Modal for ActionsModal {

impl EventHandler for ActionsModal {
fn update(&mut self, _: &mut UpdateContext, event: Event) -> Option<Event> {
event.opt().emitted(self.actions.to_emitter(), |event| {
if let SelectStateEvent::Submit(_) = event {
self.close(true);
}
})
event
.opt()
.action(|action, propagate| {
// For any input action, check if any menu items are bound to it
// as a shortcut. If there are multiple menu actions bound to
// the same shortcut, we'll just take the first.
let bound_index =
self.actions.data().items().position(|menu_action| {
menu_action.shortcut == Some(action)
});
if let Some(index) = bound_index {
// We need ownership of the menu action to emit it, so defer
// into the on_close handler. Selecting the item is how we
// know which one to submit
self.actions.data_mut().select_index(index);
self.close(true);
} else {
propagate.set();
}
})
.emitted(self.actions.to_emitter(), |event| {
if let SelectStateEvent::Submit(_) = event {
self.close(true);
}
})
}

fn children(&mut self) -> Vec<Component<Child<'_>>> {
Expand Down Expand Up @@ -111,6 +140,8 @@ pub struct MenuAction {
/// update() handler.
emitter: Emitter<dyn LocalEvent>,
enabled: bool,
/// Input action bound to this menu action
shortcut: Option<Action>,
}

impl MenuAction {
Expand All @@ -123,14 +154,34 @@ impl MenuAction {
{
|action| Self {
name: action.to_string(),
enabled: action.enabled(data),
emitter: data.to_emitter().upcast(),
enabled: action.enabled(data),
shortcut: action.shortcut(data),
value: Box::new(action),
}
}
}

impl ToStringGenerate for MenuAction {}
impl Generate for &MenuAction {
type Output<'this> = Span<'this>
where
Self: 'this;

fn generate<'this>(self) -> Self::Output<'this>
where
Self: 'this,
{
// If a shortcut is given, include the binding in the text
self.shortcut
.map(|shortcut| {
TuiContext::get()
.input_engine
.add_hint(&self.name, shortcut)
.into()
})
.unwrap_or_else(|| self.name.as_str().into())
}
}

/// Trait for an enum that can be converted into menu actions. Most components
/// have a static list of actions available, so this trait makes it
Expand All @@ -140,4 +191,91 @@ pub trait IntoMenuAction<Data>: Display + LocalEvent {
fn enabled(&self, _: &Data) -> bool {
true
}

/// What input action, if any, should trigger this menu action?
fn shortcut(&self, _: &Data) -> Option<Action> {
None
}
}

#[cfg(test)]
mod tests {
use super::*;
use crate::{
test_util::{harness, terminal, TestHarness, TestTerminal},
view::test_util::TestComponent,
};
use crossterm::event::KeyCode;
use rstest::rstest;
use strum::{EnumIter, IntoEnumIterator};

/// A component that provides some actions
#[derive(Default)]
struct Actionable {
emitter: Emitter<TestMenuAction>,
}

impl EventHandler for Actionable {
fn menu_actions(&self) -> Vec<MenuAction> {
TestMenuAction::iter()
.map(MenuAction::with_data(self))
.collect()
}
}

impl Draw for Actionable {
fn draw(&self, _: &mut Frame, _: (), _: DrawMetadata) {}
}

impl ToEmitter<TestMenuAction> for Actionable {
fn to_emitter(&self) -> Emitter<TestMenuAction> {
self.emitter
}
}

#[derive(Debug, derive_more::Display, PartialEq, EnumIter)]
enum TestMenuAction {
Flobrigate,
Profilate,
Disablify,
Shortcutticated,
}

impl IntoMenuAction<Actionable> for TestMenuAction {
fn enabled(&self, _: &Actionable) -> bool {
!matches!(self, Self::Disablify)
}

fn shortcut(&self, _: &Actionable) -> Option<Action> {
match self {
Self::Shortcutticated => Some(Action::Edit),
_ => None,
}
}
}

/// Test basic action menu interactions
#[rstest]
fn test_actions(harness: TestHarness, terminal: TestTerminal) {
let mut component =
TestComponent::new(&harness, &terminal, Actionable::default());

// Select a basic action
component.open_actions().assert_empty();
component
.send_keys([KeyCode::Down, KeyCode::Enter])
.assert_emitted([TestMenuAction::Profilate]);

// Selecting a disabled action does nothing
component.open_actions().assert_empty();
component
.send_keys([KeyCode::Down, KeyCode::Enter])
.assert_emitted([TestMenuAction::Profilate]);

// Actions can be selected by shortcut
component.open_actions().assert_empty();
component
.send_keys([KeyCode::Char('e')])
.assert_emitted([TestMenuAction::Shortcutticated]);
}
}
3 changes: 1 addition & 2 deletions crates/tui/src/view/component/primary.rs
Original file line number Diff line number Diff line change
Expand Up @@ -539,8 +539,7 @@ mod tests {

harness.clear_messages(); // Clear init junk

// Open action menu
component.send_key(KeyCode::Char('x')).assert_empty();
component.open_actions().assert_empty();
// Select first action - Edit Collection
component.send_key(KeyCode::Enter).assert_empty();
// Event should be converted into a message appropriately
Expand Down
17 changes: 10 additions & 7 deletions crates/tui/src/view/component/recipe_pane/authentication.rs
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,14 @@ enum AuthenticationMenuAction {
Reset,
}

impl IntoMenuAction<AuthenticationDisplay> for AuthenticationMenuAction {}
impl IntoMenuAction<AuthenticationDisplay> for AuthenticationMenuAction {
fn shortcut(&self, _: &AuthenticationDisplay) -> Option<Action> {
match self {
Self::Edit => Some(Action::Edit),
Self::Reset => Some(Action::Reset),
}
}
}

/// Private to hide enum variants
#[derive(Debug)]
Expand Down Expand Up @@ -480,13 +487,9 @@ mod tests {
AuthenticationDisplay::new(RecipeId::factory(()), authentication),
);

component.open_actions().assert_empty();
component
.send_keys([
KeyCode::Char('x'),
KeyCode::Enter,
KeyCode::Char('!'),
KeyCode::Enter,
])
.send_keys([KeyCode::Enter, KeyCode::Char('!'), KeyCode::Enter])
.assert_empty();
assert_eq!(
component.data().override_value(),
Expand Down
9 changes: 8 additions & 1 deletion crates/tui/src/view/component/recipe_pane/body.rs
Original file line number Diff line number Diff line change
Expand Up @@ -308,7 +308,14 @@ enum RawBodyMenuAction {
Reset,
}

impl IntoMenuAction<RawBody> for RawBodyMenuAction {}
impl IntoMenuAction<RawBody> for RawBodyMenuAction {
fn shortcut(&self, _: &RawBody) -> Option<Action> {
match self {
Self::Edit => Some(Action::Edit),
Self::Reset => Some(Action::Reset),
}
}
}

/// Local event to save a user's override body. Triggered from the on_complete
/// callback when the user closes the editor.
Expand Down
18 changes: 12 additions & 6 deletions crates/tui/src/view/component/recipe_pane/table.rs
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,16 @@ where
}
}
}

fn shortcut(
&self,
_: &RecipeFieldTable<RowSelectKey, RowToggleKey>,
) -> Option<Action> {
match self {
Self::Edit { .. } => Some(Action::Edit),
Self::Reset { .. } => Some(Action::Reset),
}
}
}

#[derive(Clone)]
Expand Down Expand Up @@ -582,13 +592,9 @@ mod tests {
},
);

component.open_actions().assert_empty();
component
.send_keys([
KeyCode::Char('x'),
KeyCode::Enter,
KeyCode::Char('!'),
KeyCode::Enter,
])
.send_keys([KeyCode::Enter, KeyCode::Char('!'), KeyCode::Enter])
.assert_empty();

let selected_row = component.data().select.data().selected().unwrap();
Expand Down
15 changes: 6 additions & 9 deletions crates/tui/src/view/component/response_view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -213,9 +213,10 @@ mod tests {
);

// Open actions modal and select the copy action
component.open_actions().assert_empty();
// Note: Edit Collections action isn't visible here
component
// Note: Edit Collections action isn't visible here
.send_keys([KeyCode::Char('x'), KeyCode::Down, KeyCode::Enter])
.send_keys([KeyCode::Down, KeyCode::Enter])
.assert_empty();

let body = assert_matches!(
Expand Down Expand Up @@ -302,14 +303,10 @@ mod tests {
}

// Open actions modal and select the save action
component.open_actions().assert_empty();
component
.send_keys([
KeyCode::Char('x'),
// Note: Edit Collections action isn't visible here
KeyCode::Down,
KeyCode::Down,
KeyCode::Enter,
])
// Note: Edit Collections action isn't visible here
.send_keys([KeyCode::Down, KeyCode::Down, KeyCode::Enter])
.assert_empty();

let (request_id, data) = assert_matches!(
Expand Down
22 changes: 11 additions & 11 deletions crates/tui/src/view/state/select.rs
Original file line number Diff line number Diff line change
Expand Up @@ -234,18 +234,8 @@ impl<Item, State: SelectStateData> SelectState<Item, State> {
}
}

/// Select the previous item in the list
pub fn previous(&mut self) {
self.select_delta(-1);
}

/// Select the next item in the list
pub fn next(&mut self) {
self.select_delta(1);
}

/// Select an item by index
fn select_index(&mut self, index: usize) {
pub fn select_index(&mut self, index: usize) {
let state = self.state.get_mut();
let current = state.selected();
state.select(index);
Expand All @@ -257,6 +247,16 @@ impl<Item, State: SelectStateData> SelectState<Item, State> {
}
}

/// Select the previous item in the list
pub fn previous(&mut self) {
self.select_delta(-1);
}

/// Select the next item in the list
pub fn next(&mut self) {
self.select_delta(1);
}

/// Move some number of items up or down the list. Selection will wrap if
/// it underflows/overflows.
fn select_delta(&mut self, delta: isize) {
Expand Down
5 changes: 5 additions & 0 deletions crates/tui/src/view/test_util.rs
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,11 @@ where
events,
}
}

/// Open the actions menu
pub fn open_actions(&mut self) -> PropagatedEvents<'_, T> {
self.send_key(KeyCode::Char('x'))
}
}

/// A collection of events that were propagated out from a particular
Expand Down

0 comments on commit 9e4468b

Please sign in to comment.