From 4c8cf68968649d7331b041b3a11b89b39c16ec82 Mon Sep 17 00:00:00 2001 From: Lucas Pickering Date: Tue, 17 Dec 2024 20:07:18 -0500 Subject: [PATCH] Replace callbacks and local events with emitted events Using events instead of callbacks to trigger behavior guarantees that components will have mutable access, and that the TUI will be rerendered after an update. The emitter trait gives each component that can emit events a unique ID, so we can't mix up events of the same type from different instances. It also ensures each component emits events of a single known type, so we get better type coercion from the event trait objects. --- CHANGELOG.md | 2 + crates/core/src/collection/models.rs | 9 +- crates/tui/src/lib.rs | 13 +- crates/tui/src/message.rs | 5 +- crates/tui/src/view.rs | 26 +- crates/tui/src/view/common/actions.rs | 49 ++- crates/tui/src/view/common/button.rs | 18 +- crates/tui/src/view/common/modal.rs | 52 ++- crates/tui/src/view/common/text_box.rs | 292 ++++++------- .../tui/src/view/component/exchange_pane.rs | 27 +- crates/tui/src/view/component/history.rs | 26 +- crates/tui/src/view/component/internal.rs | 10 +- crates/tui/src/view/component/misc.rs | 63 ++- crates/tui/src/view/component/primary.rs | 384 ++++++++++-------- .../tui/src/view/component/profile_select.rs | 73 ++-- .../tui/src/view/component/queryable_body.rs | 174 ++++---- crates/tui/src/view/component/recipe_list.rs | 129 +++--- crates/tui/src/view/component/recipe_pane.rs | 36 +- .../component/recipe_pane/authentication.rs | 104 ++--- .../src/view/component/recipe_pane/body.rs | 24 +- .../src/view/component/recipe_pane/table.rs | 111 ++--- crates/tui/src/view/component/request_view.rs | 8 +- .../tui/src/view/component/response_view.rs | 32 +- crates/tui/src/view/component/root.rs | 37 +- crates/tui/src/view/context.rs | 9 +- crates/tui/src/view/event.rs | 149 +++++-- crates/tui/src/view/state/fixed_select.rs | 59 ++- crates/tui/src/view/state/select.rs | 328 ++++++++------- crates/tui/src/view/test_util.rs | 141 +++++-- crates/tui/src/view/util.rs | 8 +- 30 files changed, 1448 insertions(+), 950 deletions(-) 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::