diff --git a/CHANGELOG.md b/CHANGELOG.md index 43ef4609..4cc1a275 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - This should make errors much less cryptic and frustrating - Improve UX of query text box - The query is now auto-applied when changed (with a 500ms debounce), and drops focus on the text box when Enter is pressed +- Refactor UI event handling logic + - This shouldn't have any noticable impact on the user, but if you notice any bugs please open an issue ### Fixed diff --git a/crates/core/src/collection/models.rs b/crates/core/src/collection/models.rs index ad78476d..967d671b 100644 --- a/crates/core/src/collection/models.rs +++ b/crates/core/src/collection/models.rs @@ -586,7 +586,14 @@ impl Collection { impl crate::test_util::Factory for Collection { fn factory(_: ()) -> Self { use crate::test_util::by_id; - let recipe = Recipe::factory(()); + // Include a body in the recipe, so body-related behavior can be tested + let recipe = Recipe { + body: Some(RecipeBody::Raw { + body: r#"{"message": "hello"}"#.into(), + content_type: Some(ContentType::Json), + }), + ..Recipe::factory(()) + }; let profile = Profile::factory(()); Collection { recipes: by_id([recipe]).into(), diff --git a/crates/tui/src/lib.rs b/crates/tui/src/lib.rs index 8a5c5dbf..a8cdc4af 100644 --- a/crates/tui/src/lib.rs +++ b/crates/tui/src/lib.rs @@ -54,7 +54,7 @@ use std::{ use tokio::{ select, sync::mpsc::{self, UnboundedReceiver}, - time, + task, time, }; use tracing::{debug, error, info, info_span, trace}; @@ -139,7 +139,14 @@ impl Tui { request_store, }; - app.run().await + // Run the main loop in a local task set. This allows simple UI behavior + // requires async (e.g. event debouncing) to run on the main thread and + // retain access to the view context. This allows some tasks to avoid + // using the message channel, simplifying the process + let local = task::LocalSet::new(); + local.spawn_local(app.run()); + local.await; + Ok(()) } /// Run the main TUI update loop. Any error returned from this is fatal. See @@ -316,8 +323,6 @@ impl Tui { self.view.handle_input(event, action); } - Message::Local(event) => self.view.local(event), - Message::Notify(message) => self.view.notify(message), Message::PromptStart(prompt) => { self.view.open_modal(prompt); diff --git a/crates/tui/src/message.rs b/crates/tui/src/message.rs index 5382fc1d..2e5be8c5 100644 --- a/crates/tui/src/message.rs +++ b/crates/tui/src/message.rs @@ -1,7 +1,7 @@ //! Async message passing! This is how inputs and other external events trigger //! state updates. -use crate::view::{Confirm, LocalEvent}; +use crate::view::Confirm; use anyhow::Context; use derive_more::From; use slumber_config::Action; @@ -113,9 +113,6 @@ pub enum Message { action: Option, }, - /// Trigger a localized UI event - Local(Box), - /// Send an informational notification to the user Notify(String), diff --git a/crates/tui/src/view.rs b/crates/tui/src/view.rs index 07cc30d8..de1f30cc 100644 --- a/crates/tui/src/view.rs +++ b/crates/tui/src/view.rs @@ -12,7 +12,6 @@ mod util; pub use common::modal::{IntoModal, ModalPriority}; pub use context::{UpdateContext, ViewContext}; -pub use event::LocalEvent; pub use styles::Styles; pub use util::{Confirm, PreviewPrompter}; @@ -133,7 +132,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::push_event(Event::OpenModal(Box::new(modal.into_modal()))); + ViewContext::open_modal(modal.into_modal()); } /// Queue an event to send an informational notification to the user @@ -142,11 +141,6 @@ impl View { ViewContext::push_event(Event::Notify(notification)); } - /// Trigger a localized UI event - pub fn local(&mut self, event: Box) { - ViewContext::push_event(Event::Local(event)); - } - /// Queue an event to update the view according to an input event from the /// user. If possible, a bound action is provided which tells us what /// abstract action the input maps to. @@ -226,9 +220,9 @@ mod tests { // Initial events assert_events!( - Event::HttpSelectRequest(None), - Event::Local(_), - Event::Notify(_) + Event::Emitted { .. }, // Recipe list selection + Event::Emitted { .. }, // Primary pane selection + Event::Notify(_), ); // Events should *still* be in the queue, because we haven't drawn yet @@ -237,17 +231,17 @@ mod tests { request_store: &mut request_store, }); assert_events!( - Event::HttpSelectRequest(None), - Event::Local(_), - Event::Notify(_) + Event::Emitted { .. }, + Event::Emitted { .. }, + Event::Notify(_), ); // Nothing new terminal.draw(|frame| view.draw(frame, &request_store)); assert_events!( - Event::HttpSelectRequest(None), - Event::Local(_), - Event::Notify(_) + Event::Emitted { .. }, + Event::Emitted { .. }, + Event::Notify(_), ); // *Now* the queue is drained diff --git a/crates/tui/src/view/common/actions.rs b/crates/tui/src/view/common/actions.rs index 1673034e..2822319b 100644 --- a/crates/tui/src/view/common/actions.rs +++ b/crates/tui/src/view/common/actions.rs @@ -1,10 +1,13 @@ use crate::view::{ common::{list::List, modal::Modal}, component::Component, + context::UpdateContext, draw::{Draw, DrawMetadata, Generate}, - event::{Child, Event, EventHandler}, - state::fixed_select::{FixedSelect, FixedSelectState}, - ViewContext, + event::{Child, Emitter, EmitterId, Event, EventHandler, Update}, + state::{ + fixed_select::{FixedSelect, FixedSelectState}, + select::{SelectStateEvent, SelectStateEventType}, + }, }; use ratatui::{ layout::Constraint, @@ -17,6 +20,7 @@ use ratatui::{ /// is defined by the generic parameter #[derive(Debug)] pub struct ActionsModal { + emitter_id: EmitterId, /// Join the list of global actions into the given one actions: Component>, } @@ -25,17 +29,11 @@ impl ActionsModal { /// Create a new actions modal, optional disabling certain actions based on /// some external condition(s). pub fn new(disabled_actions: &[T]) -> Self { - let on_submit = move |action: &mut T| { - // Close the modal *first*, so the parent can handle the - // callback event. Jank but it works - ViewContext::push_event(Event::CloseModal { submitted: true }); - ViewContext::push_event(Event::new_local(*action)); - }; - Self { + emitter_id: EmitterId::new(), actions: FixedSelectState::builder() .disabled_items(disabled_actions) - .on_submit(on_submit) + .subscribe([SelectStateEventType::Submit]) .build() .into(), } @@ -62,7 +60,25 @@ where } } -impl EventHandler for ActionsModal { +impl EventHandler for ActionsModal +where + T: FixedSelect, + ActionsModal: Draw, +{ + fn update(&mut self, _: &mut UpdateContext, event: Event) -> Update { + if let Some(event) = self.actions.emitted(&event) { + if let SelectStateEvent::Submit(index) = event { + // Close modal first so the parent can consume the emitted event + self.close(true); + let action = self.actions.data()[*index]; + self.emit(action); + } + Update::Consumed + } else { + Update::Propagate(event) + } + } + fn children(&mut self) -> Vec>> { vec![self.actions.to_child_mut()] } @@ -82,3 +98,12 @@ where ); } } + +impl Emitter for ActionsModal { + /// Emit the action itself + type Emitted = T; + + fn id(&self) -> EmitterId { + self.emitter_id + } +} diff --git a/crates/tui/src/view/common/button.rs b/crates/tui/src/view/common/button.rs index e2a47109..99f6344b 100644 --- a/crates/tui/src/view/common/button.rs +++ b/crates/tui/src/view/common/button.rs @@ -5,9 +5,8 @@ use crate::{ view::{ context::UpdateContext, draw::{Draw, DrawMetadata, Generate}, - event::{Event, EventHandler, Update}, + event::{Emitter, EmitterId, Event, EventHandler, Update}, state::fixed_select::{FixedSelect, FixedSelectState}, - ViewContext, }, }; use ratatui::{ @@ -51,6 +50,7 @@ impl<'a> Generate for Button<'a> { /// type `T`. #[derive(Debug, Default)] pub struct ButtonGroup { + emitter_id: EmitterId, select: FixedSelectState, } @@ -64,9 +64,7 @@ impl EventHandler for ButtonGroup { Action::Right => self.select.next(), Action::Submit => { // Propagate the selected item as a dynamic event - ViewContext::push_event(Event::new_local( - self.select.selected(), - )); + self.emit(self.select.selected()); } _ => return Update::Propagate(event), } @@ -104,3 +102,13 @@ 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 + } +} diff --git a/crates/tui/src/view/common/modal.rs b/crates/tui/src/view/common/modal.rs index 2525e2b1..23454426 100644 --- a/crates/tui/src/view/common/modal.rs +++ b/crates/tui/src/view/common/modal.rs @@ -3,9 +3,9 @@ use crate::{ view::{ context::UpdateContext, draw::{Draw, DrawMetadata}, - event::{Child, Event, EventHandler, Update}, + event::{Child, Emitter, EmitterToken, Event, EventHandler, Update}, util::centered_rect, - Component, + Component, ViewContext, }, }; use ratatui::{ @@ -40,6 +40,12 @@ pub trait Modal: Debug + Draw<()> + EventHandler { /// Dimensions of the modal, relative to the whole screen fn dimensions(&self) -> (Constraint, Constraint); + /// Send an event to close this modal. `submitted` flag will be forwarded + /// to the `on_close` handler. + fn close(&self, submitted: bool) { + ViewContext::push_event(Event::CloseModal { submitted }); + } + /// Optional callback when the modal is closed. Useful for finishing /// operations that require ownership of the modal data. Submitted flag is /// set based on the correspond flag in the `CloseModal` event. @@ -191,3 +197,45 @@ 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. +#[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>, +} + +impl ModalHandle { + pub fn new() -> Self { + Self { emitter: None } + } + + /// Open a modal and store its emitter ID + pub fn open(&mut self, modal: T) + where + T: 'static + Modal, + { + self.emitter = Some(modal.detach()); + ViewContext::open_modal(modal); + } + + /// Check if an event was emitted by the most recently opened modal + pub fn emitted<'a>(&self, event: &'a Event) -> Option<&'a T::Emitted> { + self.emitter + .as_ref() + .and_then(|emitter| emitter.emitted(event)) + } +} + +impl Default for ModalHandle { + fn default() -> Self { + Self { emitter: None } + } +} diff --git a/crates/tui/src/view/common/text_box.rs b/crates/tui/src/view/common/text_box.rs index 2c73701e..ed87081d 100644 --- a/crates/tui/src/view/common/text_box.rs +++ b/crates/tui/src/view/common/text_box.rs @@ -2,13 +2,11 @@ use crate::{ context::TuiContext, - message::Message, view::{ context::UpdateContext, draw::{Draw, DrawMetadata}, - event::{Event, EventHandler, Update}, + event::{Emitter, EmitterId, Event, EventHandler, Update}, util::Debounce, - ViewContext, }, }; use crossterm::event::{KeyCode, KeyModifiers}; @@ -27,6 +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, // Parameters sensitive: bool, /// Text to show when text content is empty @@ -41,24 +40,8 @@ pub struct TextBox { // State state: TextState, on_change_debounce: Option, - - // Callbacks - /// Called when user clicks to start editing - #[debug(skip)] - on_click: Option, - /// Called when user changes the text content. Can optionally be debounced - #[debug(skip)] - on_change: Option, - /// Called when user exits with submission (e.g. Enter) - #[debug(skip)] - on_submit: Option, - /// Called when user exits without saving (e.g. Escape) - #[debug(skip)] - on_cancel: Option, } -type Callback = Box; - type Validator = Box bool>; impl TextBox { @@ -91,9 +74,8 @@ impl TextBox { self } - /// Set validation function. If input is invalid, the `on_change` and - /// `on_submit` callbacks will be blocked, meaning the user must fix the - /// error or cancel. + /// Set validation function. If input is invalid, events will not be emitted + /// for submit or change, meaning the user must fix the error or cancel. pub fn validator( mut self, validator: impl 'static + Fn(&str) -> bool, @@ -102,32 +84,10 @@ impl TextBox { self } - /// Set the callback to be called when the user clicks the textbox - pub fn on_click(mut self, f: impl 'static + Fn()) -> Self { - self.on_click = Some(Box::new(f)); - self - } - - /// Set the callback to be called when the user changes the text value. - /// Callback can optionally be debounced, so it isn't called repeatedly - /// while the user is typing - pub fn on_change(mut self, f: impl 'static + Fn(), debounce: bool) -> Self { - self.on_change = Some(Box::new(f)); - if debounce { - self.on_change_debounce = Some(Debounce::new(DEBOUNCE)); - } - self - } - - /// Set the callback to be called when the user hits escape - pub fn on_cancel(mut self, f: impl 'static + Fn()) -> Self { - self.on_cancel = Some(Box::new(f)); - self - } - - /// Set the callback to be called when the user hits enter - pub fn on_submit(mut self, f: impl 'static + Fn()) -> Self { - self.on_submit = Some(Box::new(f)); + /// Enable debouncing on the change event, meaning the user has to stop + /// inputting for a certain delay before the event is emitted + pub fn debounce(mut self) -> Self { + self.on_change_debounce = Some(Debounce::new(DEBOUNCE)); self } @@ -141,11 +101,10 @@ impl TextBox { self.state.text } - /// Set text, and move the cursor to the end + /// Set text, and move the cursor to the end. This will **not** emit events pub fn set_text(&mut self, text: String) { self.state.text = text; self.state.end(); - self.submit(); } /// Check if the current input text is valid. Always returns true if there @@ -205,34 +164,26 @@ impl TextBox { true // We DID handle this event } - /// Call parent's on_change callback. Should be called whenever text - /// _content_ is changed + /// Emit a change event. Should be called whenever text _content_ is changed fn change(&mut self) { let is_valid = self.is_valid(); if let Some(debounce) = &self.on_change_debounce { if self.is_valid() { - let messages_tx = ViewContext::messages_tx(); - // WARNING: There is a bug here. If there are multiple text - // boxes active on the screen, there's no guarantee this will go - // to the right one. Fortunately that UX doesn't really make - // sense. This can be truly fixed with unique target IDs for - // local events, but that's a much bigger refactor. For now it - // should be fine. - debounce.start(move || { - messages_tx.send(Message::Local(Box::new(ChangeEvent))) - }); + // Defer the change event until after the debounce period + let emitter = self.detach(); + debounce.start(move || emitter.emit(TextBoxEvent::Change)); } else { debounce.cancel(); } } else if is_valid { - call(&self.on_change); + self.emit(TextBoxEvent::Change); } } - /// Call parent's submission callback + /// Emit a submit event fn submit(&mut self) { if self.is_valid() { - call(&self.on_submit); + self.emit(TextBoxEvent::Submit); } } } @@ -240,11 +191,9 @@ impl TextBox { impl EventHandler for TextBox { fn update(&mut self, _: &mut UpdateContext, event: Event) -> Update { match event { - Event::Local(local) - if local.downcast_ref::().is_some() => - { - call(&self.on_change) - } + // Warning: all actions need to be handled here, because unhandled + // actions have to get treated as text input instead of being + // propagated Event::Input { action: Some(Action::Submit), .. @@ -252,11 +201,11 @@ impl EventHandler for TextBox { Event::Input { action: Some(Action::Cancel), .. - } => call(&self.on_cancel), + } => self.emit(TextBoxEvent::Cancel), Event::Input { action: Some(Action::LeftClick), .. - } => call(&self.on_click), + } => self.emit(TextBoxEvent::Focus), Event::Input { event: crossterm::event::Event::Key(key_event), .. @@ -430,20 +379,28 @@ impl PersistedContainer for TextBox { fn restore_persisted(&mut self, value: Self::Value) { self.set_text(value); + self.submit(); } } -/// Local event for triggering debounced on_change calls -#[derive(Debug)] -struct ChangeEvent; +impl Emitter for TextBox { + type Emitted = TextBoxEvent; -/// Call a callback if defined -fn call(f: &Option) { - if let Some(f) = f { - f(); + fn id(&self) -> EmitterId { + self.emitter_id } } +/// Emitted event type for a text box +#[derive(Debug)] +#[cfg_attr(test, derive(PartialEq))] +pub enum TextBoxEvent { + Focus, + Change, + Cancel, + Submit, +} + #[cfg(test)] mod tests { use super::*; @@ -454,7 +411,7 @@ mod tests { use ratatui::text::Span; use rstest::rstest; use slumber_core::assert_matches; - use std::{cell::Cell, rc::Rc}; + use tokio::{task::LocalSet, time}; /// Create a span styled as the cursor fn cursor(text: &str) -> Span { @@ -477,69 +434,38 @@ mod tests { ) } - /// Helper for counting calls to a closure - #[derive(Clone, Debug, Default)] - struct Counter(Rc>); - - impl Counter { - fn increment(&self) { - self.0.set(self.0.get() + 1); - } - - /// Create a callback that just calls the counter - fn callback(&self) -> impl Fn() { - let counter = self.clone(); - move || { - counter.increment(); - } - } - } - - impl PartialEq for Counter { - fn eq(&self, other: &usize) -> bool { - self.0.get() == *other - } - } - /// Test the basic interaction loop on the text box #[rstest] fn test_interaction( harness: TestHarness, #[with(10, 1)] terminal: TestTerminal, ) { - let click_count = Counter::default(); - let change_count = Counter::default(); - let submit_count = Counter::default(); - let cancel_count = Counter::default(); - let mut component = TestComponent::new( - &harness, - &terminal, - TextBox::default() - .on_click(click_count.callback()) - .on_change(change_count.callback(), false) - .on_submit(submit_count.callback()) - .on_cancel(cancel_count.callback()), - (), - ); + let mut component = + TestComponent::new(&harness, &terminal, TextBox::default(), ()); // Assert initial state/view assert_state(&component.data().state, "", 0); terminal.assert_buffer_lines([vec![cursor(" "), text(" ")]]); // Type some text - component.send_text("hello!").assert_empty(); - assert_state(&component.data().state, "hello!", 6); + component.send_text("hi!").assert_emitted([ + TextBoxEvent::Change, + TextBoxEvent::Change, + TextBoxEvent::Change, + ]); + assert_state(&component.data().state, "hi!", 3); terminal.assert_buffer_lines([vec![ - text("hello!"), + text("hi!"), cursor(" "), - text(" "), + text(" "), ]]); // Sending with a modifier applied should do nothing, unless it's shift + component .send_key_modifiers(KeyCode::Char('W'), KeyModifiers::SHIFT) - .assert_empty(); - assert_state(&component.data().state, "hello!W", 7); + .assert_emitted([TextBoxEvent::Change]); + assert_state(&component.data().state, "hi!W", 4); assert_matches!( component .send_key_modifiers( @@ -550,50 +476,49 @@ mod tests { .events(), &[Event::Input { .. }] ); - assert_state(&component.data().state, "hello!W", 7); + assert_state(&component.data().state, "hi!W", 4); - // Test callbacks - component.click(0, 0).assert_empty(); - assert_eq!(click_count, 1); + // Test emitted events - assert_eq!(change_count, 7); + component.click(0, 0).assert_emitted([TextBoxEvent::Focus]); - component.send_key(KeyCode::Enter).assert_empty(); - assert_eq!(submit_count, 1); + component + .send_key(KeyCode::Enter) + .assert_emitted([TextBoxEvent::Submit]); - component.send_key(KeyCode::Esc).assert_empty(); - assert_eq!(cancel_count, 1); + component + .send_key(KeyCode::Esc) + .assert_emitted([TextBoxEvent::Cancel]); } /// Test on_change debouncing #[rstest] #[tokio::test] async fn test_debounce( - mut harness: TestHarness, + harness: TestHarness, #[with(10, 1)] terminal: TestTerminal, ) { - let change_count = Counter::default(); - let mut component = TestComponent::new( - &harness, - &terminal, - TextBox::default().on_change(change_count.callback(), true), - (), - ); - - // Type some text - component.send_text("hello!").assert_empty(); - // on_change isn't called immediately - assert_eq!(change_count, 0); - - // It gets called after waiting - let event = assert_matches!( - harness.pop_message_wait().await, - Message::Local(event) => event, - ); - // We have to feed the event from the message channel to the component - // manually. This is normally done by the main loop - component.update_draw(Event::Local(event)).assert_empty(); - assert_eq!(change_count, 1); + // Local task set needed for the debounce task + let local = LocalSet::new(); + let future = local.run_until(async { + let mut component = TestComponent::new( + &harness, + &terminal, + TextBox::default().debounce(), + (), + ); + + // Type some text. on_change isn't called immediately + component.send_text("hi").assert_emitted([]); + + // It gets called after waiting. Give the debounce a bit of buffer + // time before checking it + time::sleep(DEBOUNCE * 5 / 4).await; + component + .drain_draw() + .assert_emitted([TextBoxEvent::Change]); + }); + future.await; } /// Test text navigation and deleting. [TextState] has its own tests so @@ -607,17 +532,29 @@ mod tests { TestComponent::new(&harness, &terminal, TextBox::default(), ()); // Type some text - component.send_text("hello!").assert_empty(); + component.send_text("hello!").assert_emitted([ + // One change event per letter + TextBoxEvent::Change, + TextBoxEvent::Change, + TextBoxEvent::Change, + TextBoxEvent::Change, + TextBoxEvent::Change, + TextBoxEvent::Change, + ]); assert_state(&component.data().state, "hello!", 6); // Move around, delete some text. component.send_key(KeyCode::Left).assert_empty(); assert_state(&component.data().state, "hello!", 5); - component.send_key(KeyCode::Backspace).assert_empty(); + component + .send_key(KeyCode::Backspace) + .assert_emitted([TextBoxEvent::Change]); assert_state(&component.data().state, "hell!", 4); - component.send_key(KeyCode::Delete).assert_empty(); + component + .send_key(KeyCode::Delete) + .assert_emitted([TextBoxEvent::Change]); assert_state(&component.data().state, "hell", 4); component.send_key(KeyCode::Home).assert_empty(); @@ -633,7 +570,7 @@ mod tests { #[rstest] fn test_sensitive( harness: TestHarness, - #[with(6, 1)] terminal: TestTerminal, + #[with(3, 1)] terminal: TestTerminal, ) { let mut component = TestComponent::new( &harness, @@ -642,10 +579,12 @@ mod tests { (), ); - component.send_text("hello").assert_empty(); + component + .send_text("hi") + .assert_emitted([TextBoxEvent::Change, TextBoxEvent::Change]); - assert_state(&component.data().state, "hello", 5); - terminal.assert_buffer_lines([vec![text("•••••"), cursor(" ")]]); + assert_state(&component.data().state, "hi", 2); + terminal.assert_buffer_lines([vec![text("••"), cursor(" ")]]); } #[rstest] @@ -706,39 +645,34 @@ mod tests { harness: TestHarness, #[with(6, 1)] terminal: TestTerminal, ) { - let change_count = Counter::default(); - let submit_count = Counter::default(); let mut component = TestComponent::new( &harness, &terminal, - TextBox::default() - .validator(|text| text.len() <= 2) - .on_change(change_count.callback(), false) - .on_submit(submit_count.callback()), + TextBox::default().validator(|text| text.len() <= 2), (), ); // Valid text, everything is normal - component.send_text("he").assert_empty(); + component + .send_text("he") + .assert_emitted([TextBoxEvent::Change, TextBoxEvent::Change]); terminal.assert_buffer_lines([vec![ text("he"), cursor(" "), text(" "), ]]); - assert_eq!(change_count, 2); - component.send_key(KeyCode::Enter).assert_empty(); - assert_eq!(submit_count, 1); - // Invalid text, styling changes - component.send_text("llo").assert_empty(); + component + .send_key(KeyCode::Enter) + .assert_emitted([TextBoxEvent::Submit]); + + // Invalid text, styling changes and no events are emitted + component.send_text("llo").assert_emitted([]); terminal.assert_buffer_lines([vec![ Span::styled("hello", TuiContext::get().styles.text_box.invalid), cursor(" "), ]]); - // Callbacks are disabled - assert_eq!(change_count, 2); - component.send_key(KeyCode::Enter).assert_empty(); - assert_eq!(submit_count, 1); + component.send_key(KeyCode::Enter).assert_emitted([]); } #[test] diff --git a/crates/tui/src/view/component/exchange_pane.rs b/crates/tui/src/view/component/exchange_pane.rs index 4e5d985c..6e94b98c 100644 --- a/crates/tui/src/view/component/exchange_pane.rs +++ b/crates/tui/src/view/component/exchange_pane.rs @@ -3,7 +3,6 @@ use crate::{ view::{ common::{tabs::Tabs, Pane}, component::{ - primary::PrimaryPane, request_view::{RequestView, RequestViewProps}, response_view::{ ResponseBodyView, ResponseBodyViewProps, ResponseHeadersView, @@ -13,9 +12,9 @@ use crate::{ }, context::UpdateContext, draw::{Draw, DrawMetadata, Generate}, - event::{Child, Event, EventHandler, Update}, + event::{Child, Emitter, EmitterId, Event, EventHandler, Update}, util::persistence::PersistedLazy, - RequestState, ViewContext, + RequestState, }, }; use derive_more::Display; @@ -42,6 +41,7 @@ use strum::{EnumCount, EnumIter}; /// pane isn't selected. #[derive(Debug, Default)] pub struct ExchangePane { + emitter_id: EmitterId, tabs: Component, Tabs>>, request: Component, response_headers: Component, @@ -77,11 +77,7 @@ enum Tab { impl EventHandler for ExchangePane { fn update(&mut self, _: &mut UpdateContext, event: Event) -> Update { match event.action() { - Some(Action::LeftClick) => { - ViewContext::push_event(Event::new_local( - PrimaryPane::Exchange, - )); - } + Some(Action::LeftClick) => self.emit(ExchangePaneEvent::Click), _ => return Update::Propagate(event), } Update::Consumed @@ -270,3 +266,18 @@ impl<'a> Draw> for ExchangePane { } } } + +/// Notify parent when this pane is clicked +impl Emitter for ExchangePane { + type Emitted = ExchangePaneEvent; + + fn id(&self) -> EmitterId { + self.emitter_id + } +} + +/// Emitted event for the exchange pane component +#[derive(Debug)] +pub enum ExchangePaneEvent { + Click, +} diff --git a/crates/tui/src/view/component/history.rs b/crates/tui/src/view/component/history.rs index dfe556b8..e860456d 100644 --- a/crates/tui/src/view/component/history.rs +++ b/crates/tui/src/view/component/history.rs @@ -6,9 +6,9 @@ use crate::{ common::{list::List, modal::Modal}, component::Component, draw::{Draw, DrawMetadata, Generate}, - event::{Child, Event, EventHandler}, - state::select::SelectState, - ViewContext, + event::{Child, Event, EventHandler, Update}, + state::select::{SelectState, SelectStateEvent, SelectStateEventType}, + UpdateContext, ViewContext, }, }; use ratatui::{ @@ -40,13 +40,8 @@ impl History { .map(|recipe| recipe.name().to_owned()) .unwrap_or_else(|| recipe_id.to_string()); let select = SelectState::builder(requests) + .subscribe([SelectStateEventType::Select]) .preselect_opt(selected_request_id.as_ref()) - // When an item is selected, load it up - .on_select(|exchange| { - ViewContext::push_event(Event::HttpSelectRequest(Some( - exchange.id(), - ))) - }) .build(); Self { @@ -77,6 +72,19 @@ impl Modal for History { } impl EventHandler for History { + fn update(&mut self, _: &mut UpdateContext, event: Event) -> Update { + if let Some(event) = self.select.emitted(&event) { + if let SelectStateEvent::Select(index) = event { + ViewContext::push_event(Event::HttpSelectRequest(Some( + self.select.data()[*index].id(), + ))) + } + } else { + return Update::Propagate(event); + } + Update::Consumed + } + fn children(&mut self) -> Vec>> { vec![self.select.to_child_mut()] } diff --git a/crates/tui/src/view/component/internal.rs b/crates/tui/src/view/component/internal.rs index 655aeda2..1bfe8a9d 100644 --- a/crates/tui/src/view/component/internal.rs +++ b/crates/tui/src/view/component/internal.rs @@ -5,7 +5,7 @@ use crate::view::{ context::UpdateContext, draw::{Draw, DrawMetadata}, - event::{Child, Event, ToChild, Update}, + event::{Child, Emitter, Event, ToChild, Update}, }; use crossterm::event::MouseEvent; use derive_more::Display; @@ -204,6 +204,14 @@ impl Component { self.inner } + /// Forward to [Emitter::emitted] + pub fn emitted<'a>(&self, event: &'a Event) -> Option<&'a T::Emitted> + where + T: Emitter, + { + self.data().emitted(event) + } + /// Draw the component to the frame. This will update global state, then /// defer to the component's [Draw] implementation for the actual draw. pub fn draw( diff --git a/crates/tui/src/view/component/misc.rs b/crates/tui/src/view/component/misc.rs index 7832b233..73f03bfd 100644 --- a/crates/tui/src/view/component/misc.rs +++ b/crates/tui/src/view/component/misc.rs @@ -6,14 +6,17 @@ use crate::view::{ button::ButtonGroup, list::List, modal::{IntoModal, Modal}, - text_box::TextBox, + text_box::{TextBox, TextBoxEvent}, }, component::Component, context::UpdateContext, draw::{Draw, DrawMetadata, Generate}, event::{Child, Event, EventHandler, Update}, - state::{select::SelectState, Notification}, - Confirm, ModalPriority, ViewContext, + state::{ + select::{SelectState, SelectStateEvent, SelectStateEventType}, + Notification, + }, + Confirm, ModalPriority, }; use derive_more::Display; use ratatui::{ @@ -80,20 +83,9 @@ impl TextBoxModal { text_box: TextBox, on_submit: impl 'static + FnOnce(String), ) -> Self { - let text_box = text_box - // Make sure cancel gets propagated to close the modal - .on_cancel(|| { - ViewContext::push_event(Event::CloseModal { submitted: false }) - }) - .on_submit(move || { - // We have to defer submission to on_close, because we need the - // owned value of `self.prompt` - ViewContext::push_event(Event::CloseModal { submitted: true }); - }) - .into(); Self { title, - text_box, + text_box: text_box.into(), on_submit: Box::new(on_submit), } } @@ -117,6 +109,26 @@ impl Modal for TextBoxModal { } impl EventHandler for TextBoxModal { + fn update(&mut self, _: &mut UpdateContext, event: Event) -> Update { + if let Some(event) = self.text_box.emitted(&event) { + match event { + TextBoxEvent::Focus | TextBoxEvent::Change => {} + TextBoxEvent::Cancel => { + // Propagate cancel to close the modal + self.close(false); + } + TextBoxEvent::Submit => { + // We have to defer submission to on_close, because we need + // the owned value of `self.on_submit` + self.close(true); + } + } + Update::Consumed + } else { + Update::Propagate(event) + } + } + fn children(&mut self) -> Vec>> { vec![self.text_box.to_child_mut()] } @@ -165,11 +177,7 @@ impl SelectListModal { Self { title, options: SelectState::builder(options) - .on_submit(move |_| { - ViewContext::push_event(Event::CloseModal { - submitted: true, - }); - }) + .subscribe([SelectStateEventType::Submit]) .build() .into(), on_submit: Box::new(on_submit), @@ -207,6 +215,17 @@ impl Modal for SelectListModal { } impl EventHandler for SelectListModal { + fn update(&mut self, _: &mut UpdateContext, event: Event) -> Update { + if let Some(event) = self.options.emitted(&event) { + if let SelectStateEvent::Submit(_) = event { + self.close(true); + } + } else { + return Update::Propagate(event); + } + Update::Consumed + } + fn children(&mut self) -> Vec>> { vec![self.options.to_child_mut()] } @@ -298,12 +317,12 @@ impl Modal for ConfirmModal { impl EventHandler for ConfirmModal { fn update(&mut self, _: &mut UpdateContext, event: Event) -> Update { // When user selects a button, send the response and close - let Some(button) = event.local::() else { + let Some(button) = self.buttons.emitted(&event) else { return Update::Propagate(event); }; self.answer = *button == ConfirmButton::Yes; - ViewContext::push_event(Event::CloseModal { submitted: true }); + self.close(true); Update::Consumed } diff --git a/crates/tui/src/view/component/primary.rs b/crates/tui/src/view/component/primary.rs index 48879902..2a695dab 100644 --- a/crates/tui/src/view/component/primary.rs +++ b/crates/tui/src/view/component/primary.rs @@ -5,18 +5,25 @@ use crate::{ message::Message, util::ResultReported, view::{ - common::actions::ActionsModal, + common::{actions::ActionsModal, modal::ModalHandle}, component::{ - exchange_pane::{ExchangePane, ExchangePaneProps}, + exchange_pane::{ + ExchangePane, ExchangePaneEvent, ExchangePaneProps, + }, help::HelpModal, profile_select::ProfilePane, - recipe_list::RecipeListPane, - recipe_pane::{RecipeMenuAction, RecipePane, RecipePaneProps}, + recipe_list::{RecipeListPane, RecipeListPaneEvent}, + recipe_pane::{ + RecipeMenuAction, RecipePane, RecipePaneEvent, RecipePaneProps, + }, }, context::UpdateContext, draw::{Draw, DrawMetadata, ToStringGenerate}, - event::{Child, Event, EventHandler, Update}, - state::fixed_select::FixedSelectState, + event::{Child, Emitter, Event, EventHandler, Update}, + state::{ + fixed_select::FixedSelectState, + select::{SelectStateEvent, SelectStateEventType}, + }, util::{ persistence::{Persisted, PersistedLazy}, view_text, @@ -51,6 +58,7 @@ pub struct PrimaryView { recipe_list_pane: Component, recipe_pane: Component, exchange_pane: Component, + actions_handle: ModalHandle>, } #[cfg_attr(test, derive(Clone))] @@ -71,7 +79,7 @@ pub struct PrimaryViewProps<'a> { Serialize, Deserialize, )] -pub enum PrimaryPane { +enum PrimaryPane { #[default] RecipeList, Recipe, @@ -93,10 +101,6 @@ enum FullscreenMode { #[persisted(Option)] struct FullscreenModeKey; -/// Event triggered when selected pane changes, so we can exit fullscreen -#[derive(Debug)] -struct PaneChanged; - /// Action menu items. This is the fallback menu if none of our children have /// one #[derive( @@ -113,24 +117,21 @@ impl PrimaryView { pub fn new(collection: &Collection) -> Self { let profile_pane = ProfilePane::new(collection).into(); let recipe_list_pane = RecipeListPane::new(&collection.recipes).into(); - let selected_pane = FixedSelectState::builder() - // Changing panes kicks us out of fullscreen - .on_select(|_| { - ViewContext::push_event(Event::new_local(PaneChanged)) - }) - .build(); Self { selected_pane: PersistedLazy::new( SingletonKey::default(), - selected_pane, + FixedSelectState::builder() + .subscribe([SelectStateEventType::Select]) + .build(), ), - fullscreen_mode: Persisted::default(), + fullscreen_mode: Default::default(), recipe_list_pane, profile_pane, recipe_pane: Default::default(), exchange_pane: Default::default(), + actions_handle: Default::default(), } } @@ -154,67 +155,6 @@ impl PrimaryView { self.profile_pane.data().selected_profile_id() } - /// Draw the "normal" view, when nothing is fullscreened - fn draw_all_panes( - &self, - frame: &mut Frame, - props: PrimaryViewProps, - area: Rect, - ) { - // Split the main pane horizontally - let [left_area, right_area] = - Layout::horizontal([Constraint::Max(40), Constraint::Min(40)]) - .areas(area); - - let [profile_area, recipes_area] = - Layout::vertical([Constraint::Length(3), Constraint::Min(0)]) - .areas(left_area); - let [recipe_area, request_response_area] = - self.get_right_column_layout(right_area); - - self.profile_pane.draw(frame, (), profile_area, true); - self.recipe_list_pane.draw( - frame, - (), - recipes_area, - self.is_selected(PrimaryPane::RecipeList), - ); - - let (selected_recipe_id, selected_recipe_kind) = - match self.recipe_list_pane.data().selected_node() { - Some((selected_recipe_id, selected_recipe_kind)) => { - (Some(selected_recipe_id), Some(selected_recipe_kind)) - } - None => (None, None), - }; - let collection = ViewContext::collection(); - let selected_recipe_node = selected_recipe_id.and_then(|id| { - collection - .recipes - .try_get(id) - .reported(&ViewContext::messages_tx()) - }); - self.recipe_pane.draw( - frame, - RecipePaneProps { - selected_recipe_node, - selected_profile_id: self.selected_profile_id(), - }, - recipe_area, - self.is_selected(PrimaryPane::Recipe), - ); - - self.exchange_pane.draw( - frame, - ExchangePaneProps { - selected_recipe_kind, - request_state: props.selected_request, - }, - request_response_area, - self.is_selected(PrimaryPane::Exchange), - ); - } - /// Is the given pane selected? fn is_selected(&self, primary_pane: PrimaryPane) -> bool { self.selected_pane.is_selected(&primary_pane) @@ -241,6 +181,58 @@ impl PrimaryView { } } + /// Get the current placement and focus for all panes, according to current + /// selection and fullscreen state. We always draw all panes so they can + /// perform their state updates. To hide them we just render to an empty + /// rect. + fn panes(&self, area: Rect) -> Panes { + if let Some(fullscreen_mode) = *self.fullscreen_mode { + match fullscreen_mode { + FullscreenMode::Recipe => Panes { + profile: PaneState::default(), + recipe_list: PaneState::default(), + recipe: PaneState { area, focus: true }, + exchange: PaneState::default(), + }, + FullscreenMode::Exchange => Panes { + profile: PaneState::default(), + recipe_list: PaneState::default(), + recipe: PaneState::default(), + exchange: PaneState { area, focus: true }, + }, + } + } else { + // Split the main pane horizontally + let [left_area, right_area] = + Layout::horizontal([Constraint::Max(40), Constraint::Min(40)]) + .areas(area); + + let [profile_area, recipe_list_area] = + Layout::vertical([Constraint::Length(3), Constraint::Min(0)]) + .areas(left_area); + let [recipe_area, exchange_area] = + self.get_right_column_layout(right_area); + Panes { + profile: PaneState { + area: profile_area, + focus: true, + }, + recipe_list: PaneState { + area: recipe_list_area, + focus: self.is_selected(PrimaryPane::RecipeList), + }, + recipe: PaneState { + area: recipe_area, + focus: self.is_selected(PrimaryPane::Recipe), + }, + exchange: PaneState { + area: exchange_area, + focus: self.is_selected(PrimaryPane::Exchange), + }, + } + } + } + /// Get layout for the right column of panes fn get_right_column_layout(&self, area: Rect) -> [Rect; 2] { // Split right column vertically. Expand the currently selected pane @@ -256,6 +248,13 @@ impl PrimaryView { .areas(area) } + /// Send a request for the currently selected recipe (if any) + fn send_request(&self) { + if let Some(config) = self.recipe_pane.data().request_config() { + ViewContext::send_message(Message::HttpBeginRequest(config)); + } + } + /// Handle menu actions for recipe list or detail panes. We handle this here /// for code de-duplication, and because we have access to all the needed /// context. @@ -287,36 +286,22 @@ impl PrimaryView { impl EventHandler for PrimaryView { fn update(&mut self, _: &mut UpdateContext, event: Event) -> Update { - match &event { - // Input messages - Event::Input { - action: Some(action), - event: _, - } => match action { + if let Some(action) = event.action() { + match action { Action::PreviousPane => self.selected_pane.get_mut().previous(), Action::NextPane => self.selected_pane.get_mut().next(), - Action::Submit => { - // Send a request from anywhere - if let Some(config) = - self.recipe_pane.data().request_config() - { - ViewContext::send_message(Message::HttpBeginRequest( - config, - )); - } - } + // Send a request from anywhere + Action::Submit => self.send_request(), Action::OpenActions => { - ViewContext::open_modal::>( - Default::default(), - ); + self.actions_handle.open(ActionsModal::default()); } Action::OpenHelp => { - ViewContext::open_modal::(Default::default()); + ViewContext::open_modal(HelpModal); } // Pane hotkeys Action::SelectProfileList => { - self.profile_pane.data().open_modal() + self.profile_pane.data_mut().open_modal() } Action::SelectRecipeList => self .selected_pane @@ -348,31 +333,45 @@ impl EventHandler for PrimaryView { *self.fullscreen_mode.get_mut() = None; } _ => return Update::Propagate(event), - }, - - Event::Local(local) => { - if let Some(PaneChanged) = local.downcast_ref() { - self.maybe_exit_fullscreen(); - } else if let Some(pane) = local.downcast_ref::() { - // Children can select themselves by sending PrimaryPane - self.selected_pane.get_mut().select(pane); - } else if let Some(action) = - local.downcast_ref::() - { + } + } else if let Some(event) = self.selected_pane.emitted(&event) { + if let SelectStateEvent::Select(_) = event { + // Exit fullscreen when pane changes + self.maybe_exit_fullscreen(); + } + } else if let Some(event) = self.recipe_list_pane.emitted(&event) { + match event { + RecipeListPaneEvent::Click => { + self.selected_pane + .get_mut() + .select(&PrimaryPane::RecipeList); + } + RecipeListPaneEvent::MenuAction(action) => { self.handle_recipe_menu_action(*action); - } else if let Some(action) = local.downcast_ref::() - { - match action { - MenuAction::EditCollection => { - ViewContext::send_message(Message::CollectionEdit) - } - } - } else { - return Update::Propagate(event); } } - - _ => return Update::Propagate(event), + } else if let Some(event) = self.recipe_pane.emitted(&event) { + match event { + RecipePaneEvent::Click => { + self.selected_pane.get_mut().select(&PrimaryPane::Recipe); + } + RecipePaneEvent::MenuAction(action) => { + self.handle_recipe_menu_action(*action); + } + } + } else if let Some(ExchangePaneEvent::Click) = + self.exchange_pane.emitted(&event) + { + self.selected_pane.get_mut().select(&PrimaryPane::Exchange); + } else if let Some(action) = self.actions_handle.emitted(&event) { + // Handle our own menu action type + match action { + MenuAction::EditCollection => { + ViewContext::send_message(Message::CollectionEdit) + } + } + } else { + return Update::Propagate(event); } Update::Consumed } @@ -394,46 +393,75 @@ impl<'a> Draw> for PrimaryView { props: PrimaryViewProps<'a>, metadata: DrawMetadata, ) { - match *self.fullscreen_mode { - None => self.draw_all_panes(frame, props, metadata.area()), - Some(FullscreenMode::Recipe) => { - let collection = ViewContext::collection(); - let selected_recipe_node = - self.recipe_list_pane.data().selected_node().and_then( - |(id, _)| { - collection - .recipes - .try_get(id) - .reported(&ViewContext::messages_tx()) - }, - ); - self.recipe_pane.draw( - frame, - RecipePaneProps { - selected_recipe_node, - selected_profile_id: self.selected_profile_id(), - }, - metadata.area(), - self.is_selected(PrimaryPane::Recipe), - ); - } - Some(FullscreenMode::Exchange) => self.exchange_pane.draw( - frame, - ExchangePaneProps { - selected_recipe_kind: self - .recipe_list_pane - .data() - .selected_node() - .map(|(_, kind)| kind), - request_state: props.selected_request, - }, - metadata.area(), - true, - ), - } + // We draw all panes regardless of fullscreen state, so they can run + // their necessary state updates. We just give the hidden panes an empty + // rect to draw into so they don't appear at all + let panes = self.panes(metadata.area()); + + self.profile_pane.draw( + frame, + (), + panes.profile.area, + panes.profile.focus, + ); + self.recipe_list_pane.draw( + frame, + (), + panes.recipe_list.area, + panes.recipe_list.focus, + ); + + let (selected_recipe_id, selected_recipe_kind) = + match self.recipe_list_pane.data().selected_node() { + Some((selected_recipe_id, selected_recipe_kind)) => { + (Some(selected_recipe_id), Some(selected_recipe_kind)) + } + None => (None, None), + }; + let collection = ViewContext::collection(); + let selected_recipe_node = selected_recipe_id.and_then(|id| { + collection + .recipes + .try_get(id) + .reported(&ViewContext::messages_tx()) + }); + self.recipe_pane.draw( + frame, + RecipePaneProps { + selected_recipe_node, + selected_profile_id: self.selected_profile_id(), + }, + panes.recipe.area, + panes.recipe.focus, + ); + + self.exchange_pane.draw( + frame, + ExchangePaneProps { + selected_recipe_kind, + request_state: props.selected_request, + }, + panes.exchange.area, + panes.exchange.focus, + ); } } +/// Helper for adjusting pane behavior according to state +struct Panes { + profile: PaneState, + recipe_list: PaneState, + recipe: PaneState, + exchange: PaneState, +} + +/// Helper for adjusting pane behavior according to state +#[derive(Default)] +struct PaneState { + area: Rect, + focus: bool, +} + #[cfg(test)] mod tests { use super::*; @@ -444,6 +472,7 @@ mod tests { test_util::TestComponent, util::persistence::DatabasePersistedStore, }, }; + use crossterm::event::KeyCode; use persisted::PersistedStore; use rstest::rstest; use slumber_core::{assert_matches, http::BuildOptions}; @@ -454,7 +483,7 @@ mod tests { terminal: &'term TestTerminal, ) -> TestComponent<'term, PrimaryView, PrimaryViewProps<'static>> { let view = PrimaryView::new(&harness.collection); - let component = TestComponent::new( + let mut component = TestComponent::new( harness, terminal, view, @@ -462,6 +491,11 @@ mod tests { selected_request: None, }, ); + // Initial events + assert_matches!( + component.drain_draw().events(), + &[Event::HttpSelectRequest(None)] + ); // Clear template preview messages so we can test what we want harness.clear_messages(); component @@ -500,8 +534,14 @@ mod tests { options: BuildOptions::default(), }; let mut component = create_component(&mut harness, &terminal); + component - .update_draw(Event::new_local(RecipeMenuAction::CopyUrl)) + .send_keys([ + KeyCode::Char('l'), // Select recipe list + KeyCode::Char('x'), // Open actions modal + KeyCode::Down, + KeyCode::Enter, // Copy URL + ]) .assert_empty(); let request_config = assert_matches!( @@ -521,8 +561,17 @@ mod tests { options: BuildOptions::default(), }; let mut component = create_component(&mut harness, &terminal); + component - .update_draw(Event::new_local(RecipeMenuAction::CopyBody)) + .send_keys([ + KeyCode::Char('l'), // Select recipe list + KeyCode::Char('x'), // Open actions modal + KeyCode::Down, + KeyCode::Down, + KeyCode::Down, + KeyCode::Down, + KeyCode::Enter, // Copy Body + ]) .assert_empty(); let request_config = assert_matches!( @@ -542,8 +591,15 @@ mod tests { options: BuildOptions::default(), }; let mut component = create_component(&mut harness, &terminal); + component - .update_draw(Event::new_local(RecipeMenuAction::CopyCurl)) + .send_keys([ + KeyCode::Char('l'), // Select recipe list + KeyCode::Char('x'), // Open actions modal + KeyCode::Down, + KeyCode::Down, + KeyCode::Enter, // Copy as cURL + ]) .assert_empty(); let request_config = assert_matches!( diff --git a/crates/tui/src/view/component/profile_select.rs b/crates/tui/src/view/component/profile_select.rs index 53dbac6c..de4e1914 100644 --- a/crates/tui/src/view/component/profile_select.rs +++ b/crates/tui/src/view/component/profile_select.rs @@ -5,13 +5,19 @@ use crate::{ util::ResultReported, view::{ common::{ - list::List, modal::Modal, table::Table, - template_preview::TemplatePreview, Pane, + list::List, + modal::{Modal, ModalHandle}, + table::Table, + template_preview::TemplatePreview, + Pane, }, context::UpdateContext, draw::{Draw, DrawMetadata, Generate}, - event::{Child, Event, EventHandler, Update}, - state::{select::SelectState, StateCell}, + event::{Child, Emitter, EmitterId, Event, EventHandler, Update}, + state::{ + select::{SelectState, SelectStateEvent, SelectStateEventType}, + StateCell, + }, util::persistence::Persisted, Component, ViewContext, }, @@ -41,6 +47,8 @@ pub struct ProfilePane { /// necessarily the same: the user could highlight a profile without /// actually selecting it. selected_profile_id: Persisted, + /// Handle events from the opened modal + modal_handle: ModalHandle, } /// Persisted key for the ID of the selected profile @@ -73,6 +81,7 @@ impl ProfilePane { Self { selected_profile_id, + modal_handle: ModalHandle::new(), } } @@ -81,10 +90,9 @@ impl ProfilePane { } /// Open the profile list modal - pub fn open_modal(&self) { - ViewContext::open_modal(ProfileListModal::new( - self.selected_profile_id.as_ref(), - )); + pub fn open_modal(&mut self) { + self.modal_handle + .open(ProfileListModal::new(self.selected_profile_id.as_ref())); } } @@ -92,7 +100,9 @@ impl EventHandler for ProfilePane { fn update(&mut self, _: &mut UpdateContext, event: Event) -> Update { if let Some(Action::LeftClick) = event.action() { self.open_modal(); - } else if let Some(SelectProfile(profile_id)) = event.local() { + } else if let Some(SelectProfile(profile_id)) = + self.modal_handle.emitted(&event) + { // Handle message from the modal *self.selected_profile_id.get_mut() = Some(profile_id.clone()); // Refresh template previews @@ -133,30 +143,17 @@ impl Draw for ProfilePane { } } -/// Local event to pass selected profile ID from modal back to the parent -#[derive(Debug)] -struct SelectProfile(ProfileId); - /// Modal to allow user to select a profile from a list and preview profile /// fields #[derive(Debug)] struct ProfileListModal { + emitter_id: EmitterId, select: Component>, detail: Component, } impl ProfileListModal { pub fn new(selected_profile_id: Option<&ProfileId>) -> Self { - // Loaded request depends on the profile, so refresh on change - fn on_submit(profile: &mut ProfileListItem) { - // Close the modal *first*, so the parent can handle the - // callback event. Jank but it works - ViewContext::push_event(Event::CloseModal { submitted: true }); - ViewContext::push_event(Event::new_local(SelectProfile( - profile.id.clone(), - ))); - } - let profiles = ViewContext::collection() .profiles .values() @@ -165,9 +162,10 @@ impl ProfileListModal { let select = SelectState::builder(profiles) .preselect_opt(selected_profile_id) - .on_submit(on_submit) + .subscribe([SelectStateEventType::Submit]) .build(); Self { + emitter_id: EmitterId::new(), select: select.into(), detail: Default::default(), } @@ -185,6 +183,21 @@ impl Modal for ProfileListModal { } impl EventHandler for ProfileListModal { + fn update(&mut self, _: &mut UpdateContext, event: Event) -> Update { + if let Some(event) = self.select.emitted(&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)); + } + } else { + return Update::Propagate(event); + } + Update::Consumed + } + fn children(&mut self) -> Vec>> { vec![self.select.to_child_mut()] } @@ -226,6 +239,18 @@ impl Draw for ProfileListModal { } } +impl Emitter for ProfileListModal { + type Emitted = SelectProfile; + + fn id(&self) -> EmitterId { + self.emitter_id + } +} + +/// Local event to pass selected profile ID from modal back to the parent +#[derive(Debug)] +struct SelectProfile(ProfileId); + /// Simplified version of [Profile], to be used in the display list. This /// only stores whatever data is necessary to render the list #[derive(Clone, Debug)] diff --git a/crates/tui/src/view/component/queryable_body.rs b/crates/tui/src/view/component/queryable_body.rs index 33ca84db..30bee251 100644 --- a/crates/tui/src/view/component/queryable_body.rs +++ b/crates/tui/src/view/component/queryable_body.rs @@ -4,7 +4,7 @@ use crate::{ context::TuiContext, view::{ common::{ - text_box::TextBox, + text_box::{TextBox, TextBoxEvent}, text_window::{ScrollbarMargins, TextWindow, TextWindowProps}, }, context::UpdateContext, @@ -12,7 +12,7 @@ use crate::{ event::{Child, Event, EventHandler, Update}, state::{Identified, StateCell}, util::{highlight, str_to_text}, - Component, ViewContext, + Component, }, }; use anyhow::Context; @@ -86,18 +86,11 @@ impl QueryableBody { let input_engine = &TuiContext::get().input_engine; let binding = input_engine.binding_display(Action::Search); - let send_local = |callback| { - move || ViewContext::push_event(Event::new_local(callback)) - }; let text_box = TextBox::default() .placeholder(format!("{binding} to query body")) .placeholder_focused("Query with JSONPath (ex: $.results)") .validator(|text| JsonPath::parse(text).is_ok()) - // Callback trigger an events, so we can modify our own state - .on_click(send_local(QueryCallback::Focus)) - .on_change(send_local(QueryCallback::Change), true) - .on_cancel(send_local(QueryCallback::Cancel)) - .on_submit(send_local(QueryCallback::Submit)); + .debounce(); Self { state: Default::default(), query_available: Cell::new(false), @@ -160,11 +153,11 @@ impl EventHandler for QueryableBody { if self.query_available.get() { self.query_focused = true; } - } else if let Some(callback) = event.local::() { - match callback { - QueryCallback::Focus => self.query_focused = true, - QueryCallback::Change => self.update_query(), - QueryCallback::Cancel => { + } else if let Some(event) = self.query_text_box.emitted(&event) { + match event { + TextBoxEvent::Focus => self.query_focused = true, + TextBoxEvent::Change => self.update_query(), + TextBoxEvent::Cancel => { // Reset text to whatever was submitted last self.query_text_box.data_mut().set_text( self.query @@ -174,7 +167,7 @@ impl EventHandler for QueryableBody { ); self.query_focused = false; } - QueryCallback::Submit => { + TextBoxEvent::Submit => { self.update_query(); self.query_focused = false; } @@ -253,15 +246,6 @@ impl PersistedContainer for QueryableBody { } } -/// All callback events from the query text box -#[derive(Copy, Clone, Debug)] -enum QueryCallback { - Focus, - Change, - Cancel, - Submit, -} - /// Calculate display text based on current body/query fn init_state( content_type: Option, @@ -348,6 +332,7 @@ mod tests { use rstest::{fixture, rstest}; use serde::Serialize; use slumber_core::{http::ResponseRecord, test_util::header_map}; + use tokio::task::LocalSet; const TEXT: &[u8] = b"{\"greeting\":\"hello\"}"; @@ -406,70 +391,78 @@ mod tests { #[with(32, 5)] terminal: TestTerminal, json_response: ResponseRecord, ) { - let mut component = TestComponent::new( - &harness, - &terminal, - QueryableBody::new(), - QueryableBodyProps { - content_type: None, - body: &json_response.body, - }, - ); - - // Assert initial state/view - let data = component.data(); - assert!(data.query_available.get()); - assert_eq!(data.query, None); - assert_eq!( - data.parsed_text().as_deref(), - Some("{\n \"greeting\": \"hello\"\n}") - ); - let styles = &TuiContext::get().styles.text_box; - terminal.assert_buffer_lines([ - vec![gutter("1"), " { ".into()], - vec![gutter("2"), " \"greeting\": \"hello\"".into()], - vec![gutter("3"), " } ".into()], - vec![gutter(" "), " ".into()], - vec![ - Span::styled( - "/ to query body", - styles.text.patch(styles.placeholder), - ), - Span::styled(" ", styles.text), - ], - ]); - - // Type something into the query box - component.send_key(KeyCode::Char('/')).assert_empty(); - component.send_text("$.greeting").assert_empty(); - component.send_key(KeyCode::Enter).assert_empty(); - - // Make sure state updated correctly - let data = component.data(); - assert_eq!(data.query, Some("$.greeting".parse().unwrap())); - assert_eq!(data.parsed_text().as_deref(), Some("[\n \"hello\"\n]")); - assert!(!data.query_focused); - - // Cancelling out of the text box should reset the query value - component.send_key(KeyCode::Char('/')).assert_empty(); - component.send_text("more text").assert_empty(); - component.send_key(KeyCode::Esc).assert_empty(); - let data = component.data(); - assert_eq!(data.query, Some("$.greeting".parse().unwrap())); - assert_eq!(data.query_text_box.data().text(), "$.greeting"); - assert!(!data.query_focused); - - // Check the view again - terminal.assert_buffer_lines([ - vec![gutter("1"), " [ ".into()], - vec![gutter("2"), " \"hello\" ".into()], - vec![gutter("3"), " ] ".into()], - vec![gutter(" "), " ".into()], - vec![Span::styled( - "$.greeting ", - styles.text, - )], - ]); + // Debounce mechanism requires a LocalSet + let local = LocalSet::new(); + let future = local.run_until(async { + let mut component = TestComponent::new( + &harness, + &terminal, + QueryableBody::new(), + QueryableBodyProps { + content_type: None, + body: &json_response.body, + }, + ); + + // Assert initial state/view + let data = component.data(); + assert!(data.query_available.get()); + assert_eq!(data.query, None); + assert_eq!( + data.parsed_text().as_deref(), + Some("{\n \"greeting\": \"hello\"\n}") + ); + let styles = &TuiContext::get().styles.text_box; + terminal.assert_buffer_lines([ + vec![gutter("1"), " { ".into()], + vec![gutter("2"), " \"greeting\": \"hello\"".into()], + vec![gutter("3"), " } ".into()], + vec![gutter(" "), " ".into()], + vec![ + Span::styled( + "/ to query body", + styles.text.patch(styles.placeholder), + ), + Span::styled(" ", styles.text), + ], + ]); + + // Type something into the query box + component.send_key(KeyCode::Char('/')).assert_empty(); + component.send_text("$.greeting").assert_empty(); + component.send_key(KeyCode::Enter).assert_empty(); + + // Make sure state updated correctly + let data = component.data(); + assert_eq!(data.query, Some("$.greeting".parse().unwrap())); + assert_eq!( + data.parsed_text().as_deref(), + Some("[\n \"hello\"\n]") + ); + assert!(!data.query_focused); + + // Cancelling out of the text box should reset the query value + component.send_key(KeyCode::Char('/')).assert_empty(); + component.send_text("more text").assert_empty(); + component.send_key(KeyCode::Esc).assert_empty(); + let data = component.data(); + assert_eq!(data.query, Some("$.greeting".parse().unwrap())); + assert_eq!(data.query_text_box.data().text(), "$.greeting"); + assert!(!data.query_focused); + + // Check the view again + terminal.assert_buffer_lines([ + vec![gutter("1"), " [ ".into()], + vec![gutter("2"), " \"hello\" ".into()], + vec![gutter("3"), " ] ".into()], + vec![gutter(" "), " ".into()], + vec![Span::styled( + "$.greeting ", + styles.text, + )], + ]); + }); + future.await } /// Render a parsed body with query text box, and load initial query from @@ -490,7 +483,7 @@ mod tests { // We already have another test to check that querying works via typing // in the box, so we just need to make sure state is initialized // correctly here - let component = TestComponent::new( + let mut component = TestComponent::new( &harness, &terminal, PersistedLazy::new(Key, QueryableBody::new()), @@ -499,6 +492,7 @@ mod tests { body: &json_response.body, }, ); + component.drain_draw().assert_empty(); assert_eq!(component.data().query, Some("$.greeting".parse().unwrap())); } } diff --git a/crates/tui/src/view/component/recipe_list.rs b/crates/tui/src/view/component/recipe_list.rs index bfa94fbb..6707ff54 100644 --- a/crates/tui/src/view/component/recipe_list.rs +++ b/crates/tui/src/view/component/recipe_list.rs @@ -1,12 +1,12 @@ use crate::{ context::TuiContext, view::{ - common::{actions::ActionsModal, list::List, Pane}, - component::{primary::PrimaryPane, recipe_pane::RecipeMenuAction}, + common::{actions::ActionsModal, list::List, modal::ModalHandle, Pane}, + component::recipe_pane::RecipeMenuAction, context::UpdateContext, draw::{Draw, DrawMetadata, Generate}, - event::{Child, Event, EventHandler, Update}, - state::select::SelectState, + event::{Child, Emitter, EmitterId, Event, EventHandler, Update}, + state::select::{SelectState, SelectStateEvent, SelectStateEventType}, util::persistence::{Persisted, PersistedLazy}, Component, ViewContext, }, @@ -33,6 +33,7 @@ use std::collections::HashSet; /// implementation. #[derive(Debug)] pub struct RecipeListPane { + emitter_id: EmitterId, /// 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) @@ -47,6 +48,7 @@ pub struct RecipeListPane { /// issue though, it just means it'll be pre-collapsed if the user ever /// adds the folder back. Not worth working around. collapsed: Persisted>, + actions_handle: ModalHandle>, } /// Persisted key for the ID of the selected recipe @@ -65,8 +67,10 @@ impl RecipeListPane { collapsed.build_select_state(recipes), ); Self { + emitter_id: EmitterId::new(), select: persistent.into(), collapsed, + actions_handle: ModalHandle::default(), } } @@ -126,47 +130,57 @@ impl RecipeListPane { impl EventHandler for RecipeListPane { fn update(&mut self, _: &mut UpdateContext, event: Event) -> Update { - let Some(action) = event.action() else { - return Update::Propagate(event); - }; - match action { - Action::LeftClick => { - ViewContext::push_event(Event::new_local( - PrimaryPane::RecipeList, - )); - } - Action::Left => { - self.set_selected_collapsed(CollapseState::Collapse); - } - Action::Right => { - self.set_selected_collapsed(CollapseState::Expand); - } - Action::Toggle => { - self.set_selected_collapsed(CollapseState::Toggle); + if let Some(action) = event.action() { + match action { + Action::LeftClick => self.emit(RecipeListPaneEvent::Click), + Action::Left => { + self.set_selected_collapsed(CollapseState::Collapse); + } + Action::Right => { + self.set_selected_collapsed(CollapseState::Expand); + } + 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, + ), + )); + } + _ => return Update::Propagate(event), } - 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); - ViewContext::open_modal(ActionsModal::new( - RecipeMenuAction::disabled_actions( - recipe.is_some(), - has_body, - ), - )) + } else if let Some(event) = self.select.emitted(&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 + // will do nothing + ViewContext::push_event(Event::HttpSelectRequest(None)); + } + SelectStateEvent::Submit(_) => {} + SelectStateEvent::Toggle(_) => { + self.set_selected_collapsed(CollapseState::Toggle); + } } - _ => return Update::Propagate(event), + } else if let Some(menu_action) = self.actions_handle.emitted(&event) { + // Menu actions are handled by the parent, so forward them + self.emit(RecipeListPaneEvent::MenuAction(*menu_action)); + } else { + return Update::Propagate(event); } Update::Consumed @@ -197,6 +211,22 @@ impl Draw for RecipeListPane { } } +/// Notify parent when this pane is clicked +impl Emitter for RecipeListPane { + type Emitted = RecipeListPaneEvent; + + fn id(&self) -> EmitterId { + self.emitter_id + } +} + +/// 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 /// only stores whatever data is necessary to render the list #[derive(Debug)] @@ -296,12 +326,6 @@ impl Collapsed { &self, recipes: &RecipeTree, ) -> SelectState { - // When highlighting a new recipe, load it from the repo - fn on_select(_: &mut RecipeListItem) { - // If a recipe isn't selected, this will do nothing - ViewContext::push_event(Event::HttpSelectRequest(None)); - } - let items = recipes .iter() // Filter out hidden nodes @@ -315,6 +339,11 @@ impl Collapsed { }) .collect(); - SelectState::builder(items).on_select(on_select).build() + SelectState::builder(items) + .subscribe([ + SelectStateEventType::Select, + SelectStateEventType::Toggle, + ]) + .build() } } diff --git a/crates/tui/src/view/component/recipe_pane.rs b/crates/tui/src/view/component/recipe_pane.rs index 23f05300..a601f330 100644 --- a/crates/tui/src/view/component/recipe_pane.rs +++ b/crates/tui/src/view/component/recipe_pane.rs @@ -10,13 +10,13 @@ use crate::{ context::TuiContext, message::RequestConfig, view::{ - common::{actions::ActionsModal, Pane}, - component::{primary::PrimaryPane, recipe_pane::recipe::RecipeDisplay}, + common::{actions::ActionsModal, modal::ModalHandle, Pane}, + component::recipe_pane::recipe::RecipeDisplay, context::UpdateContext, draw::{Draw, DrawMetadata, Generate, ToStringGenerate}, - event::{Child, Event, EventHandler, Update}, + event::{Child, Emitter, EmitterId, Event, EventHandler, Update}, state::StateCell, - Component, ViewContext, + Component, }, }; use derive_more::Display; @@ -36,9 +36,11 @@ use strum::{EnumCount, EnumIter}; /// empty #[derive(Debug, Default)] pub struct RecipePane { + emitter_id: EmitterId, /// 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)] @@ -84,14 +86,10 @@ impl EventHandler for RecipePane { fn update(&mut self, _: &mut UpdateContext, event: Event) -> Update { if let Some(action) = event.action() { match action { - Action::LeftClick => { - ViewContext::push_event(Event::new_local( - PrimaryPane::Recipe, - )); - } + Action::LeftClick => self.emit(RecipePaneEvent::Click), Action::OpenActions => { let state = self.recipe_state.get_mut(); - ViewContext::open_modal(ActionsModal::new( + self.actions_handle.open(ActionsModal::new( RecipeMenuAction::disabled_actions( state.is_some(), state @@ -102,6 +100,9 @@ impl EventHandler for RecipePane { } _ => return Update::Propagate(event), } + } else if let Some(menu_action) = self.actions_handle.emitted(&event) { + // Menu actions are handled by the parent, so forward them + self.emit(RecipePaneEvent::MenuAction(*menu_action)); } else { return Update::Propagate(event); } @@ -182,6 +183,21 @@ impl<'a> Draw> for RecipePane { } } +impl Emitter for RecipePane { + type Emitted = RecipePaneEvent; + + fn id(&self) -> EmitterId { + self.emitter_id + } +} + +/// Emitted event for the recipe pane component +#[derive(Debug)] +pub enum RecipePaneEvent { + Click, + MenuAction(RecipeMenuAction), +} + /// Template preview state will be recalculated when any of these fields change #[derive(Clone, Debug, PartialEq)] struct RecipeStateKey { diff --git a/crates/tui/src/view/component/recipe_pane/authentication.rs b/crates/tui/src/view/component/recipe_pane/authentication.rs index f1358314..a78a1039 100644 --- a/crates/tui/src/view/component/recipe_pane/authentication.rs +++ b/crates/tui/src/view/component/recipe_pane/authentication.rs @@ -10,7 +10,10 @@ use crate::{ }, context::UpdateContext, draw::{Draw, DrawMetadata, Generate}, - event::{Child, Event, EventHandler, Update}, + event::{ + Child, Emitter, EmitterId, EmitterToken, Event, EventHandler, + Update, + }, state::fixed_select::FixedSelectState, ViewContext, }, @@ -32,11 +35,14 @@ use strum::{EnumCount, EnumIter}; /// Display authentication settings for a recipe #[derive(Debug)] -pub struct AuthenticationDisplay(State); +pub struct AuthenticationDisplay { + emitter_id: EmitterId, + state: State, +} impl AuthenticationDisplay { pub fn new(recipe_id: RecipeId, authentication: Authentication) -> Self { - let inner = match authentication { + let state = match authentication { Authentication::Basic { username, password } => { let username = RecipeTemplate::new( RecipeOverrideKey::auth_basic_username(recipe_id.clone()), @@ -63,14 +69,17 @@ impl AuthenticationDisplay { ), }, }; - Self(inner) + Self { + emitter_id: EmitterId::new(), + state, + } } /// If the user has applied a temporary edit to the auth settings, get the /// override value. Return `None` to use the recipe's stock auth. pub fn override_value(&self) -> Option { - if self.0.is_overridden() { - Some(match &self.0 { + if self.state.is_overridden() { + Some(match &self.state { State::Basic { username, password, .. } => Authentication::Basic { @@ -92,11 +101,13 @@ impl EventHandler for AuthenticationDisplay { fn update(&mut self, _: &mut UpdateContext, event: Event) -> Update { let action = event.action(); if let Some(Action::Edit) = action { - self.0.open_edit_modal(); + self.state.open_edit_modal(self.detach()); } else if let Some(Action::Reset) = action { - self.0.reset_override(); - } else if let Some(SaveAuthenticationOverride(value)) = event.local() { - self.0.set_override(value); + self.state.reset_override(); + } else if let Some(SaveAuthenticationOverride(value)) = + self.emitted(&event) + { + self.state.set_override(value); } else { return Update::Propagate(event); } @@ -104,7 +115,7 @@ impl EventHandler for AuthenticationDisplay { } fn children(&mut self) -> Vec>> { - match &mut self.0 { + match &mut self.state { State::Basic { selected_field, .. } => { vec![selected_field.to_child_mut()] } @@ -120,7 +131,7 @@ impl Draw for AuthenticationDisplay { let [label_area, content_area] = Layout::vertical([Constraint::Length(1), Constraint::Min(0)]) .areas(metadata.area()); - let label = match &self.0 { + let label = match &self.state { State::Basic { username, password, @@ -153,13 +164,28 @@ impl Draw for AuthenticationDisplay { styles.text.title, ) .into(); - if self.0.is_overridden() { + if self.state.is_overridden() { title.push_span(Span::styled(" (edited)", styles.text.hint)); } frame.render_widget(title, label_area); } } +/// Emit events to ourselves for override editing +impl Emitter for AuthenticationDisplay { + type Emitted = SaveAuthenticationOverride; + + fn id(&self) -> EmitterId { + self.emitter_id + } +} + +/// Local event to save a user's override value(s). Triggered from the edit +/// modal. These will be raw string values, consumer has to parse them to +/// templates. +#[derive(Debug)] +pub struct SaveAuthenticationOverride(String); + /// Private to hide enum variants #[derive(Debug)] enum State { @@ -190,7 +216,10 @@ impl State { } /// Open a modal to let the user edit temporary override values - fn open_edit_modal(&self) { + fn open_edit_modal( + &self, + emitter: EmitterToken, + ) { let (label, value) = match &self { Self::Basic { username, @@ -214,11 +243,9 @@ impl State { TextBox::default() .default_value(value.into_owned()) .validator(|value| value.parse::