diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b0f9966..cb64def1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/crates/tui/src/view.rs b/crates/tui/src/view.rs index 6cbb825d..ab0e54b7 100644 --- a/crates/tui/src/view.rs +++ b/crates/tui/src/view.rs @@ -21,6 +21,7 @@ use crate::{ message::{Message, MessageSender}, util::ResultReported, view::{ + common::modal::Modal, component::{Component, Root}, debug::DebugMonitor, event::Event, @@ -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 diff --git a/crates/tui/src/view/common/actions.rs b/crates/tui/src/view/common/actions.rs index 5d36a74e..b7cf9212 100644 --- a/crates/tui/src/view/common/actions.rs +++ b/crates/tui/src/view/common/actions.rs @@ -2,37 +2,44 @@ 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 { - emitter_id: EmitterId, +pub struct ActionsModal { /// Join the list of global actions into the given one - actions: Component>, + actions: Component>, } -impl ActionsModal { +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) -> 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(), @@ -40,39 +47,38 @@ impl ActionsModal { } } -impl Default for ActionsModal { - fn default() -> Self { - Self::new(&[]) - } -} - -impl Modal for ActionsModal -where - T: FixedSelect, - ActionsModal: 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, 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 EventHandler for ActionsModal -where - T: FixedSelect, - ActionsModal: Draw, -{ +impl EventHandler for ActionsModal { fn update(&mut self, _: &mut UpdateContext, event: Event) -> Option { - 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); } }) } @@ -82,11 +88,7 @@ where } } -impl Draw for ActionsModal -where - T: 'static + FixedSelect, - for<'a> &'a T: Generate = Span<'a>>, -{ +impl Draw for ActionsModal { fn draw(&self, frame: &mut Frame, _: (), metadata: DrawMetadata) { self.actions.draw( frame, @@ -97,11 +99,44 @@ where } } -impl Emitter for ActionsModal { - /// 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, + /// 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, + 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: + Display + IntoEnumIterator + LocalEvent +{ + /// Create a list of actions, one per variant in this enum + fn into_actions(data: &Data) -> Vec + where + Data: ToEmitter, + { + 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; } diff --git a/crates/tui/src/view/common/button.rs b/crates/tui/src/view/common/button.rs index 2de7dd99..be498b55 100644 --- a/crates/tui/src/view/common/button.rs +++ b/crates/tui/src/view/common/button.rs @@ -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}, }, }; @@ -50,7 +50,9 @@ impl<'a> Generate for Button<'a> { /// type `T`. #[derive(Debug, Default)] pub struct ButtonGroup { - emitter_id: EmitterId, + /// The only type of event we can emit is a button being selected, so just + /// emit the button type + emitter: Emitter, select: FixedSelectState, } @@ -61,7 +63,7 @@ impl EventHandler for ButtonGroup { 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(), }) @@ -99,12 +101,8 @@ impl Draw for ButtonGroup { } } -/// The only type of event we can emit is a button being selected, so just -/// emit the button type -impl Emitter for ButtonGroup { - type Emitted = T; - - fn id(&self) -> EmitterId { - self.emitter_id +impl ToEmitter for ButtonGroup { + fn to_emitter(&self) -> Emitter { + self.emitter } } diff --git a/crates/tui/src/view/common/modal.rs b/crates/tui/src/view/common/modal.rs index f7d2d92b..ef8c6a45 100644 --- a/crates/tui/src/view/common/modal.rs +++ b/crates/tui/src/view/common/modal.rs @@ -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, @@ -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) { @@ -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 { - /// 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>, -} - -// Manual impls needed to bypass bounds -impl Copy for ModalHandle {} - -impl Clone for ModalHandle { - fn clone(&self) -> Self { - *self - } +pub struct ModalHandle { + /// 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, } -impl ModalHandle { +impl ModalHandle { 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(&mut self, modal: M) where - T: 'static + Modal, + M: 'static + ToEmitter + Modal, { - self.emitter = Some(modal.handle()); - ViewContext::open_modal(modal); + modal.open(); } } -impl Default for ModalHandle { - fn default() -> Self { - Self { emitter: None } +// Manual impls needed to bypass bounds +impl Copy for ModalHandle {} + +impl Clone for ModalHandle { + fn clone(&self) -> Self { + *self } } -impl Emitter for ModalHandle { - type Emitted = T::Emitted; +impl Default for ModalHandle { + 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 ToEmitter for ModalHandle { + fn to_emitter(&self) -> Emitter { self.emitter - .as_ref() - .map(Emitter::id) - .unwrap_or(EmitterId::nil()) } } diff --git a/crates/tui/src/view/common/text_box.rs b/crates/tui/src/view/common/text_box.rs index 238acf29..5734fd81 100644 --- a/crates/tui/src/view/common/text_box.rs +++ b/crates/tui/src/view/common/text_box.rs @@ -5,7 +5,7 @@ use crate::{ view::{ context::UpdateContext, draw::{Draw, DrawMetadata}, - event::{Emitter, EmitterId, Event, EventHandler, OptionEvent}, + event::{Emitter, Event, EventHandler, OptionEvent, ToEmitter}, util::Debounce, }, }; @@ -25,7 +25,7 @@ const DEBOUNCE: Duration = Duration::from_millis(500); /// Single line text submission component #[derive(derive_more::Debug, Default)] pub struct TextBox { - emitter_id: EmitterId, + emitter: Emitter, // Parameters sensitive: bool, /// Text to show when text content is empty @@ -177,16 +177,16 @@ impl TextBox { /// Emit a change event. Should be called whenever text _content_ is changed fn change(&mut self) { let is_valid = self.is_valid(); - let emitter = self.handle(); if let Some(debounce) = &mut self.on_change_debounce { if is_valid { // Defer the change event until after the debounce period + let emitter = self.emitter; debounce.start(move || emitter.emit(TextBoxEvent::Change)); } else { debounce.cancel(); } } else if is_valid { - self.emit(TextBoxEvent::Change); + self.emitter.emit(TextBoxEvent::Change); } } @@ -194,7 +194,7 @@ impl TextBox { fn submit(&mut self) { if self.is_valid() { self.cancel_debounce(); - self.emit(TextBoxEvent::Submit); + self.emitter.emit(TextBoxEvent::Submit); } } @@ -215,9 +215,9 @@ impl EventHandler for TextBox { Action::Submit => self.submit(), Action::Cancel => { self.cancel_debounce(); - self.emit(TextBoxEvent::Cancel); + self.emitter.emit(TextBoxEvent::Cancel); } - Action::LeftClick => self.emit(TextBoxEvent::Focus), + Action::LeftClick => self.emitter.emit(TextBoxEvent::Focus), _ => propagate.set(), }) .any(|event| match event { @@ -407,11 +407,9 @@ impl PersistedContainer for TextBox { } } -impl Emitter for TextBox { - type Emitted = TextBoxEvent; - - fn id(&self) -> EmitterId { - self.emitter_id +impl ToEmitter for TextBox { + fn to_emitter(&self) -> Emitter { + self.emitter } } diff --git a/crates/tui/src/view/component/exchange_pane.rs b/crates/tui/src/view/component/exchange_pane.rs index 956ad956..72e07c7e 100644 --- a/crates/tui/src/view/component/exchange_pane.rs +++ b/crates/tui/src/view/component/exchange_pane.rs @@ -10,7 +10,7 @@ use crate::{ }, context::UpdateContext, draw::{Draw, DrawMetadata, Generate}, - event::{Child, Emitter, EmitterId, Event, EventHandler, OptionEvent}, + event::{Child, Emitter, Event, EventHandler, OptionEvent, ToEmitter}, util::persistence::PersistedLazy, RequestState, }, @@ -36,7 +36,7 @@ use strum::{EnumCount, EnumIter}; /// new request is selected. #[derive(Debug)] pub struct ExchangePane { - emitter_id: EmitterId, + emitter: Emitter, tabs: Component, Tabs>>, state: State, } @@ -47,7 +47,7 @@ impl ExchangePane { selected_recipe_kind: Option, ) -> Self { Self { - emitter_id: Default::default(), + emitter: Default::default(), tabs: Default::default(), state: State::new(selected_request, selected_recipe_kind), } @@ -57,7 +57,7 @@ impl ExchangePane { impl EventHandler for ExchangePane { fn update(&mut self, _: &mut UpdateContext, event: Event) -> Option { event.opt().action(|action, propagate| match action { - Action::LeftClick => self.emit(ExchangePaneEvent::Click), + Action::LeftClick => self.emitter.emit(ExchangePaneEvent::Click), _ => propagate.set(), }) } @@ -134,11 +134,9 @@ impl Draw for ExchangePane { } /// Notify parent when this pane is clicked -impl Emitter for ExchangePane { - type Emitted = ExchangePaneEvent; - - fn id(&self) -> EmitterId { - self.emitter_id +impl ToEmitter for ExchangePane { + fn to_emitter(&self) -> Emitter { + self.emitter } } diff --git a/crates/tui/src/view/component/history.rs b/crates/tui/src/view/component/history.rs index 7c28cf1a..429278b5 100644 --- a/crates/tui/src/view/component/history.rs +++ b/crates/tui/src/view/component/history.rs @@ -6,7 +6,7 @@ use crate::{ common::{list::List, modal::Modal}, component::Component, draw::{Draw, DrawMetadata, Generate}, - event::{Child, Emitter, Event, EventHandler, OptionEvent}, + event::{Child, Event, EventHandler, OptionEvent, ToEmitter}, state::select::{SelectState, SelectStateEvent, SelectStateEventType}, UpdateContext, ViewContext, }, @@ -73,7 +73,7 @@ impl Modal for History { impl EventHandler for History { fn update(&mut self, _: &mut UpdateContext, event: Event) -> Option { - event.opt().emitted(self.select.handle(), |event| { + event.opt().emitted(self.select.to_emitter(), |event| { if let SelectStateEvent::Select(index) = event { ViewContext::push_event(Event::HttpSelectRequest(Some( self.select.data()[index].id(), diff --git a/crates/tui/src/view/component/internal.rs b/crates/tui/src/view/component/internal.rs index 599b9aa5..7d3cca59 100644 --- a/crates/tui/src/view/component/internal.rs +++ b/crates/tui/src/view/component/internal.rs @@ -3,9 +3,10 @@ //! component state. use crate::view::{ + common::actions::MenuAction, context::UpdateContext, draw::{Draw, DrawMetadata}, - event::{Child, Emitter, EmitterId, Event, ToChild}, + event::{Child, Emitter, Event, LocalEvent, ToChild, ToEmitter}, }; use crossterm::event::MouseEvent; use derive_more::Display; @@ -75,6 +76,31 @@ impl Component { self.name } + /// Collect all available menu actions from all **focused** components. This + /// takes a mutable reference so we don't have to duplicate the code that + /// provides children; it will *not* mutate anything. + pub fn collect_actions(&mut self) -> Vec + where + T: ToChild, + { + fn inner( + actions: &mut Vec, + mut component: Component, + ) { + // Only include actions from visible+focused components + if component.is_visible() && component.has_focus() { + actions.extend(component.data().menu_actions()); + for child in component.data_mut().children() { + inner(actions, child) + } + } + } + + let mut actions = Vec::new(); + inner(&mut actions, self.to_child_mut()); + actions + } + /// Handle an event for this component *or* its children, starting at the /// lowest descendant. Recursively walk up the tree until a component /// consumes the event. @@ -137,7 +163,7 @@ impl Component { use crossterm::event::Event::*; if let Event::Input { event, .. } = event { match event { - Key(_) | Paste(_) => self.metadata.get().has_focus(), + Key(_) | Paste(_) => self.has_focus(), Mouse(mouse_event) => { // Check if the mouse is over the component @@ -160,6 +186,11 @@ impl Component { VISIBLE_COMPONENTS.with_borrow(|tree| tree.contains(&self.id)) } + /// Was this component in focus during the previous draw phase? + fn has_focus(&self) -> bool { + self.metadata.get().has_focus() + } + /// Did the given mouse event occur over/on this component? fn intersects(&self, mouse_event: &MouseEvent) -> bool { self.is_visible() @@ -267,16 +298,13 @@ impl From for Component { } } -impl Emitter for Component { - type Emitted = T::Emitted; - - fn id(&self) -> EmitterId { - self.data().id() - } - - fn type_name(&self) -> &'static str { - // Use the name of the contained emitter - self.name +impl ToEmitter for Component +where + E: LocalEvent, + T: ToEmitter, +{ + fn to_emitter(&self) -> Emitter { + self.data().to_emitter() } } diff --git a/crates/tui/src/view/component/misc.rs b/crates/tui/src/view/component/misc.rs index 6a1003a8..6e5e70a9 100644 --- a/crates/tui/src/view/component/misc.rs +++ b/crates/tui/src/view/component/misc.rs @@ -11,7 +11,7 @@ use crate::view::{ component::Component, context::UpdateContext, draw::{Draw, DrawMetadata, Generate}, - event::{Child, Emitter, Event, EventHandler, OptionEvent}, + event::{Child, Event, EventHandler, OptionEvent, ToEmitter}, state::{ select::{SelectState, SelectStateEvent, SelectStateEventType}, Notification, @@ -115,7 +115,7 @@ impl EventHandler for TextBoxModal { fn update(&mut self, _: &mut UpdateContext, event: Event) -> Option { event .opt() - .emitted(self.text_box.handle(), |event| match event { + .emitted(self.text_box.to_emitter(), |event| match event { TextBoxEvent::Focus | TextBoxEvent::Change => {} TextBoxEvent::Cancel => { // Propagate cancel to close the modal @@ -214,14 +214,19 @@ impl Modal for SelectListModal { // because the user selected a value (submitted). if submitted { // Return the user's value and close the prompt - (self.on_submit)(self.options.data().selected().unwrap().clone()); + (self.on_submit)( + self.options + .into_data() + .into_selected() + .expect("User submitted something"), + ); } } } impl EventHandler for SelectListModal { fn update(&mut self, _: &mut UpdateContext, event: Event) -> Option { - event.opt().emitted(self.options.handle(), |event| { + event.opt().emitted(self.options.to_emitter(), |event| { if let SelectStateEvent::Submit(_) = event { self.close(true); } @@ -318,7 +323,7 @@ impl Modal for ConfirmModal { impl EventHandler for ConfirmModal { fn update(&mut self, _: &mut UpdateContext, event: Event) -> Option { - event.opt().emitted(self.buttons.handle(), |button| { + event.opt().emitted(self.buttons.to_emitter(), |button| { // When user selects a button, send the response and close self.answer = button == ConfirmButton::Yes; self.close(true); diff --git a/crates/tui/src/view/component/primary.rs b/crates/tui/src/view/component/primary.rs index d7b0ebda..51beb89a 100644 --- a/crates/tui/src/view/component/primary.rs +++ b/crates/tui/src/view/component/primary.rs @@ -5,7 +5,10 @@ use crate::{ message::Message, util::ResultReported, view::{ - common::{actions::ActionsModal, modal::ModalHandle}, + common::{ + actions::{IntoMenuActions, MenuAction}, + modal::Modal, + }, component::{ exchange_pane::{ExchangePane, ExchangePaneEvent}, help::HelpModal, @@ -16,8 +19,8 @@ use crate::{ }, }, context::UpdateContext, - draw::{Draw, DrawMetadata, ToStringGenerate}, - event::{Child, Emitter, Event, EventHandler, OptionEvent}, + draw::{Draw, DrawMetadata}, + event::{Child, Emitter, Event, EventHandler, OptionEvent, ToEmitter}, state::{ fixed_select::FixedSelectState, select::{SelectStateEvent, SelectStateEventType}, @@ -56,56 +59,10 @@ pub struct PrimaryView { recipe_list_pane: Component, recipe_pane: Component, exchange_pane: Component, - actions_handle: ModalHandle>, -} -/// Selectable panes in the primary view mode -#[derive( - Copy, - Clone, - Debug, - Default, - Display, - EnumCount, - EnumIter, - PartialEq, - Serialize, - Deserialize, -)] -enum PrimaryPane { - #[default] - RecipeList, - Recipe, - Exchange, + global_actions_emitter: Emitter, } -/// Panes that can be fullscreened. This is separate from [PrimaryPane] because -/// it makes it easy to check when we should exit fullscreen mode. -#[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)] -enum FullscreenMode { - /// Fullscreen the active request recipe - Recipe, - /// Fullscreen the active request/response exchange - Exchange, -} - -/// Persistence key for fullscreen mode -#[derive(Debug, Default, persisted::PersistedKey, Serialize)] -#[persisted(Option)] -struct FullscreenModeKey; - -/// Action menu items. This is the fallback menu if none of our children have -/// one -#[derive( - Copy, Clone, Debug, Default, Display, EnumCount, EnumIter, PartialEq, -)] -enum MenuAction { - #[default] - #[display("Edit Collection")] - EditCollection, -} -impl ToStringGenerate for MenuAction {} - impl PrimaryView { pub fn new( collection: &Collection, @@ -133,7 +90,8 @@ impl PrimaryView { profile_pane: profile_pane.into(), recipe_pane: Default::default(), exchange_pane: exchange_pane.into(), - actions_handle: Default::default(), + + global_actions_emitter: Default::default(), } } @@ -283,9 +241,6 @@ impl PrimaryView { }; match action { - RecipeMenuAction::EditCollection => { - ViewContext::send_message(Message::CollectionEdit) - } RecipeMenuAction::CopyUrl => { ViewContext::send_message(Message::CopyRequestUrl(config)) } @@ -313,12 +268,7 @@ impl EventHandler for PrimaryView { Action::NextPane => self.selected_pane.get_mut().next(), // Send a request from anywhere Action::Submit => self.send_request(), - Action::OpenActions => { - self.actions_handle.open(ActionsModal::default()); - } - Action::OpenHelp => { - ViewContext::open_modal(HelpModal); - } + Action::OpenHelp => HelpModal.open(), // Pane hotkeys Action::SelectProfileList => { @@ -355,41 +305,48 @@ impl EventHandler for PrimaryView { } _ => propagate.set(), }) - .emitted(self.selected_pane.handle(), |event| { + .emitted(self.selected_pane.to_emitter(), |event| { if let SelectStateEvent::Select(_) = event { // Exit fullscreen when pane changes self.maybe_exit_fullscreen(); } }) - .emitted(self.recipe_list_pane.handle(), |event| match event { + .emitted(self.recipe_list_pane.to_emitter(), |event| match event { RecipeListPaneEvent::Click => { self.selected_pane .get_mut() .select(&PrimaryPane::RecipeList); } - RecipeListPaneEvent::MenuAction(action) => { - self.handle_recipe_menu_action(action); - } }) - .emitted(self.recipe_pane.handle(), |event| match event { + .emitted(self.recipe_pane.to_emitter(), |event| match event { RecipePaneEvent::Click => { self.selected_pane.get_mut().select(&PrimaryPane::Recipe); } - RecipePaneEvent::MenuAction(action) => { - self.handle_recipe_menu_action(action); - } }) - .emitted(self.exchange_pane.handle(), |event| match event { + .emitted(self.exchange_pane.to_emitter(), |event| match event { ExchangePaneEvent::Click => { self.selected_pane.get_mut().select(&PrimaryPane::Exchange) } }) - .emitted(self.actions_handle, |menu_action| match menu_action { + .emitted(self.global_actions_emitter, |menu_action| { // Handle our own menu action type - MenuAction::EditCollection => { - ViewContext::send_message(Message::CollectionEdit) + match menu_action { + GlobalMenuAction::EditCollection => { + ViewContext::send_message(Message::CollectionEdit) + } } }) + // Handle all recipe actions here, for deduplication + .emitted(self.recipe_list_pane.to_emitter(), |menu_action| { + self.handle_recipe_menu_action(menu_action) + }) + .emitted(self.recipe_pane.to_emitter(), |menu_action| { + self.handle_recipe_menu_action(menu_action) + }) + } + + fn menu_actions(&self) -> Vec { + GlobalMenuAction::into_actions(self) } fn children(&mut self) -> Vec>> { @@ -452,6 +409,62 @@ impl Draw for PrimaryView { } } +impl ToEmitter for PrimaryView { + fn to_emitter(&self) -> Emitter { + self.global_actions_emitter + } +} + +/// Selectable panes in the primary view mode +#[derive( + Copy, + Clone, + Debug, + Default, + Display, + EnumCount, + EnumIter, + PartialEq, + Serialize, + Deserialize, +)] +enum PrimaryPane { + #[default] + RecipeList, + Recipe, + Exchange, +} + +/// Panes that can be fullscreened. This is separate from [PrimaryPane] because +/// it makes it easy to check when we should exit fullscreen mode. +#[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)] +enum FullscreenMode { + /// Fullscreen the active request recipe + Recipe, + /// Fullscreen the active request/response exchange + Exchange, +} + +/// Persistence key for fullscreen mode +#[derive(Debug, Default, persisted::PersistedKey, Serialize)] +#[persisted(Option)] +struct FullscreenModeKey; + +/// Menu actions available in all contexts +#[derive(Copy, Clone, Debug, Display, EnumIter)] +enum GlobalMenuAction { + #[display("Edit Collection")] + EditCollection, +} + +impl IntoMenuActions for GlobalMenuAction { + fn enabled(&self, _: &PrimaryView) -> bool { + match self { + Self::EditCollection => true, + } + } +} + /// Helper for adjusting pane behavior according to state struct Panes { profile: PaneState, @@ -522,6 +535,22 @@ mod tests { ); } + /// Test "Edit Collection" action + #[rstest] + fn test_edit_collection(mut harness: TestHarness, terminal: TestTerminal) { + let mut component = create_component(&mut harness, &terminal); + component.drain_draw().assert_empty(); + + harness.clear_messages(); // Clear init junk + + // Open action menu + component.send_key(KeyCode::Char('x')).assert_empty(); + // Select first action - Edit Collection + component.send_key(KeyCode::Enter).assert_empty(); + // Event should be converted into a message appropriately + assert_matches!(harness.pop_message_now(), Message::CollectionEdit); + } + /// Test "Copy URL" action, which is available via the Recipe List or Recipe /// panes #[rstest] diff --git a/crates/tui/src/view/component/profile_select.rs b/crates/tui/src/view/component/profile_select.rs index e19e12b5..ae8a1fff 100644 --- a/crates/tui/src/view/component/profile_select.rs +++ b/crates/tui/src/view/component/profile_select.rs @@ -13,7 +13,7 @@ use crate::{ }, context::UpdateContext, draw::{Draw, DrawMetadata, Generate}, - event::{Child, Emitter, EmitterId, Event, EventHandler, OptionEvent}, + event::{Child, Emitter, Event, EventHandler, OptionEvent, ToEmitter}, state::{ select::{SelectState, SelectStateEvent, SelectStateEventType}, StateCell, @@ -48,7 +48,7 @@ pub struct ProfilePane { /// actually selecting it. selected_profile_id: Persisted, /// Handle events from the opened modal - modal_handle: ModalHandle, + modal_handle: ModalHandle, } /// Persisted key for the ID of the selected profile @@ -104,12 +104,16 @@ impl EventHandler for ProfilePane { Action::LeftClick => self.open_modal(), _ => propagate.set(), }) - .emitted(self.modal_handle, |SelectProfile(profile_id)| { - // Handle message from the modal - *self.selected_profile_id.get_mut() = Some(profile_id.clone()); - // Refresh template previews - ViewContext::push_event(Event::HttpSelectRequest(None)); - }) + .emitted( + self.modal_handle.to_emitter(), + |SelectProfile(profile_id)| { + // Handle message from the modal + *self.selected_profile_id.get_mut() = + Some(profile_id.clone()); + // Refresh template previews + ViewContext::push_event(Event::HttpSelectRequest(None)); + }, + ) } } @@ -146,7 +150,7 @@ impl Draw for ProfilePane { /// fields #[derive(Debug)] struct ProfileListModal { - emitter_id: EmitterId, + emitter: Emitter, select: Component>, detail: Component, } @@ -164,7 +168,7 @@ impl ProfileListModal { .subscribe([SelectStateEventType::Submit]) .build(); Self { - emitter_id: EmitterId::new(), + emitter: Default::default(), select: select.into(), detail: Default::default(), } @@ -183,13 +187,13 @@ impl Modal for ProfileListModal { impl EventHandler for ProfileListModal { fn update(&mut self, _: &mut UpdateContext, event: Event) -> Option { - event.opt().emitted(self.select.handle(), |event| { + event.opt().emitted(self.select.to_emitter(), |event| { // Loaded request depends on the profile, so refresh on change if let SelectStateEvent::Submit(index) = event { // Close modal first so the parent can consume the emitted event self.close(true); let profile_id = self.select.data()[index].id.clone(); - self.emit(SelectProfile(profile_id)); + self.emitter.emit(SelectProfile(profile_id)); } }) } @@ -235,11 +239,9 @@ impl Draw for ProfileListModal { } } -impl Emitter for ProfileListModal { - type Emitted = SelectProfile; - - fn id(&self) -> EmitterId { - self.emitter_id +impl ToEmitter for ProfileListModal { + fn to_emitter(&self) -> Emitter { + self.emitter } } diff --git a/crates/tui/src/view/component/queryable_body.rs b/crates/tui/src/view/component/queryable_body.rs index 3f4e7357..1f91494e 100644 --- a/crates/tui/src/view/component/queryable_body.rs +++ b/crates/tui/src/view/component/queryable_body.rs @@ -5,15 +5,16 @@ use crate::{ util::{run_command, spawn_local}, view::{ common::{ + modal::Modal, text_box::{TextBox, TextBoxEvent, TextBoxProps}, text_window::{ScrollbarMargins, TextWindow, TextWindowProps}, }, context::UpdateContext, draw::{Draw, DrawMetadata, Generate}, - event::{Child, Emitter, EmitterId, Event, EventHandler, OptionEvent}, + event::{Child, Emitter, Event, EventHandler, OptionEvent, ToEmitter}, state::Identified, util::{highlight, str_to_text}, - Component, ViewContext, + Component, IntoModal, ViewContext, }, }; use anyhow::Context; @@ -36,7 +37,7 @@ use tokio::task::AbortHandle; /// The query state can be persisted by persisting this entire container. #[derive(Debug)] pub struct QueryableBody { - emitter_id: EmitterId, + emitter: Emitter, response: Arc, /// Which command box, if any, are we typing in? @@ -92,7 +93,7 @@ impl QueryableBody { TextState::new(response.content_type(), &response.body, true); let mut slf = Self { - emitter_id: EmitterId::new(), + emitter: Default::default(), response, command_focus: CommandFocus::None, default_query, @@ -163,7 +164,7 @@ impl QueryableBody { // Clone is cheap because Bytes uses refcounting let body = self.response.body.bytes().clone(); let command = command.to_owned(); - let emitter = self.handle(); + let emitter = self.emitter; let abort_handle = self.spawn_command(command, body, move |_, result| { emitter.emit(QueryComplete(result)) @@ -195,7 +196,7 @@ impl QueryableBody { // We provide feedback via a global mechanism in both cases, so we // don't need an emitter here Ok(_) => ViewContext::notify(format!("`{command}` succeeded")), - Err(error) => ViewContext::open_modal(error), + Err(error) => error.into_modal().open(), }); } @@ -226,7 +227,7 @@ impl EventHandler for QueryableBody { Action::Export => self.focus(CommandFocus::Export), _ => propagate.set(), }) - .emitted(self.handle(), |QueryComplete(result)| match result { + .emitted(self.emitter, |QueryComplete(result)| match result { Ok(stdout) => { self.query_state = QueryState::Ok; self.text_state = TextState::new( @@ -253,7 +254,7 @@ impl EventHandler for QueryableBody { )); } }) - .emitted(self.query_text_box.handle(), |event| match event { + .emitted(self.query_text_box.to_emitter(), |event| match event { TextBoxEvent::Focus => self.focus(CommandFocus::Query), TextBoxEvent::Change => self.update_query(), TextBoxEvent::Cancel => { @@ -268,7 +269,7 @@ impl EventHandler for QueryableBody { self.focus(CommandFocus::None); } }) - .emitted(self.export_text_box.handle(), |event| match event { + .emitted(self.export_text_box.to_emitter(), |event| match event { TextBoxEvent::Focus => self.focus(CommandFocus::Export), TextBoxEvent::Change => {} TextBoxEvent::Cancel => { @@ -364,11 +365,9 @@ impl PersistedContainer for QueryableBody { } } -impl Emitter for QueryableBody { - type Emitted = QueryComplete; - - fn id(&self) -> EmitterId { - self.emitter_id +impl ToEmitter for QueryableBody { + fn to_emitter(&self) -> Emitter { + self.emitter } } diff --git a/crates/tui/src/view/component/recipe_list.rs b/crates/tui/src/view/component/recipe_list.rs index babbefc2..344c67ce 100644 --- a/crates/tui/src/view/component/recipe_list.rs +++ b/crates/tui/src/view/component/recipe_list.rs @@ -2,16 +2,15 @@ use crate::{ context::TuiContext, view::{ common::{ - actions::ActionsModal, + actions::{IntoMenuActions, MenuAction}, list::List, - modal::ModalHandle, text_box::{TextBox, TextBoxEvent, TextBoxProps}, Pane, }, component::recipe_pane::RecipeMenuAction, context::UpdateContext, draw::{Draw, DrawMetadata, Generate}, - event::{Child, Emitter, EmitterId, Event, EventHandler, OptionEvent}, + event::{Child, Emitter, Event, EventHandler, OptionEvent, ToEmitter}, state::select::{SelectState, SelectStateEvent, SelectStateEventType}, util::persistence::{Persisted, PersistedLazy}, Component, ViewContext, @@ -43,7 +42,10 @@ use std::collections::HashSet; /// implementation. #[derive(Debug)] pub struct RecipeListPane { - emitter_id: EmitterId, + /// Emitter for the on-click event, to focus the pane + click_emitter: Emitter, + /// Emitter for menu actions, to be handled by our parent + actions_emitter: Emitter, /// The visible list of items is tracked using normal list state, so we can /// easily re-use existing logic. We'll rebuild this any time a folder is /// expanded/collapsed (i.e whenever the list of items changes) @@ -61,15 +63,8 @@ pub struct RecipeListPane { filter: Component, filter_focused: bool, - - actions_handle: ModalHandle>, } -/// Persisted key for the ID of the selected recipe -#[derive(Debug, Serialize, PersistedKey)] -#[persisted(Option)] -struct SelectedRecipeKey; - impl RecipeListPane { pub fn new(recipes: &RecipeTree) -> Self { let input_engine = &TuiContext::get().input_engine; @@ -86,12 +81,12 @@ impl RecipeListPane { let filter = TextBox::default().placeholder(format!("{binding} to filter")); Self { - emitter_id: EmitterId::new(), + click_emitter: Default::default(), + actions_emitter: Default::default(), select: select.into(), collapsed, filter: filter.into(), filter_focused: false, - actions_handle: ModalHandle::default(), } } @@ -162,7 +157,9 @@ impl EventHandler for RecipeListPane { event .opt() .action(|action, propagate| match action { - Action::LeftClick => self.emit(RecipeListPaneEvent::Click), + Action::LeftClick => { + self.click_emitter.emit(RecipeListPaneEvent::Click) + } Action::Left => { self.set_selected_collapsed(CollapseState::Collapse); } @@ -172,31 +169,9 @@ impl EventHandler for RecipeListPane { Action::Search => { self.filter_focused = true; } - Action::OpenActions => { - let recipe = self - .select - .data() - .selected() - .filter(|node| node.is_recipe()); - let has_body = recipe - .map(|recipe| { - ViewContext::collection() - .recipes - .get_recipe(&recipe.id) - .and_then(|recipe| recipe.body.as_ref()) - .is_some() - }) - .unwrap_or(false); - self.actions_handle.open(ActionsModal::new( - RecipeMenuAction::disabled_actions( - recipe.is_some(), - has_body, - ), - )); - } _ => propagate.set(), }) - .emitted(self.select.handle(), |event| match event { + .emitted(self.select.to_emitter(), |event| match event { SelectStateEvent::Select(_) => { // When highlighting a new recipe, load its most recent // request from the DB. If a recipe isn't selected, this @@ -208,17 +183,17 @@ impl EventHandler for RecipeListPane { self.set_selected_collapsed(CollapseState::Toggle); } }) - .emitted(self.filter.handle(), |event| match event { + .emitted(self.filter.to_emitter(), |event| match event { TextBoxEvent::Focus => self.filter_focused = true, TextBoxEvent::Change => self.rebuild_select_state(), TextBoxEvent::Cancel | TextBoxEvent::Submit => { self.filter_focused = false } }) - .emitted(self.actions_handle, |menu_action| { - // Menu actions are handled by the parent, so forward them - self.emit(RecipeListPaneEvent::MenuAction(menu_action)) - }) + } + + fn menu_actions(&self) -> Vec { + RecipeMenuAction::into_actions(self) } fn children(&mut self) -> Vec>> { @@ -264,19 +239,51 @@ impl Draw for RecipeListPane { } /// Notify parent when this pane is clicked -impl Emitter for RecipeListPane { - type Emitted = RecipeListPaneEvent; +impl ToEmitter for RecipeListPane { + fn to_emitter(&self) -> Emitter { + self.click_emitter + } +} - fn id(&self) -> EmitterId { - self.emitter_id +/// Notify parent when one of this pane's actions is selected +impl ToEmitter for RecipeListPane { + fn to_emitter(&self) -> Emitter { + self.actions_emitter } } +impl IntoMenuActions for RecipeMenuAction { + fn enabled(&self, data: &RecipeListPane) -> bool { + let recipe = data + .select + .data() + .selected() + .filter(|node| node.is_recipe()); + match self { + Self::CopyUrl | Self::CopyCurl => recipe.is_some(), + // Check if the recipe has a body + Self::ViewBody | Self::CopyBody => recipe + .map(|recipe| { + ViewContext::collection() + .recipes + .get_recipe(&recipe.id) + .and_then(|recipe| recipe.body.as_ref()) + .is_some() + }) + .unwrap_or(false), + } + } +} + +/// Persisted key for the ID of the selected recipe +#[derive(Debug, Serialize, PersistedKey)] +#[persisted(Option)] +struct SelectedRecipeKey; + /// Emitted event type for the recipe list pane #[derive(Debug)] pub enum RecipeListPaneEvent { Click, - MenuAction(RecipeMenuAction), } /// Simplified version of [RecipeNode], to be used in the display tree. This diff --git a/crates/tui/src/view/component/recipe_pane.rs b/crates/tui/src/view/component/recipe_pane.rs index f9468b13..72a64b91 100644 --- a/crates/tui/src/view/component/recipe_pane.rs +++ b/crates/tui/src/view/component/recipe_pane.rs @@ -10,11 +10,14 @@ use crate::{ context::TuiContext, message::RequestConfig, view::{ - common::{actions::ActionsModal, modal::ModalHandle, Pane}, + common::{ + actions::{IntoMenuActions, MenuAction}, + Pane, + }, component::recipe_pane::recipe::RecipeDisplay, context::UpdateContext, - draw::{Draw, DrawMetadata, Generate, ToStringGenerate}, - event::{Child, Emitter, EmitterId, Event, EventHandler, OptionEvent}, + draw::{Draw, DrawMetadata, Generate}, + event::{Child, Emitter, Event, EventHandler, OptionEvent, ToEmitter}, state::StateCell, Component, ViewContext, }, @@ -33,17 +36,19 @@ use slumber_core::{ util::doc_link, }; use std::cell::Ref; -use strum::{EnumCount, EnumIter}; +use strum::EnumIter; /// Display for the current recipe node, which could be a recipe, a folder, or /// empty #[derive(Debug, Default)] pub struct RecipePane { - emitter_id: EmitterId, + /// Emitter for the on-click event, to focus the pane + click_emitter: Emitter, + /// Emitter for menu actions, to be handled by our parent + actions_emitter: Emitter, /// All UI state derived from the recipe is stored together, and reset when /// the recipe or profile changes recipe_state: StateCell>>, - actions_handle: ModalHandle>, } #[derive(Clone)] @@ -110,27 +115,16 @@ impl RecipePane { impl EventHandler for RecipePane { fn update(&mut self, _: &mut UpdateContext, event: Event) -> Option { - event - .opt() - .action(|action, propagate| match action { - Action::LeftClick => self.emit(RecipePaneEvent::Click), - Action::OpenActions => { - let state = self.recipe_state.get_mut(); - self.actions_handle.open(ActionsModal::new( - RecipeMenuAction::disabled_actions( - state.is_some(), - state - .and_then(|state| state.data().as_ref()) - .is_some_and(|state| state.has_body()), - ), - )) - } - _ => propagate.set(), - }) - .emitted(self.actions_handle, |menu_action| { - // Menu actions are handled by the parent, so forward them - self.emit(RecipePaneEvent::MenuAction(menu_action)); - }) + event.opt().action(|action, propagate| match action { + Action::LeftClick => { + self.click_emitter.emit(RecipePaneEvent::Click) + } + _ => propagate.set(), + }) + } + + fn menu_actions(&self) -> Vec { + RecipeMenuAction::into_actions(self) } fn children(&mut self) -> Vec>> { @@ -207,11 +201,17 @@ impl<'a> Draw> for RecipePane { } } -impl Emitter for RecipePane { - type Emitted = RecipePaneEvent; +/// Notify parent when this pane is clicked +impl ToEmitter for RecipePane { + fn to_emitter(&self) -> Emitter { + self.click_emitter + } +} - fn id(&self) -> EmitterId { - self.emitter_id +/// Notify parent when one of this pane's actions is selected +impl ToEmitter for RecipePane { + fn to_emitter(&self) -> Emitter { + self.actions_emitter } } @@ -219,7 +219,6 @@ impl Emitter for RecipePane { #[derive(Debug)] pub enum RecipePaneEvent { Click, - MenuAction(RecipeMenuAction), } /// Template preview state will be recalculated when any of these fields change @@ -231,13 +230,8 @@ struct RecipeStateKey { /// Items in the actions popup menu. This is also used by the recipe list /// component, so the action is handled in the parent. -#[derive( - Copy, Clone, Debug, Default, Display, EnumCount, EnumIter, PartialEq, -)] +#[derive(Copy, Clone, Debug, Display, EnumIter)] pub enum RecipeMenuAction { - #[default] - #[display("Edit Collection")] - EditCollection, #[display("Copy URL")] CopyUrl, #[display("Copy as cURL")] @@ -248,25 +242,22 @@ pub enum RecipeMenuAction { CopyBody, } -impl RecipeMenuAction { - pub fn disabled_actions( - has_recipe: bool, - has_body: bool, - ) -> &'static [Self] { - if has_recipe { - if has_body { - &[] - } else { - &[Self::CopyBody, Self::ViewBody] +impl IntoMenuActions for RecipeMenuAction { + fn enabled(&self, data: &RecipePane) -> bool { + let recipe = data.recipe_state.get().and_then(|state| { + Ref::filter_map(state, |state| state.data().as_ref()).ok() + }); + match self { + // Enabled if we have any recipe + Self::CopyUrl | Self::CopyCurl => recipe.is_some(), + // Enabled if we have a body + Self::ViewBody | Self::CopyBody => { + recipe.is_some_and(|recipe| recipe.has_body()) } - } else { - &[Self::CopyUrl, Self::CopyBody, Self::CopyCurl] } } } -impl ToStringGenerate for RecipeMenuAction {} - /// Render folder as a tree impl<'a> Generate for &'a Folder { type Output<'this> = Text<'this> diff --git a/crates/tui/src/view/component/recipe_pane/authentication.rs b/crates/tui/src/view/component/recipe_pane/authentication.rs index 85c1f8ac..b3676593 100644 --- a/crates/tui/src/view/component/recipe_pane/authentication.rs +++ b/crates/tui/src/view/component/recipe_pane/authentication.rs @@ -2,7 +2,7 @@ use crate::{ context::TuiContext, util::ResultReported, view::{ - common::{table::Table, text_box::TextBox}, + common::{modal::Modal, table::Table, text_box::TextBox}, component::{ misc::TextBoxModal, recipe_pane::persistence::{RecipeOverrideKey, RecipeTemplate}, @@ -10,10 +10,7 @@ use crate::{ }, context::UpdateContext, draw::{Draw, DrawMetadata, Generate}, - event::{ - Child, Emitter, EmitterHandle, EmitterId, Event, EventHandler, - OptionEvent, - }, + event::{Child, Emitter, Event, EventHandler, OptionEvent, ToEmitter}, state::fixed_select::FixedSelectState, ViewContext, }, @@ -36,7 +33,7 @@ use strum::{EnumCount, EnumIter}; /// Display authentication settings for a recipe #[derive(Debug)] pub struct AuthenticationDisplay { - emitter_id: EmitterId, + emitter: Emitter, state: State, } @@ -70,7 +67,7 @@ impl AuthenticationDisplay { }, }; Self { - emitter_id: EmitterId::new(), + emitter: Default::default(), state, } } @@ -102,12 +99,12 @@ impl EventHandler for AuthenticationDisplay { event .opt() .action(|action, propagate| match action { - Action::Edit => self.state.open_edit_modal(self.handle()), + Action::Edit => self.state.open_edit_modal(self.emitter), Action::Reset => self.state.reset_override(), _ => propagate.set(), }) - .emitted(self.handle(), |SaveAuthenticationOverride(value)| { + .emitted(self.emitter, |SaveAuthenticationOverride(value)| { self.state.set_override(&value) }) } @@ -170,11 +167,9 @@ impl Draw for AuthenticationDisplay { } /// Emit events to ourselves for override editing -impl Emitter for AuthenticationDisplay { - type Emitted = SaveAuthenticationOverride; - - fn id(&self) -> EmitterId { - self.emitter_id +impl ToEmitter for AuthenticationDisplay { + fn to_emitter(&self) -> Emitter { + self.emitter } } @@ -214,10 +209,7 @@ impl State { } /// Open a modal to let the user edit temporary override values - fn open_edit_modal( - &self, - emitter: EmitterHandle, - ) { + fn open_edit_modal(&self, emitter: Emitter) { let (label, value) = match &self { Self::Basic { username, @@ -236,7 +228,7 @@ impl State { ("bearer token", token.template().display()) } }; - ViewContext::open_modal(TextBoxModal::new( + TextBoxModal::new( format!("Edit {label}"), TextBox::default() .default_value(value.into_owned()) @@ -245,7 +237,8 @@ impl State { // Defer the state update into an event, so it can get &mut emitter.emit(SaveAuthenticationOverride(value)) }, - )) + ) + .open() } /// Override the value template for whichever field is selected, and diff --git a/crates/tui/src/view/component/recipe_pane/body.rs b/crates/tui/src/view/component/recipe_pane/body.rs index eb94c6e7..1a65c8d0 100644 --- a/crates/tui/src/view/component/recipe_pane/body.rs +++ b/crates/tui/src/view/component/recipe_pane/body.rs @@ -10,7 +10,7 @@ use crate::{ }, context::UpdateContext, draw::{Draw, DrawMetadata}, - event::{Child, Emitter, EmitterId, Event, EventHandler, OptionEvent}, + event::{Child, Emitter, Event, EventHandler, OptionEvent, ToEmitter}, state::Identified, Component, ViewContext, }, @@ -127,7 +127,7 @@ impl Draw for RecipeBodyDisplay { #[derive(Debug)] pub struct RawBody { - emitter_id: EmitterId, + emitter: Emitter, body: RecipeTemplate, text_window: Component, } @@ -139,7 +139,7 @@ impl RawBody { content_type: Option, ) -> Self { Self { - emitter_id: EmitterId::new(), + emitter: Default::default(), body: RecipeTemplate::new( RecipeOverrideKey::body(recipe_id), template, @@ -166,7 +166,7 @@ impl RawBody { return; }; - let emitter = self.handle(); + let emitter = self.emitter; ViewContext::send_message(Message::FileEdit { path, on_complete: Box::new(move |path| { @@ -217,7 +217,7 @@ impl EventHandler for RawBody { Action::Reset => self.body.reset_override(), _ => propagate.set(), }) - .emitted(self.handle(), |SaveBodyOverride(path)| { + .emitted(self.emitter, |SaveBodyOverride(path)| { self.load_override(&path) }) } @@ -254,11 +254,9 @@ impl Draw for RawBody { } /// Emit events to ourselves for override editing -impl Emitter for RawBody { - type Emitted = SaveBodyOverride; - - fn id(&self) -> EmitterId { - self.emitter_id +impl ToEmitter for RawBody { + fn to_emitter(&self) -> Emitter { + self.emitter } } diff --git a/crates/tui/src/view/component/recipe_pane/recipe.rs b/crates/tui/src/view/component/recipe_pane/recipe.rs index aa29482b..46f1454b 100644 --- a/crates/tui/src/view/component/recipe_pane/recipe.rs +++ b/crates/tui/src/view/component/recipe_pane/recipe.rs @@ -34,7 +34,7 @@ use slumber_core::{ use std::ops::Deref; use strum::{EnumCount, EnumIter}; -/// Display a recipe. Note a recipe *node*, this is for genuine bonafide recipe. +/// Display a recipe. Not a recipe *node*, this is for genuine bonafide recipe. /// This maintains internal state specific to a recipe, so it should be /// recreated every time the recipe/profile changes. #[derive(Debug)] diff --git a/crates/tui/src/view/component/recipe_pane/table.rs b/crates/tui/src/view/component/recipe_pane/table.rs index 56dc4716..dfb22e5f 100644 --- a/crates/tui/src/view/component/recipe_pane/table.rs +++ b/crates/tui/src/view/component/recipe_pane/table.rs @@ -3,6 +3,7 @@ use crate::{ util::ResultReported, view::{ common::{ + modal::Modal, table::{Table, ToggleRow}, text_box::TextBox, }, @@ -13,10 +14,7 @@ use crate::{ }, context::UpdateContext, draw::{Draw, DrawMetadata, Generate}, - event::{ - Child, Emitter, EmitterHandle, EmitterId, Event, EventHandler, - OptionEvent, - }, + event::{Child, Emitter, Event, EventHandler, OptionEvent, ToEmitter}, state::select::{SelectState, SelectStateEvent, SelectStateEventType}, util::persistence::{Persisted, PersistedKey, PersistedLazy}, ViewContext, @@ -50,7 +48,7 @@ where RowSelectKey: PersistedKey>, RowToggleKey: PersistedKey, { - emitter_id: EmitterId, + emitter: Emitter, select: Component< PersistedLazy< RowSelectKey, @@ -88,7 +86,7 @@ where .subscribe([SelectStateEventType::Toggle]) .build(); Self { - emitter_id: EmitterId::new(), + emitter: Default::default(), select: PersistedLazy::new(select_key, select).into(), } } @@ -126,7 +124,7 @@ where // Consume the event even if we have no rows, for // consistency if let Some(selected_row) = self.select.data().selected() { - selected_row.open_edit_modal(self.handle()); + selected_row.open_edit_modal(self.emitter); } } Action::Reset => { @@ -138,13 +136,13 @@ where } _ => propagate.set(), }) - .emitted(self.select.handle(), |event| { + .emitted(self.select.to_emitter(), |event| { if let SelectStateEvent::Toggle(index) = event { self.select.data_mut().get_mut()[index].toggle(); } }) .emitted( - self.handle(), + self.emitter, |SaveRecipeTableOverride { row_index, value }| { // The row we're modifying *should* still be the selected // row, because it shouldn't be possible to change the @@ -196,16 +194,14 @@ where } /// Emit events to ourselves for override editing -impl Emitter +impl ToEmitter for RecipeFieldTable where RowSelectKey: PersistedKey>, RowToggleKey: PersistedKey, { - type Emitted = SaveRecipeTableOverride; - - fn id(&self) -> EmitterId { - self.emitter_id + fn to_emitter(&self) -> Emitter { + self.emitter } } @@ -274,9 +270,9 @@ impl> RowState { } /// Open a modal to create or edit the value's temporary override - fn open_edit_modal(&self, emitter: EmitterHandle) { + fn open_edit_modal(&self, emitter: Emitter) { let index = self.index; - ViewContext::open_modal(TextBoxModal::new( + TextBoxModal::new( format!("Edit value for {}", self.key), TextBox::default() // Edit as a raw template @@ -289,7 +285,8 @@ impl> RowState { value, }); }, - )); + ) + .open(); } /// Override the value template and re-render the preview diff --git a/crates/tui/src/view/component/request_view.rs b/crates/tui/src/view/component/request_view.rs index 604228c2..3f7b7cfb 100644 --- a/crates/tui/src/view/component/request_view.rs +++ b/crates/tui/src/view/component/request_view.rs @@ -3,14 +3,13 @@ use crate::{ message::Message, view::{ common::{ - actions::ActionsModal, + actions::{IntoMenuActions, MenuAction}, header_table::HeaderTable, - modal::ModalHandle, text_window::{TextWindow, TextWindowProps}, }, context::UpdateContext, - draw::{Draw, DrawMetadata, Generate, ToStringGenerate}, - event::{Child, Event, EventHandler, OptionEvent}, + draw::{Draw, DrawMetadata, Generate}, + event::{Child, Emitter, Event, EventHandler, OptionEvent, ToEmitter}, state::Identified, util::{highlight, view_text}, Component, ViewContext, @@ -18,24 +17,23 @@ use crate::{ }; use derive_more::Display; use ratatui::{layout::Layout, prelude::Constraint, text::Text, Frame}; -use slumber_config::Action; use slumber_core::{ http::{content_type::ContentType, RequestRecord}, util::{format_byte_size, MaybeStr}, }; use std::sync::Arc; -use strum::{EnumCount, EnumIter}; +use strum::EnumIter; /// Display rendered HTTP request state. The request could still be in flight, /// it just needs to have been built successfully. #[derive(Debug)] pub struct RequestView { + actions_emitter: Emitter, /// Store pointer to the request, so we can access it in the update step request: Arc, /// Persist the visible body, because it may vary from the actual body. /// `None` iff the request has no body body: Option>>, - actions_handle: ModalHandle>, body_text_window: Component, } @@ -43,56 +41,22 @@ impl RequestView { pub fn new(request: Arc) -> Self { let body = init_body(&request); Self { + actions_emitter: Default::default(), request, body, - actions_handle: Default::default(), body_text_window: Default::default(), } } } -/// Items in the actions popup menu -#[derive( - Copy, Clone, Debug, Default, Display, EnumCount, EnumIter, PartialEq, -)] -enum MenuAction { - #[default] - #[display("Edit Collection")] - EditCollection, - #[display("Copy URL")] - CopyUrl, - #[display("Copy Body")] - CopyBody, - #[display("View Body")] - ViewBody, -} - -impl ToStringGenerate for MenuAction {} - impl EventHandler for RequestView { fn update(&mut self, _: &mut UpdateContext, event: Event) -> Option { - event - .opt() - .action(|action, propagate| match action { - Action::OpenActions => { - let disabled = if self.body.is_some() { - [].as_slice() - } else { - // No body available - disable these actions - &[MenuAction::CopyBody, MenuAction::ViewBody] - }; - self.actions_handle.open(ActionsModal::new(disabled)); - } - _ => propagate.set(), - }) - .emitted(self.actions_handle, |menu_action| match menu_action { - MenuAction::EditCollection => { - ViewContext::send_message(Message::CollectionEdit) - } - MenuAction::CopyUrl => ViewContext::send_message( + event.opt().emitted(self.actions_emitter, |menu_action| { + match menu_action { + RequestMenuAction::CopyUrl => ViewContext::send_message( Message::CopyText(self.request.url.to_string()), ), - MenuAction::CopyBody => { + RequestMenuAction::CopyBody => { // Copy exactly what the user sees. Currently requests // don't support formatting/querying but that could change if let Some(body) = &self.body { @@ -101,12 +65,17 @@ impl EventHandler for RequestView { )); } } - MenuAction::ViewBody => { + RequestMenuAction::ViewBody => { if let Some(body) = &self.body { view_text(body, self.request.mime()); } } - }) + } + }) + } + + fn menu_actions(&self) -> Vec { + RequestMenuAction::into_actions(self) } fn children(&mut self) -> Vec>> { @@ -155,6 +124,32 @@ impl Draw for RequestView { } } +impl ToEmitter for RequestView { + fn to_emitter(&self) -> Emitter { + self.actions_emitter + } +} + +/// Items in the actions popup menu +#[derive(Copy, Clone, Debug, Display, EnumIter)] +pub enum RequestMenuAction { + #[display("Copy URL")] + CopyUrl, + #[display("Copy Body")] + CopyBody, + #[display("View Body")] + ViewBody, +} + +impl IntoMenuActions for RequestMenuAction { + fn enabled(&self, data: &RequestView) -> bool { + match self { + Self::CopyUrl => true, + Self::CopyBody | Self::ViewBody => data.body.is_some(), + } + } +} + /// Calculate body text, including syntax highlighting. We have to clone the /// body to prevent a self-reference fn init_body(request: &RequestRecord) -> Option>> { diff --git a/crates/tui/src/view/component/response_view.rs b/crates/tui/src/view/component/response_view.rs index beccfa14..c1d67b53 100644 --- a/crates/tui/src/view/component/response_view.rs +++ b/crates/tui/src/view/component/response_view.rs @@ -5,13 +5,13 @@ use crate::{ message::Message, view::{ common::{ - actions::ActionsModal, header_table::HeaderTable, - modal::ModalHandle, + actions::{IntoMenuActions, MenuAction}, + header_table::HeaderTable, }, component::queryable_body::QueryableBody, context::UpdateContext, - draw::{Draw, DrawMetadata, Generate, ToStringGenerate}, - event::{Child, Event, EventHandler, OptionEvent}, + draw::{Draw, DrawMetadata, Generate}, + event::{Child, Emitter, Event, EventHandler, OptionEvent, ToEmitter}, util::{persistence::PersistedLazy, view_text}, Component, ViewContext, }, @@ -20,20 +20,19 @@ use derive_more::Display; use persisted::PersistedKey; use ratatui::Frame; use serde::Serialize; -use slumber_config::Action; use slumber_core::{collection::RecipeId, http::ResponseRecord}; use std::sync::Arc; -use strum::{EnumCount, EnumIter}; +use strum::EnumIter; /// Display response body #[derive(Debug)] pub struct ResponseBodyView { + actions_emitter: Emitter, response: Arc, /// The presentable version of the response body, which may or may not /// match the response body. We apply transformations such as filter, /// prettification, or in the case of binary responses, a hex dump. body: Component>, - actions_handle: ModalHandle>, } impl ResponseBodyView { @@ -49,21 +48,17 @@ impl ResponseBodyView { ) .into(); Self { + actions_emitter: Default::default(), response, body, - actions_handle: Default::default(), } } } /// Items in the actions popup menu for the Body -#[derive( - Copy, Clone, Debug, Default, Display, EnumCount, EnumIter, PartialEq, -)] -enum BodyMenuAction { - #[default] - #[display("Edit Collection")] - EditCollection, +#[derive(Copy, Clone, Debug, Display, EnumIter)] +#[allow(clippy::enum_variant_names)] +enum ResponseBodyMenuAction { #[display("View Body")] ViewBody, #[display("Copy Body")] @@ -72,7 +67,13 @@ enum BodyMenuAction { SaveBody, } -impl ToStringGenerate for BodyMenuAction {} +impl IntoMenuActions for ResponseBodyMenuAction { + fn enabled(&self, _: &ResponseBodyView) -> bool { + match self { + Self::ViewBody | Self::CopyBody | Self::SaveBody => true, + } + } +} /// Persisted key for response body JSONPath query text box #[derive(Debug, Serialize, PersistedKey)] @@ -81,25 +82,15 @@ struct ResponseQueryKey(RecipeId); impl EventHandler for ResponseBodyView { fn update(&mut self, _: &mut UpdateContext, event: Event) -> Option { - event - .opt() - .action(|action, propagate| match action { - Action::OpenActions => { - self.actions_handle.open(ActionsModal::default()) - } - _ => propagate.set(), - }) - .emitted(self.actions_handle, |menu_action| match menu_action { - BodyMenuAction::EditCollection => { - ViewContext::send_message(Message::CollectionEdit) - } - BodyMenuAction::ViewBody => { + event.opt().emitted(self.actions_emitter, |menu_action| { + match menu_action { + ResponseBodyMenuAction::ViewBody => { view_text( self.body.data().visible_text(), self.response.mime(), ); } - BodyMenuAction::CopyBody => { + ResponseBodyMenuAction::CopyBody => { // Use whatever text is visible to the user. This differs // from saving the body, because we can't copy binary // content, so if the file is binary we'll copy the hexcode @@ -108,14 +99,19 @@ impl EventHandler for ResponseBodyView { self.body.data().visible_text().to_string(), )); } - BodyMenuAction::SaveBody => { + ResponseBodyMenuAction::SaveBody => { // This will trigger a modal to ask the user for a path ViewContext::send_message(Message::SaveResponseBody { request_id: self.response.id, data: self.body.data().modified_text(), }); } - }) + } + }) + } + + fn menu_actions(&self) -> Vec { + ResponseBodyMenuAction::into_actions(self) } fn children(&mut self) -> Vec>> { @@ -129,6 +125,12 @@ impl Draw for ResponseBodyView { } } +impl ToEmitter for ResponseBodyView { + fn to_emitter(&self) -> Emitter { + self.actions_emitter + } +} + #[derive(Debug)] pub struct ResponseHeadersView { response: Arc, @@ -216,12 +218,8 @@ mod tests { // Open actions modal and select the copy action component - .send_keys([ - KeyCode::Char('x'), - KeyCode::Down, - KeyCode::Down, - KeyCode::Enter, - ]) + // Note: Edit Collections action isn't visible here + .send_keys([KeyCode::Char('x'), KeyCode::Down, KeyCode::Enter]) .assert_empty(); let body = assert_matches!( @@ -311,7 +309,7 @@ mod tests { component .send_keys([ KeyCode::Char('x'), - KeyCode::Down, + // Note: Edit Collections action isn't visible here KeyCode::Down, KeyCode::Down, KeyCode::Enter, diff --git a/crates/tui/src/view/component/root.rs b/crates/tui/src/view/component/root.rs index 95d10bbe..4c055c89 100644 --- a/crates/tui/src/view/component/root.rs +++ b/crates/tui/src/view/component/root.rs @@ -3,7 +3,10 @@ use crate::{ message::Message, util::ResultReported, view::{ - common::modal::ModalQueue, + common::{ + actions::ActionsModal, + modal::{Modal, ModalQueue}, + }, component::{ help::HelpFooter, history::History, @@ -148,11 +151,8 @@ impl Root { .map(RequestStateSummary::from) .collect(); - ViewContext::open_modal(History::new( - recipe_id, - requests, - self.selected_request_id(), - )); + History::new(recipe_id, requests, self.selected_request_id()) + .open(); } Ok(()) } @@ -167,6 +167,12 @@ impl EventHandler for Root { event .opt() .action(|action, propagate| match action { + Action::OpenActions => { + // Walk down the component tree and collect actions from + // all visible+focused components + let actions = self.primary_view.collect_actions(); + ActionsModal::new(actions).open(); + } Action::History => { self.open_history(context.request_store) .reported(&ViewContext::messages_tx()); @@ -175,7 +181,7 @@ impl EventHandler for Root { if let Some(request_id) = self.selected_request_id.0 { // 2024 edition: if-let chain if context.request_store.is_in_progress(request_id) { - ViewContext::open_modal(ConfirmModal::new( + ConfirmModal::new( "Cancel request?".into(), move |response| { if response { @@ -184,7 +190,8 @@ impl EventHandler for Root { ); } }, - )) + ) + .open() } } } @@ -291,10 +298,9 @@ mod tests { test_util::TestComponent, util::persistence::DatabasePersistedStore, }, }; - use crossterm::event::KeyCode; use persisted::PersistedStore; use rstest::rstest; - use slumber_core::{assert_matches, http::Exchange, test_util::Factory}; + use slumber_core::{http::Exchange, test_util::Factory}; /// Test that, on first render, the view loads the most recent historical /// request for the first recipe+profile @@ -407,23 +413,4 @@ mod tests { Some(new_exchange.id) ); } - - #[rstest] - fn test_edit_collection(mut harness: TestHarness, terminal: TestTerminal) { - let mut component = TestComponent::new( - &harness, - &terminal, - Root::new(&harness.collection, &harness.request_store.borrow()), - ); - component.drain_draw().assert_empty(); - - harness.clear_messages(); // Clear init junk - - // Open action menu - component.send_key(KeyCode::Char('x')).assert_empty(); - // Select first action - Edit Collection - component.send_key(KeyCode::Enter).assert_empty(); - // Event should be converted into a message appropriately - assert_matches!(harness.pop_message_now(), Message::CollectionEdit); - } } diff --git a/crates/tui/src/view/context.rs b/crates/tui/src/view/context.rs index 0759cff7..a790ba38 100644 --- a/crates/tui/src/view/context.rs +++ b/crates/tui/src/view/context.rs @@ -5,7 +5,6 @@ use crate::{ component::RecipeOverrideStore, event::{Event, EventQueue}, state::Notification, - IntoModal, }, }; use slumber_core::{collection::Collection, db::CollectionDatabase}; @@ -127,11 +126,6 @@ impl ViewContext { Self::with_mut(|context| context.event_queue.pop()) } - /// Open a modal - pub fn open_modal(modal: impl 'static + IntoModal) { - Self::push_event(Event::OpenModal(Box::new(modal.into_modal()))); - } - /// Queue an event to send an informational notification to the user pub fn notify(message: impl ToString) { let notification = Notification::new(message.to_string()); diff --git a/crates/tui/src/view/event.rs b/crates/tui/src/view/event.rs index 89f4f6c2..c5431f22 100644 --- a/crates/tui/src/view/event.rs +++ b/crates/tui/src/view/event.rs @@ -4,11 +4,12 @@ use crate::{ util::Flag, view::{ - common::modal::Modal, context::UpdateContext, state::Notification, + common::{actions::MenuAction, modal::Modal}, + context::UpdateContext, + state::Notification, Component, ViewContext, }, }; -use derive_more::derive::Display; use persisted::{PersistedContainer, PersistedLazyRefMut, PersistedStore}; use slumber_config::Action; use slumber_core::http::RequestId; @@ -39,6 +40,15 @@ pub trait EventHandler { Some(event) } + /// Provide a list of actions that are accessible from the actions menu. + /// This list may be static (e.g. determined from an enum) or dynamic. When + /// the user opens the actions menu, all available actions for all + /// **focused** components will be collected and show in the menu. If an + /// action is selected, an event will be emitted with that action value. + fn menu_actions(&self) -> Vec { + Vec::new() + } + /// Get **all** children of this component. This includes children that are /// not currently visible, and ones that are out of focus, meaning they /// shouldn't receive keyboard events. The event handling infrastructure is @@ -73,11 +83,19 @@ impl EventHandler for Option { } } + fn menu_actions(&self) -> Vec { + if let Some(inner) = &self { + inner.menu_actions() + } else { + Vec::new() + } + } + fn children(&mut self) -> Vec>> { if let Some(inner) = self.as_mut() { inner.children() } else { - vec![] + Vec::new() } } } @@ -250,7 +268,8 @@ pub enum Event { /// makes sure this will only be consumed by the intended recipient. Use /// [Emitter::emitted] to match on and consume this event type. Emitted { - emitter: EmitterId, + /// Who emitted this event? + emitter_id: EmitterId, /// Store the type name for better debug messages emitter_type: &'static str, event: Box, @@ -281,11 +300,9 @@ pub trait OptionEvent { /// Typically you'll need to pass a handle for the emitter here, in order /// to detach the emitter's lifetime from `self`, so that `self` can be used /// in the lambda. - fn emitted( - self, - emitter: T, - f: impl FnOnce(T::Emitted), - ) -> Self; + fn emitted(self, emitter: Emitter, f: impl FnOnce(E)) -> Self + where + E: LocalEvent; } impl OptionEvent for Option { @@ -317,11 +334,10 @@ impl OptionEvent for Option { } } - fn emitted( - self, - emitter: T, - f: impl FnOnce(T::Emitted), - ) -> Self { + fn emitted(self, emitter: Emitter, f: impl FnOnce(E)) -> Self + where + E: LocalEvent, + { let Some(event) = self else { return self; }; @@ -360,140 +376,137 @@ impl dyn LocalEvent { /// An emitter generates events of a particular type. This is used for /// components that need to respond to actions performed on their children, e.g. -/// listen to select and submit events on a child list. -pub trait Emitter { - /// The type of event emitted by this emitter. This will be converted into - /// a trait object when pushed onto the event queue, and converted back by - /// [Self::emitted] - type Emitted: LocalEvent; - - /// Return a unique ID for this emitter. Unlike components, where the - /// `Component` wrapper holds the ID, emitters are responsible for holding - /// their own IDs. This makes the ergonomics much simpler, and allows for - /// emitters to be used in contexts where a component wrapper doesn't exist. - fn id(&self) -> EmitterId; +/// listen to select and submit events on a child list. It can also be used for +/// components to communicate with themselves from async actions, e.g. reporting +/// back the result of a modal interaction. +/// +/// It would be good to impl `!Send` for this type because this relies on the +/// ViewContext and therefore shouldn't be passed off the main thread, but there +/// is one use case where it needs to be Send to be passed to the main loop via +/// Message without actually changing threads. +#[derive(Debug)] +pub struct Emitter { + id: EmitterId, + phantom: PhantomData, +} - /// Get the name of the implementing type, for logging/debugging - fn type_name(&self) -> &'static str { - any::type_name::() +impl Emitter { + fn new(id: EmitterId) -> Self { + Self { + id, + phantom: PhantomData, + } } +} +impl Emitter { /// Push an event onto the event queue - fn emit(&self, event: Self::Emitted) { + pub fn emit(&self, event: T) { ViewContext::push_event(Event::Emitted { - emitter: self.id(), - emitter_type: self.type_name(), + emitter_id: self.id, + emitter_type: any::type_name::(), event: Box::new(event), }); } - /// Get a lightweight version of this emitter, with the same type and ID but - /// datched from this lifetime. Useful for both emitting and consuming - /// emissions from across task boundaries, modals, etc. - fn handle(&self) -> EmitterHandle { - EmitterHandle { - id: self.id(), - type_name: self.type_name(), - phantom: PhantomData, - } - } - /// Check if an event is an emitted event from this emitter, and return /// the emitted data if so - fn emitted(&self, event: Event) -> Result { + pub fn emitted(&self, event: Event) -> Result { match event { Event::Emitted { - emitter, + emitter_id, event, emitter_type, - } if emitter == self.id() => { + } if emitter_id == self.id => { // This cast should be infallible because emitter IDs are unique // and each emitter can only emit one type - Ok(event.downcast().unwrap_or_else(|| { + Ok(event.downcast::().unwrap_or_else(|| { panic!( - "Incorrect emitted event type for emitter `{emitter}`. \ - Expected type {}, received type {emitter_type}", - any::type_name::() + "Incorrect emitted event type for emitter \ + `{emitter_id}`. Expected type {}, received type \ + {emitter_type}", + any::type_name::() ) })) } _ => Err(event), } } -} - -impl Emitter for T -where - T: Deref, - T::Target: Emitter, -{ - type Emitted = <::Target as Emitter>::Emitted; - fn id(&self) -> EmitterId { - self.deref().id() + /// Cast this to an emitter of `dyn LocalEvent`, so that it can emit events + /// of any type. This should be used when emitting events of multiple types + /// from the same spot. The original type must be known at consumption time, + /// so [Self::emitted] can be used to downcast back. + pub fn upcast(self) -> Emitter { + Emitter { + id: self.id, + phantom: PhantomData, + } } } -/// A unique ID to refer to a component instance that emits specialized events. -/// This is used by the consumer to confirm that the event came from a specific -/// instance, so that events from multiple instances of the same component type -/// cannot be mixed up. This should never be compared directly; use -/// [Emitter::emitted] instead. -#[derive(Copy, Clone, Debug, Display, Eq, Hash, PartialEq)] -pub struct EmitterId(Uuid); - -impl EmitterId { - pub fn new() -> Self { - Self(Uuid::new_v4()) +impl Emitter { + /// Push a type-erased event onto the event queue + pub fn emit(&self, event: Box) { + ViewContext::push_event(Event::Emitted { + emitter_id: self.id, + // We lose the original type name :( + emitter_type: any::type_name_of_val(&event), + // The event is already boxed, so do *not* double box it + event, + }); } +} + +// Manual impls needed to bypass bounds +impl Copy for Emitter {} - /// Empty emitted ID, to be used when no emitter is present - pub fn nil() -> Self { - Self(Uuid::nil()) +impl Clone for Emitter { + fn clone(&self) -> Self { + *self } } -/// Generate a new random ID -impl Default for EmitterId { +impl Default for Emitter { fn default() -> Self { - Self::new() + Self::new(EmitterId::new()) } } -/// A lightweight copy of an emitter that can be passed between threads or -/// callbacks. This has the same emitting capability as the source emitter -/// because it contains its ID and is bound to its emitted event type. Generate -/// via [Emitter::handle]. +/// An emitter generates events of a particular type. This is used for +/// components that need to respond to actions performed on their children, e.g. +/// listen to select and submit events on a child list. /// -/// It would be good to impl `!Send` for this type because it shouldn't be -/// passed between threads, but there is one use case where it needs to be Send -/// to be passed to the main loop via Message, but doesn't actually change -/// threads. !Send is more correct because if you try to emit from another -/// thread it'll panic, because the event queue isn't available there. -#[derive(Debug)] -pub struct EmitterHandle { - id: EmitterId, - type_name: &'static str, - phantom: PhantomData, +/// In most cases a component will emit only one type of event and therefore +/// one impl of this trait, but it's possible for a single component to have +/// multiple implementations. In the case of multiple implementations, the +/// component must store a different emitter for each implementation, since each +/// emitter is bound to a particular event type. +pub trait ToEmitter { + fn to_emitter(&self) -> Emitter; } -// Manual impls needed to bypass bounds -impl Copy for EmitterHandle {} - -impl Clone for EmitterHandle { - fn clone(&self) -> Self { - *self +impl ToEmitter for T +where + T: Deref, + T::Target: ToEmitter, + E: LocalEvent, +{ + fn to_emitter(&self) -> Emitter { + self.deref().to_emitter() } } -impl Emitter for EmitterHandle { - type Emitted = T; - - fn id(&self) -> EmitterId { - self.id - } +/// A unique ID to refer to a component instance that emits specialized events. +/// This is used by the consumer to confirm that the event came from a specific +/// instance, so that events from multiple instances of the same component type +/// cannot be mixed up. This should never be compared directly; use +/// [Emitter::emitted] instead. +#[derive(Copy, Clone, Debug, derive_more::Display, Eq, Hash, PartialEq)] +pub struct EmitterId(Uuid); - fn type_name(&self) -> &'static str { - self.type_name +impl EmitterId { + fn new() -> Self { + Self(Uuid::new_v4()) } } diff --git a/crates/tui/src/view/state/fixed_select.rs b/crates/tui/src/view/state/fixed_select.rs index 256b1580..9ce833c4 100644 --- a/crates/tui/src/view/state/fixed_select.rs +++ b/crates/tui/src/view/state/fixed_select.rs @@ -1,7 +1,7 @@ use crate::view::{ context::UpdateContext, draw::{Draw, DrawMetadata}, - event::{Emitter, EmitterId, Event, EventHandler}, + event::{Emitter, Event, EventHandler, ToEmitter}, state::select::{ SelectItem, SelectState, SelectStateBuilder, SelectStateData, SelectStateEvent, SelectStateEventType, @@ -235,11 +235,9 @@ where } } -impl Emitter for FixedSelectState { - type Emitted = SelectStateEvent; - - fn id(&self) -> EmitterId { - self.inner.id() +impl ToEmitter for FixedSelectState { + fn to_emitter(&self) -> Emitter { + self.inner.to_emitter() } } diff --git a/crates/tui/src/view/state/select.rs b/crates/tui/src/view/state/select.rs index 11edfb79..184e114a 100644 --- a/crates/tui/src/view/state/select.rs +++ b/crates/tui/src/view/state/select.rs @@ -1,7 +1,7 @@ use crate::view::{ context::UpdateContext, draw::{Draw, DrawMetadata}, - event::{Emitter, EmitterId, Event, EventHandler, OptionEvent}, + event::{Emitter, Event, EventHandler, OptionEvent, ToEmitter}, }; use persisted::PersistedContainer; use ratatui::{ @@ -28,7 +28,7 @@ pub struct SelectState where State: SelectStateData, { - emitter_id: EmitterId, + emitter: Emitter, /// Which event types to emit subscribed_events: Vec, /// Use interior mutability because this needs to be modified during the @@ -85,6 +85,23 @@ impl SelectStateBuilder { self } + /// Disable certain items in the list by index. Disabled items can still be + /// selected, but do not emit events. + pub fn disabled_indexes( + mut self, + disabled_indexes: impl IntoIterator, + ) -> Self { + // O(n^2)! We expect both lists to be very small so it's not an issue + for disabled in disabled_indexes { + for (i, item) in self.items.iter_mut().enumerate() { + if disabled == i { + item.disabled = true; + } + } + } + self + } + /// Which types of events should this emit? pub fn subscribe( mut self, @@ -127,7 +144,7 @@ impl SelectStateBuilder { State: SelectStateData, { let mut select = SelectState { - emitter_id: EmitterId::new(), + emitter: Default::default(), subscribed_events: self.subscribed_events, state: RefCell::default(), items: self.items, @@ -202,6 +219,12 @@ impl SelectState { self.items.get_mut(index).map(|item| &mut item.value) } + /// Move the selected item out of the list, if there is any + pub fn into_selected(mut self) -> Option { + let index = self.selected_index()?; + Some(self.items.swap_remove(index).value) + } + /// Select an item by value. Generally the given value will be the type /// `Item`, but it could be anything that compares to `Item` (e.g. an ID /// type). @@ -263,7 +286,7 @@ impl SelectState { let event = event_fn(selected); // Check if the parent subscribed to this event type if self.is_subscribed(SelectStateEventType::from(&event)) { - self.emit(event); + self.emitter.emit(event); } } _ => {} @@ -376,11 +399,12 @@ where } } -impl Emitter for SelectState { - type Emitted = SelectStateEvent; - - fn id(&self) -> EmitterId { - self.emitter_id +impl ToEmitter for SelectState +where + State: SelectStateData, +{ + fn to_emitter(&self) -> Emitter { + self.emitter } } diff --git a/crates/tui/src/view/test_util.rs b/crates/tui/src/view/test_util.rs index f37aca76..686db37d 100644 --- a/crates/tui/src/view/test_util.rs +++ b/crates/tui/src/view/test_util.rs @@ -5,11 +5,17 @@ use crate::{ http::RequestStore, test_util::{TestHarness, TestTerminal}, view::{ - common::modal::{Modal, ModalQueue}, + common::{ + actions::ActionsModal, + modal::{Modal, ModalQueue}, + }, component::Component, context::ViewContext, draw::{Draw, DrawMetadata}, - event::{Child, Emitter, Event, EventHandler, ToChild}, + event::{ + Child, Event, EventHandler, LocalEvent, OptionEvent, ToChild, + ToEmitter, + }, UpdateContext, }, }; @@ -19,6 +25,7 @@ use crossterm::event::{ }; use itertools::Itertools; use ratatui::{layout::Rect, Frame}; +use slumber_config::Action; use std::{cell::RefCell, rc::Rc}; /// A wrapper around a component that makes it easy to test. This provides lots @@ -33,7 +40,7 @@ pub struct TestComponent<'term, T, Props> { /// terminal but can be modified to test things like resizes, using /// [Self::set_area] area: Rect, - component: Component>, + component: Component>, /// Whatever props were used for the most recent draw. We store these for /// convenience, because in most test cases we use the same props over and /// over, and just care about changes in response to events. This requires @@ -77,8 +84,8 @@ where data: T, initial_props: Props, ) -> Self { - let component: Component> = - WithModalQueue::new(data).into(); + let component: Component> = + TestWrapper::new(data).into(); let mut slf = Self { terminal, request_store: Rc::clone(&harness.request_store), @@ -315,19 +322,17 @@ impl<'a, Component> PropagatedEvents<'a, Component> { /// Assert that only emitted events were propagated, and those events match /// a specific sequence. Requires `PartialEq` to be implemented for the /// emitted event type. - pub fn assert_emitted( - self, - expected: impl IntoIterator, - ) where - Component: Emitter, - Component::Emitted: PartialEq, + pub fn assert_emitted(self, expected: impl IntoIterator) + where + Component: ToEmitter, + E: LocalEvent + PartialEq, { - let handle = self.component.handle(); + let emitter = self.component.to_emitter(); let emitted = self .events .into_iter() .map(|event| { - handle.emitted(event).unwrap_or_else(|event| { + emitter.emitted(event).unwrap_or_else(|event| { panic!( "Expected only emitted events to have been propagated, \ but received: {event:#?}", @@ -345,15 +350,19 @@ impl<'a, Component> PropagatedEvents<'a, Component> { } } -/// A wrapper component to pair a component with a modal queue. Useful when the -/// component opens modals. This is included automatically in all tests, because -/// the modal queue is always present in the real app. -struct WithModalQueue { +/// A wrapper component to provide global functionality to a component in unit +/// tests. This provides a modal queue and action menu, which are provided by +/// the root component during app operation. This is included automatically in +/// all tests. +/// +/// In a sense this is a duplicate of the root component. Maybe someday we could +/// make that component generic and get rid of this? +struct TestWrapper { inner: Component, modal_queue: Component, } -impl WithModalQueue { +impl TestWrapper { pub fn new(component: T) -> Self { Self { inner: component.into(), @@ -370,13 +379,27 @@ impl WithModalQueue { } } -impl EventHandler for WithModalQueue { +impl EventHandler for TestWrapper { + fn update(&mut self, _: &mut UpdateContext, event: Event) -> Option { + event.opt().action(|action, propagate| match action { + // Unfortunately we have to duplicate this with Root because the + // child component is different + Action::OpenActions => { + // Walk down the component tree and collect actions from + // all visible+focused components + let actions = self.inner.collect_actions(); + ActionsModal::new(actions).open(); + } + _ => propagate.set(), + }) + } + fn children(&mut self) -> Vec>> { vec![self.modal_queue.to_child_mut(), self.inner.to_child_mut()] } } -impl> Draw for WithModalQueue { +impl> Draw for TestWrapper { fn draw(&self, frame: &mut Frame, props: Props, metadata: DrawMetadata) { self.inner .draw(frame, props, metadata.area(), metadata.has_focus());