Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Build action menus dynamically #454

Merged
merged 1 commit into from
Jan 9, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading