Skip to content

Commit

Permalink
Build action menus dynamically
Browse files Browse the repository at this point in the history
This cleans up and deduplicates the code used to generate action menus, and also opens the door for components to provide a dynamic number of actions. It will also make it easier to bind shortcuts to actions.

This also includes a medium-sized refactor of emitters. The Emitter trait has had most of its logic moved to the EmitterHandle struct. Accordingly, the Emitter trait is now called ToEmitter, and EmitterHandle is now just Emitter. This should cut down a bit on static code duplication during compilation, and also simplifies the emitter logic a bit. Components themselves are no longer emitters, instead they just hold emitters. Since components are no longer working directly with Emitter IDs, it's not possible to mix up IDs and emit the wrong type from one. An emitter is just an emitter ID bound to a particular type.
  • Loading branch information
LucasPickering committed Jan 9, 2025
1 parent 29e72c6 commit 6c91bd3
Show file tree
Hide file tree
Showing 27 changed files with 775 additions and 653 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
- Add `--output` flag to `slumber request` to control where the response body is written to
- Support MIME type mapping for `pager` config field, so you can set different pagers based on media type. [See docs](https://slumber.lucaspickering.me/book/api/configuration/mime.html)

### Fixed

- Fix certain recipe-related menu actions being enabled when they shouldn't be

## [2.5.0] - 2025-01-06

### Added
Expand Down
3 changes: 2 additions & 1 deletion crates/tui/src/view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ use crate::{
message::{Message, MessageSender},
util::ResultReported,
view::{
common::modal::Modal,
component::{Component, Root},
debug::DebugMonitor,
event::Event,
Expand Down Expand Up @@ -123,7 +124,7 @@ impl View {
/// Queue an event to open a new modal. The input can be anything that
/// converts to modal content
pub fn open_modal(&mut self, modal: impl IntoModal + 'static) {
ViewContext::open_modal(modal.into_modal());
modal.into_modal().open();
}

/// Queue an event to send an informational notification to the user
Expand Down
143 changes: 89 additions & 54 deletions crates/tui/src/view/common/actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,77 +2,83 @@ use crate::view::{
common::{list::List, modal::Modal},
component::Component,
context::UpdateContext,
draw::{Draw, DrawMetadata, Generate},
event::{Child, Emitter, EmitterId, Event, EventHandler, OptionEvent},
state::{
fixed_select::{FixedSelect, FixedSelectState},
select::{SelectStateEvent, SelectStateEventType},
draw::{Draw, DrawMetadata, ToStringGenerate},
event::{
Child, Emitter, Event, EventHandler, LocalEvent, OptionEvent, ToEmitter,
},
state::select::{SelectState, SelectStateEvent, SelectStateEventType},
};
use ratatui::{
layout::Constraint,
text::{Line, Span},
widgets::ListState,
Frame,
};
use itertools::Itertools;
use ratatui::{layout::Constraint, text::Line, Frame};
use std::fmt::Display;
use strum::IntoEnumIterator;

/// Modal to list and trigger arbitrary actions. The list of available actions
/// is defined by the generic parameter
/// Modal to list and trigger arbitrary actions. The user opens the action menu
/// with a keybinding, at which point the list of available actions is built
/// dynamically via [EventHandler::menu_actions]. When an action is selected,
/// the modal is closed and that action will be emitted as a dynamic event, to
/// be handled by the component that originally supplied it. Each component that
/// provides actions should store an [Emitter] specifically for its actions,
/// which will be provided to each supplied action and can be used to check and
/// consume the action events.
#[derive(Debug)]
pub struct ActionsModal<T: FixedSelect> {
emitter_id: EmitterId,
pub struct ActionsModal {
/// Join the list of global actions into the given one
actions: Component<FixedSelectState<T, ListState>>,
actions: Component<SelectState<MenuAction>>,
}

impl<T: FixedSelect> ActionsModal<T> {
impl ActionsModal {
/// Create a new actions modal, optional disabling certain actions based on
/// some external condition(s).
pub fn new(disabled_actions: &[T]) -> Self {
pub fn new(actions: Vec<MenuAction>) -> Self {
let disabled_indexes = actions
.iter()
.enumerate()
.filter(|(_, action)| !action.enabled)
.map(|(i, _)| i)
.collect_vec();
Self {
emitter_id: EmitterId::new(),
actions: FixedSelectState::builder()
.disabled_items(disabled_actions)
actions: SelectState::builder(actions)
.disabled_indexes(disabled_indexes)
.subscribe([SelectStateEventType::Submit])
.build()
.into(),
}
}
}

impl<T: FixedSelect> Default for ActionsModal<T> {
fn default() -> Self {
Self::new(&[])
}
}

impl<T> Modal for ActionsModal<T>
where
T: FixedSelect,
ActionsModal<T>: Draw,
{
impl Modal for ActionsModal {
fn title(&self) -> Line<'_> {
"Actions".into()
}

fn dimensions(&self) -> (Constraint, Constraint) {
(Constraint::Length(30), Constraint::Length(T::COUNT as u16))
(
Constraint::Length(30),
Constraint::Length(self.actions.data().len() as u16),
)
}

fn on_close(self: Box<Self>, submitted: bool) {
if submitted {
let action = self
.actions
.into_data()
.into_selected()
.expect("User submitted something");
// Emit an event on behalf of the component that supplied this
// action. The component will use its own supplied emitter ID to
// consume the event
action.emitter.emit(action.value);
}
}
}

impl<T> EventHandler for ActionsModal<T>
where
T: FixedSelect,
ActionsModal<T>: Draw,
{
impl EventHandler for ActionsModal {
fn update(&mut self, _: &mut UpdateContext, event: Event) -> Option<Event> {
event.opt().emitted(self.actions.handle(), |event| {
if let SelectStateEvent::Submit(index) = event {
// Close modal first so the parent can consume the emitted
// event
event.opt().emitted(self.actions.to_emitter(), |event| {
if let SelectStateEvent::Submit(_) = event {
self.close(true);
let action = self.actions.data()[index];
self.emit(action);
}
})
}
Expand All @@ -82,11 +88,7 @@ where
}
}

impl<T> Draw for ActionsModal<T>
where
T: 'static + FixedSelect,
for<'a> &'a T: Generate<Output<'a> = Span<'a>>,
{
impl Draw for ActionsModal {
fn draw(&self, frame: &mut Frame, _: (), metadata: DrawMetadata) {
self.actions.draw(
frame,
Expand All @@ -97,11 +99,44 @@ where
}
}

impl<T: FixedSelect> Emitter for ActionsModal<T> {
/// Emit the action itself
type Emitted = T;
/// One item in an action menu modal. The action menu is built dynamically, and
/// each action is tied back to the component that supplied it via an [Emitter].
#[derive(Debug, derive_more::Display)]
#[display("{name}")]
pub struct MenuAction {
name: String,
value: Box<dyn LocalEvent>,
/// Because actions are sourced from multiple components, we use a
/// type-erased emitter here. When the action is selected, we'll emit it on
/// behalf of the supplier, who will then downcast and consume it in its
/// update() handler.
emitter: Emitter<dyn LocalEvent>,
enabled: bool,
}

fn id(&self) -> EmitterId {
self.emitter_id
impl ToStringGenerate for MenuAction {}

/// Trait for an enum that can be converted into a list of actions. Most
/// components have a static list of actions available, so this trait makes it
/// easy to implement [EventHandler::menu_actions].
pub trait IntoMenuActions<Data>:
Display + IntoEnumIterator + LocalEvent
{
/// Create a list of actions, one per variant in this enum
fn into_actions(data: &Data) -> Vec<MenuAction>
where
Data: ToEmitter<Self>,
{
Self::iter()
.map(|action| MenuAction {
name: action.to_string(),
enabled: action.enabled(data),
emitter: data.to_emitter().upcast(),
value: Box::new(action),
})
.collect()
}

/// Should this action be enabled in the menu?
fn enabled(&self, data: &Data) -> bool;
}
18 changes: 8 additions & 10 deletions crates/tui/src/view/common/button.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use crate::{
view::{
context::UpdateContext,
draw::{Draw, DrawMetadata, Generate},
event::{Emitter, EmitterId, Event, EventHandler, OptionEvent},
event::{Emitter, Event, EventHandler, OptionEvent, ToEmitter},
state::fixed_select::{FixedSelect, FixedSelectState},
},
};
Expand Down Expand Up @@ -50,7 +50,9 @@ impl<'a> Generate for Button<'a> {
/// type `T`.
#[derive(Debug, Default)]
pub struct ButtonGroup<T: FixedSelect> {
emitter_id: EmitterId,
/// The only type of event we can emit is a button being selected, so just
/// emit the button type
emitter: Emitter<T>,
select: FixedSelectState<T>,
}

Expand All @@ -61,7 +63,7 @@ impl<T: FixedSelect> EventHandler for ButtonGroup<T> {
Action::Right => self.select.next(),
Action::Submit => {
// Propagate the selected item as a dynamic event
self.emit(self.select.selected());
self.emitter.emit(self.select.selected());
}
_ => propagate.set(),
})
Expand Down Expand Up @@ -99,12 +101,8 @@ impl<T: FixedSelect> Draw for ButtonGroup<T> {
}
}

/// The only type of event we can emit is a button being selected, so just
/// emit the button type
impl<T: FixedSelect> Emitter for ButtonGroup<T> {
type Emitted = T;

fn id(&self) -> EmitterId {
self.emitter_id
impl<T: FixedSelect> ToEmitter<T> for ButtonGroup<T> {
fn to_emitter(&self) -> Emitter<T> {
self.emitter
}
}
77 changes: 42 additions & 35 deletions crates/tui/src/view/common/modal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ use crate::{
context::UpdateContext,
draw::{Draw, DrawMetadata},
event::{
Child, Emitter, EmitterHandle, EmitterId, Event, EventHandler,
OptionEvent,
Child, Emitter, Event, EventHandler, LocalEvent, OptionEvent,
ToEmitter,
},
util::centered_rect,
Component, ViewContext,
Expand Down Expand Up @@ -44,6 +44,14 @@ pub trait Modal: Debug + Draw<()> + EventHandler {
/// Dimensions of the modal, relative to the whole screen
fn dimensions(&self) -> (Constraint, Constraint);

/// Send an event to open this modal
fn open(self)
where
Self: 'static + Sized,
{
ViewContext::push_event(Event::OpenModal(Box::new(self)));
}

/// Send an event to close this modal. `submitted` flag will be forwarded
/// to the `on_close` handler.
fn close(&self, submitted: bool) {
Expand Down Expand Up @@ -229,55 +237,54 @@ impl Draw for ModalQueue {
/// A helper to manage opened modals. Useful for components that need to open
/// a modal of a particular type, then listen for emitted events from that
/// modal. This only supports **a single modal at a time** of that type.
///
/// The generic parameter here is the type of *event* emitted by the modal, not
/// the modal type itself. This event is what the opener will receive back from
/// the modal.
#[derive(Debug)]
pub struct ModalHandle<T: Emitter> {
/// Track the emitter ID of the opened modal, so we can check for emitted
/// events from it later. This is `None` on initialization. Note: this does
/// *not* get cleared when a modal is closed, because that requires extra
/// plumbing but would not actually accomplish anything. Once a modal is
/// closed, it won't be emitting anymore so there's no harm in hanging onto
/// its ID.
emitter: Option<EmitterHandle<T::Emitted>>,
}

// Manual impls needed to bypass bounds
impl<T: Emitter> Copy for ModalHandle<T> {}

impl<T: Emitter> Clone for ModalHandle<T> {
fn clone(&self) -> Self {
*self
}
pub struct ModalHandle<E> {
/// Track the emitter of the opened modal, so we can check for emitted
/// events from it later. We'll reuse the same emitter for every opened
/// modal. This makes it simpler, and shouldn't create issues since you
/// can't have multiple instances of the same modal open+visible at once.
emitter: Emitter<E>,
}

impl<T: Emitter> ModalHandle<T> {
impl<E: LocalEvent> ModalHandle<E> {
pub fn new() -> Self {
Self { emitter: None }
Self {
emitter: Emitter::default(),
}
}

/// Open a modal and store its emitter ID
pub fn open(&mut self, modal: T)
pub fn open<M>(&mut self, modal: M)
where
T: 'static + Modal,
M: 'static + ToEmitter<E> + Modal,
{
self.emitter = Some(modal.handle());
ViewContext::open_modal(modal);
modal.open();
}
}

impl<T: Emitter> Default for ModalHandle<T> {
fn default() -> Self {
Self { emitter: None }
// Manual impls needed to bypass bounds
impl<E> Copy for ModalHandle<E> {}

impl<E> Clone for ModalHandle<E> {
fn clone(&self) -> Self {
*self
}
}

impl<T: Emitter> Emitter for ModalHandle<T> {
type Emitted = T::Emitted;
impl<E> Default for ModalHandle<E> {
fn default() -> Self {
Self {
emitter: Emitter::default(),
}
}
}

fn id(&self) -> EmitterId {
// If we don't have an ID stored yet, use an empty one
impl<E: 'static + Debug> ToEmitter<E> for ModalHandle<E> {
fn to_emitter(&self) -> Emitter<E> {
self.emitter
.as_ref()
.map(Emitter::id)
.unwrap_or(EmitterId::nil())
}
}
Loading

0 comments on commit 6c91bd3

Please sign in to comment.