Skip to content

Commit

Permalink
Replace callbacks and local events with emitted events
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
LucasPickering committed Dec 18, 2024
1 parent b6b8e16 commit 4c8cf68
Show file tree
Hide file tree
Showing 30 changed files with 1,448 additions and 950 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
9 changes: 8 additions & 1 deletion crates/core/src/collection/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
13 changes: 9 additions & 4 deletions crates/tui/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ use std::{
use tokio::{
select,
sync::mpsc::{self, UnboundedReceiver},
time,
task, time,
};
use tracing::{debug, error, info, info_span, trace};

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down
5 changes: 1 addition & 4 deletions crates/tui/src/message.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -113,9 +113,6 @@ pub enum Message {
action: Option<Action>,
},

/// Trigger a localized UI event
Local(Box<dyn 'static + LocalEvent + Send + Sync>),

/// Send an informational notification to the user
Notify(String),

Expand Down
26 changes: 10 additions & 16 deletions crates/tui/src/view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -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
Expand All @@ -142,11 +141,6 @@ impl View {
ViewContext::push_event(Event::Notify(notification));
}

/// Trigger a localized UI event
pub fn local(&mut self, event: Box<dyn LocalEvent>) {
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.
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
49 changes: 37 additions & 12 deletions crates/tui/src/view/common/actions.rs
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -17,6 +20,7 @@ use ratatui::{
/// is defined by the generic parameter
#[derive(Debug)]
pub struct ActionsModal<T: FixedSelect> {
emitter_id: EmitterId,
/// Join the list of global actions into the given one
actions: Component<FixedSelectState<T, ListState>>,
}
Expand All @@ -25,17 +29,11 @@ impl<T: FixedSelect> ActionsModal<T> {
/// 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(),
}
Expand All @@ -62,7 +60,25 @@ where
}
}

impl<T: FixedSelect> EventHandler for ActionsModal<T> {
impl<T> EventHandler for ActionsModal<T>
where
T: FixedSelect,
ActionsModal<T>: 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<Component<Child<'_>>> {
vec![self.actions.to_child_mut()]
}
Expand All @@ -82,3 +98,12 @@ where
);
}
}

impl<T: FixedSelect> Emitter for ActionsModal<T> {
/// Emit the action itself
type Emitted = T;

fn id(&self) -> EmitterId {
self.emitter_id
}
}
18 changes: 13 additions & 5 deletions crates/tui/src/view/common/button.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down Expand Up @@ -51,6 +50,7 @@ impl<'a> Generate for Button<'a> {
/// type `T`.
#[derive(Debug, Default)]
pub struct ButtonGroup<T: FixedSelect> {
emitter_id: EmitterId,
select: FixedSelectState<T>,
}

Expand All @@ -64,9 +64,7 @@ impl<T: FixedSelect> EventHandler for ButtonGroup<T> {
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),
}
Expand Down Expand Up @@ -104,3 +102,13 @@ impl<T: FixedSelect> Draw for ButtonGroup<T> {
}
}
}

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

fn id(&self) -> EmitterId {
self.emitter_id
}
}
52 changes: 50 additions & 2 deletions crates/tui/src/view/common/modal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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<T: Emitter> {
/// Track the emitter ID of the opened modal, so we can check for emitted
/// events from it later. This is `None` on initialization. Note: this does
/// *not* get cleared when a modal is closed, because that requires extra
/// plumbing but would not actually accomplish anything. Once a modal is
/// closed, it won't be emitting anymore so there's no harm in hanging onto
/// its ID.
emitter: Option<EmitterToken<T::Emitted>>,
}

impl<T: Emitter> ModalHandle<T> {
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<T: Emitter> Default for ModalHandle<T> {
fn default() -> Self {
Self { emitter: None }
}
}
Loading

0 comments on commit 4c8cf68

Please sign in to comment.