From 19c10a1e63861c9b852f7fd8712d3498eba5a9ea Mon Sep 17 00:00:00 2001 From: Lucas Pickering Date: Thu, 2 Nov 2023 17:55:54 -0400 Subject: [PATCH] Use text area for text display This required some `RefCell` shenanigans, but I like the pattern. Also, refactored update to add queueing for subsequent events, instead of just propagating up. This feels less icky and allows components to propagate events to their children. --- src/tui/view/component/misc.rs | 30 ++--- src/tui/view/component/mod.rs | 64 +++++----- src/tui/view/component/modal.rs | 16 +-- src/tui/view/component/primary.rs | 43 +++---- src/tui/view/component/request.rs | 61 +++++---- src/tui/view/component/response.rs | 86 ++++++------- src/tui/view/component/root.rs | 12 +- src/tui/view/component/tabs.rs | 27 ++-- src/tui/view/component/text_window.rs | 170 ++++++++++++++------------ src/tui/view/mod.rs | 66 +++++----- src/tui/view/theme.rs | 3 + 11 files changed, 279 insertions(+), 299 deletions(-) diff --git a/src/tui/view/component/misc.rs b/src/tui/view/component/misc.rs index 3303254d..075d704e 100644 --- a/src/tui/view/component/misc.rs +++ b/src/tui/view/component/misc.rs @@ -8,7 +8,7 @@ use crate::{ view::{ component::{ modal::IntoModal, primary::PrimaryPane, root::FullscreenMode, - Component, Draw, Event, Modal, UpdateContext, UpdateOutcome, + Component, Draw, Event, Modal, Update, UpdateContext, }, state::Notification, util::{layout, ButtonBrick, ToTui}, @@ -40,19 +40,18 @@ impl Modal for ErrorModal { } impl Component for ErrorModal { - fn update( - &mut self, - _context: &mut UpdateContext, - event: Event, - ) -> UpdateOutcome { + fn update(&mut self, context: &mut UpdateContext, event: Event) -> Update { match event { // Extra close action Event::Input { action: Some(Action::Submit), .. - } => UpdateOutcome::Propagate(Event::CloseModal), + } => { + context.queue_event(Event::CloseModal); + Update::Consumed + } - _ => UpdateOutcome::Propagate(event), + _ => Update::Propagate(event), } } } @@ -140,11 +139,7 @@ impl Modal for PromptModal { } impl Component for PromptModal { - fn update( - &mut self, - _context: &mut UpdateContext, - event: Event, - ) -> UpdateOutcome { + fn update(&mut self, context: &mut UpdateContext, event: Event) -> Update { match event { // Submit Event::Input { @@ -154,22 +149,23 @@ impl Component for PromptModal { // Submission is handled in on_close. The control flow here is // ugly but it's hard with the top-down nature of modals self.submit = true; - UpdateOutcome::Propagate(Event::CloseModal) + context.queue_event(Event::CloseModal); + Update::Consumed } // Make sure cancel gets propagated to close the modal event @ Event::Input { action: Some(Action::Cancel), .. - } => UpdateOutcome::Propagate(event), + } => Update::Propagate(event), // All other input gets forwarded to the text editor Event::Input { event, .. } => { self.text_area.input(event); - UpdateOutcome::Consumed + Update::Consumed } - _ => UpdateOutcome::Propagate(event), + _ => Update::Propagate(event), } } } diff --git a/src/tui/view/component/mod.rs b/src/tui/view/component/mod.rs index 41cbfcbf..524d0205 100644 --- a/src/tui/view/component/mod.rs +++ b/src/tui/view/component/mod.rs @@ -26,7 +26,11 @@ use crate::{ }, }; use ratatui::prelude::Rect; -use std::fmt::{Debug, Display}; +use std::{ + collections::VecDeque, + fmt::{Debug, Display}, +}; +use tracing::trace; /// The main building block that makes up the view. This is modeled after React, /// with some key differences: @@ -45,13 +49,8 @@ pub trait Component: Debug + Display { /// Update the state of *just* this component according to the message. /// Returned outcome indicates what to do afterwards. Context allows updates /// to trigger side-effects, e.g. launching an HTTP request. - fn update( - &mut self, - _context: &mut UpdateContext, - event: Event, - ) -> UpdateOutcome { - // By default just forward to our parent - UpdateOutcome::Propagate(event) + fn update(&mut self, _context: &mut UpdateContext, event: Event) -> Update { + Update::Propagate(event) } /// Which, if any, of this component's children currently has focus? The @@ -65,19 +64,32 @@ pub trait Component: Debug + Display { /// Mutable context passed to each update call. Allows for triggering side /// effects. -pub struct UpdateContext { +pub struct UpdateContext<'a> { messages_tx: MessageSender, + event_queue: &'a mut VecDeque, } -impl UpdateContext { - pub fn new(messages_tx: MessageSender) -> Self { - Self { messages_tx } +impl<'a> UpdateContext<'a> { + pub fn new( + messages_tx: MessageSender, + event_queue: &'a mut VecDeque, + ) -> Self { + Self { + messages_tx, + event_queue, + } } /// Send a message to trigger an async action pub fn send_message(&mut self, message: Message) { self.messages_tx.send(message); } + + /// Queue a subsequent view event to be handled after the current one + pub fn queue_event(&mut self, event: Event) { + trace!(?event, "Queueing subsequent event"); + self.event_queue.push_back(event); + } } /// Something that can be drawn onto screen as one or more TUI widgets. @@ -104,7 +116,6 @@ pub trait Draw { pub struct DrawContext<'a, 'f> { pub input_engine: &'a InputEngine, pub theme: &'a Theme, - // TODO refcell? pub frame: &'a mut Frame<'f>, } @@ -151,12 +162,6 @@ pub enum Event { /// to implement custom close triggers. CloseModal, - /// Propagated from downstream when the user changes changes in a tab - /// selection. Allows parents to react to the tab change. This does not - /// include the new tab value because that would require generices. You can - /// grab the value from the child though. - TabChanged, - /// Tell the user something informational Notify(Notification), } @@ -164,23 +169,14 @@ pub enum Event { /// The result of a component state update operation. This corresponds to a /// single input [ViewMessage]. #[derive(Debug)] -pub enum UpdateOutcome { +pub enum Update { /// The consuming component updated its state accordingly, and no further /// changes are necessary Consumed, - /// The returned message should be passed to the parent component. This can - /// mean one of two things: - /// - /// - The updated component did not handle the message, and it should - /// bubble up the tree - /// - The updated component *did* make changes according to the message, - /// and is sending a related message up the tree for ripple-effect - /// changes - /// - /// This dual meaning is maybe a little janky. There's an argument that - /// rippled changes should be a separate variant that would cause the - /// caller to reset back to the bottom of the component tree. There's - /// no immediate need for that though so I'm keeping it simpler for - /// now. + /// The message was not consumed by this component, and should be passed to + /// the parent component. While technically possible, this should *not* be + /// used to trigger additional events. Instead, use + /// [UpdateContext::queue_event] for that. That will ensure the entire tree + /// has a chance to respond to the entire event. Propagate(Event), } diff --git a/src/tui/view/component/modal.rs b/src/tui/view/component/modal.rs index 7ae09c92..a9f46521 100644 --- a/src/tui/view/component/modal.rs +++ b/src/tui/view/component/modal.rs @@ -2,7 +2,7 @@ use crate::tui::{ input::Action, view::{ component::{ - Component, Draw, DrawContext, Event, UpdateContext, UpdateOutcome, + Component, Draw, DrawContext, Event, Update, UpdateContext, }, util::centered_rect, }, @@ -97,11 +97,7 @@ impl ModalQueue { } impl Component for ModalQueue { - fn update( - &mut self, - _context: &mut UpdateContext, - event: Event, - ) -> UpdateOutcome { + fn update(&mut self, _context: &mut UpdateContext, event: Event) -> Update { match event { // Close the active modal. If there's no modal open, we'll propagate // the event down @@ -114,20 +110,20 @@ impl Component for ModalQueue { Some(modal) => { // Inform the modal of its terminal status modal.on_close(); - UpdateOutcome::Consumed + Update::Consumed } // Modal wasn't open, so don't consume the event - None => UpdateOutcome::Propagate(event), + None => Update::Propagate(event), } } // Open a new modal Event::OpenModal { modal, priority } => { self.open(modal, priority); - UpdateOutcome::Consumed + Update::Consumed } - _ => UpdateOutcome::Propagate(event), + _ => Update::Propagate(event), } } diff --git a/src/tui/view/component/primary.rs b/src/tui/view/component/primary.rs index 5092eca7..5a232dab 100644 --- a/src/tui/view/component/primary.rs +++ b/src/tui/view/component/primary.rs @@ -9,7 +9,7 @@ use crate::{ component::{ request::{RequestPane, RequestPaneProps}, response::{ResponsePane, ResponsePaneProps}, - Component, Draw, Event, UpdateContext, UpdateOutcome, + Component, Draw, Event, Update, UpdateContext, }, state::{FixedSelect, RequestState, StatefulList, StatefulSelect}, util::{layout, BlockBrick, ListBrick, ToTui}, @@ -110,11 +110,7 @@ impl PrimaryView { } impl Component for PrimaryView { - fn update( - &mut self, - context: &mut UpdateContext, - event: Event, - ) -> UpdateOutcome { + fn update(&mut self, context: &mut UpdateContext, event: Event) -> Update { match event { // Send HTTP request (bubbled up from child) Event::HttpSendRequest => { @@ -127,7 +123,7 @@ impl Component for PrimaryView { .map(|profile| profile.id.clone()), }); } - UpdateOutcome::Consumed + Update::Consumed } // Input messages @@ -136,17 +132,17 @@ impl Component for PrimaryView { .. } => { self.selected_pane.previous(); - UpdateOutcome::Consumed + Update::Consumed } Event::Input { action: Some(Action::NextPane), .. } => { self.selected_pane.next(); - UpdateOutcome::Consumed + Update::Consumed } - _ => UpdateOutcome::Propagate(event), + _ => Update::Propagate(event), } } @@ -244,27 +240,23 @@ impl ProfileListPane { } impl Component for ProfileListPane { - fn update( - &mut self, - _context: &mut UpdateContext, - event: Event, - ) -> UpdateOutcome { + fn update(&mut self, _context: &mut UpdateContext, event: Event) -> Update { match event { Event::Input { action: Some(Action::Up), .. } => { self.profiles.previous(); - UpdateOutcome::Consumed + Update::Consumed } Event::Input { action: Some(Action::Down), .. } => { self.profiles.next(); - UpdateOutcome::Consumed + Update::Consumed } - _ => UpdateOutcome::Propagate(event), + _ => Update::Propagate(event), } } } @@ -306,18 +298,14 @@ impl RecipeListPane { } impl Component for RecipeListPane { - fn update( - &mut self, - context: &mut UpdateContext, - event: Event, - ) -> UpdateOutcome { - let mut load_from_repo = |pane: &RecipeListPane| -> UpdateOutcome { + fn update(&mut self, context: &mut UpdateContext, event: Event) -> Update { + let mut load_from_repo = |pane: &RecipeListPane| -> Update { if let Some(recipe) = pane.recipes.selected() { context.send_message(Message::RepositoryStartLoad { recipe_id: recipe.id.clone(), }); } - UpdateOutcome::Consumed + Update::Consumed }; match event { @@ -327,7 +315,8 @@ impl Component for RecipeListPane { } => { // Parent has to be responsible for sending the request because // it also needs access to the profile list state - UpdateOutcome::Propagate(Event::HttpSendRequest) + context.queue_event(Event::HttpSendRequest); + Update::Consumed } Event::Input { action: Some(Action::Up), @@ -343,7 +332,7 @@ impl Component for RecipeListPane { self.recipes.next(); load_from_repo(self) } - _ => UpdateOutcome::Propagate(event), + _ => Update::Propagate(event), } } } diff --git a/src/tui/view/component/request.rs b/src/tui/view/component/request.rs index 61c3bf43..8a22b4c5 100644 --- a/src/tui/view/component/request.rs +++ b/src/tui/view/component/request.rs @@ -1,5 +1,5 @@ use crate::{ - config::RequestRecipe, + config::{RequestRecipe, RequestRecipeId}, tui::{ input::Action, view::{ @@ -8,7 +8,7 @@ use crate::{ root::FullscreenMode, tabs::Tabs, text_window::{TextWindow, TextWindowProps}, - Component, Draw, Event, UpdateContext, UpdateOutcome, + Component, Draw, Event, Update, UpdateContext, }, state::FixedSelect, util::{layout, BlockBrick, ToTui}, @@ -19,7 +19,6 @@ use crate::{ use derive_more::Display; use ratatui::{ prelude::{Constraint, Direction, Rect}, - text::Text, widgets::Paragraph, }; use strum::EnumIter; @@ -29,7 +28,7 @@ use strum::EnumIter; #[display(fmt = "RequestPane")] pub struct RequestPane { tabs: Tabs, - text_window: TextWindow, + text_window: TextWindow, } pub struct RequestPaneProps<'a> { @@ -37,9 +36,7 @@ pub struct RequestPaneProps<'a> { pub selected_recipe: Option<&'a RequestRecipe>, } -#[derive( - Copy, Clone, Debug, Default, derive_more::Display, EnumIter, PartialEq, -)] +#[derive(Copy, Clone, Debug, Default, Display, EnumIter, PartialEq)] enum Tab { #[default] Body, @@ -50,27 +47,20 @@ enum Tab { impl FixedSelect for Tab {} impl Component for RequestPane { - fn update( - &mut self, - _context: &mut UpdateContext, - event: Event, - ) -> UpdateOutcome { + fn update(&mut self, context: &mut UpdateContext, event: Event) -> Update { match event { // Toggle fullscreen Event::Input { action: Some(Action::Fullscreen), .. - } => UpdateOutcome::Propagate(Event::ToggleFullscreen( - FullscreenMode::Request, - )), - - // Reset content state when tab changes - Event::TabChanged => { - self.text_window.reset(); - UpdateOutcome::Consumed + } => { + context.queue_event(Event::ToggleFullscreen( + FullscreenMode::Request, + )); + Update::Consumed } - _ => UpdateOutcome::Propagate(event), + _ => Update::Propagate(event), } } @@ -118,18 +108,27 @@ impl<'a> Draw> for RequestPane { self.tabs.draw(context, (), tabs_chunk); // Request content - let text: Text = match self.tabs.selected() { + match self.tabs.selected() { Tab::Body => { - recipe.body.as_deref().map(Text::from).unwrap_or_default() + let text = recipe.body.as_deref().unwrap_or_default(); + self.text_window.draw( + context, + TextWindowProps { + key: &recipe.id, + text, + }, + content_chunk, + ); } - Tab::Query => recipe.query.to_tui(context), - Tab::Headers => recipe.headers.to_tui(context), - }; - self.text_window.draw( - context, - TextWindowProps { text }, - content_chunk, - ); + Tab::Query => context.frame.render_widget( + Paragraph::new(recipe.query.to_tui(context)), + content_chunk, + ), + Tab::Headers => context.frame.render_widget( + Paragraph::new(recipe.headers.to_tui(context)), + content_chunk, + ), + } } } } diff --git a/src/tui/view/component/response.rs b/src/tui/view/component/response.rs index 85524b5f..faffcaaa 100644 --- a/src/tui/view/component/response.rs +++ b/src/tui/view/component/response.rs @@ -1,22 +1,25 @@ -use crate::tui::{ - input::Action, - view::{ - component::{ - primary::PrimaryPane, - root::FullscreenMode, - tabs::Tabs, - text_window::{TextWindow, TextWindowProps}, - Component, Draw, Event, UpdateContext, UpdateOutcome, +use crate::{ + http::RequestId, + tui::{ + input::Action, + view::{ + component::{ + primary::PrimaryPane, + root::FullscreenMode, + tabs::Tabs, + text_window::{TextWindow, TextWindowProps}, + Component, Draw, Event, Update, UpdateContext, + }, + state::{FixedSelect, RequestState}, + util::{layout, BlockBrick, ToTui}, + DrawContext, }, - state::{FixedSelect, RequestState}, - util::{layout, BlockBrick, ToTui}, - DrawContext, }, }; use derive_more::Display; use ratatui::{ prelude::{Alignment, Constraint, Direction, Rect}, - text::{Line, Text}, + text::Line, widgets::{Paragraph, Wrap}, }; use strum::EnumIter; @@ -27,7 +30,7 @@ use strum::EnumIter; #[display(fmt = "ResponsePane")] pub struct ResponsePane { tabs: Tabs, - text_window: TextWindow, + text_window: TextWindow, } pub struct ResponsePaneProps<'a> { @@ -35,9 +38,7 @@ pub struct ResponsePaneProps<'a> { pub active_request: Option<&'a RequestState>, } -#[derive( - Copy, Clone, Debug, Default, derive_more::Display, EnumIter, PartialEq, -)] +#[derive(Copy, Clone, Debug, Default, Display, EnumIter, PartialEq)] enum Tab { #[default] Body, @@ -47,27 +48,20 @@ enum Tab { impl FixedSelect for Tab {} impl Component for ResponsePane { - fn update( - &mut self, - _context: &mut UpdateContext, - event: Event, - ) -> UpdateOutcome { + fn update(&mut self, context: &mut UpdateContext, event: Event) -> Update { match event { // Toggle fullscreen Event::Input { action: Some(Action::Fullscreen), .. - } => UpdateOutcome::Propagate(Event::ToggleFullscreen( - FullscreenMode::Response, - )), - - // Reset content state when tab changes - Event::TabChanged => { - self.text_window.reset(); - UpdateOutcome::Consumed + } => { + context.queue_event(Event::ToggleFullscreen( + FullscreenMode::Response, + )); + Update::Consumed } - _ => UpdateOutcome::Propagate(event), + _ => Update::Propagate(event), } } @@ -168,18 +162,24 @@ impl<'a> Draw> for ResponsePane { self.tabs.draw(context, (), tabs_chunk); // Main content for the response - let text: Text = match self.tabs.selected() { - Tab::Body => pretty_body - .as_deref() - .unwrap_or(response.body.text()) - .into(), - Tab::Headers => response.headers.to_tui(context), - }; - self.text_window.draw( - context, - TextWindowProps { text }, - content_chunk, - ); + match self.tabs.selected() { + Tab::Body => self.text_window.draw( + context, + TextWindowProps { + key: &record.id, + // Use the pretty body if available. If not, + // fall back to the ugly one + text: pretty_body + .as_deref() + .unwrap_or(response.body.text()), + }, + content_chunk, + ), + Tab::Headers => context.frame.render_widget( + Paragraph::new(response.headers.to_tui(context)), + content_chunk, + ), + } } // Sadge diff --git a/src/tui/view/component/root.rs b/src/tui/view/component/root.rs index 2caceca1..f87f2d5f 100644 --- a/src/tui/view/component/root.rs +++ b/src/tui/view/component/root.rs @@ -10,7 +10,7 @@ use crate::{ primary::{PrimaryView, PrimaryViewProps}, request::RequestPaneProps, response::ResponsePaneProps, - Component, Draw, Event, UpdateContext, UpdateOutcome, + Component, Draw, Event, Update, UpdateContext, }, state::RequestState, util::layout, @@ -104,11 +104,7 @@ impl Root { } impl Component for Root { - fn update( - &mut self, - context: &mut UpdateContext, - event: Event, - ) -> UpdateOutcome { + fn update(&mut self, context: &mut UpdateContext, event: Event) -> Update { match event { Event::Init => { // Load the initial state for the selected recipe @@ -151,9 +147,9 @@ impl Component for Root { Event::Input { .. } => {} // There shouldn't be anything left unhandled. Bubble up to log it - _ => return UpdateOutcome::Propagate(event), + _ => return Update::Propagate(event), } - UpdateOutcome::Consumed + Update::Consumed } fn children(&mut self) -> Vec<&mut dyn Component> { diff --git a/src/tui/view/component/tabs.rs b/src/tui/view/component/tabs.rs index 1496ece7..262be5b1 100644 --- a/src/tui/view/component/tabs.rs +++ b/src/tui/view/component/tabs.rs @@ -2,7 +2,7 @@ use crate::tui::{ input::Action, view::{ component::{ - Component, Draw, DrawContext, Event, UpdateContext, UpdateOutcome, + Component, Draw, DrawContext, Event, Update, UpdateContext, }, state::{FixedSelect, StatefulSelect}, }, @@ -25,35 +25,24 @@ impl Tabs { } impl Component for Tabs { - fn update( - &mut self, - _context: &mut UpdateContext, - event: Event, - ) -> UpdateOutcome { + fn update(&mut self, _context: &mut UpdateContext, event: Event) -> Update { match event { Event::Input { action: Some(action), .. } => match action { - // Propagate TabChanged event if appropriate Action::Left => { - if self.tabs.previous() { - UpdateOutcome::Propagate(Event::TabChanged) - } else { - UpdateOutcome::Consumed - } + self.tabs.previous(); + Update::Consumed } Action::Right => { - if self.tabs.next() { - UpdateOutcome::Propagate(Event::TabChanged) - } else { - UpdateOutcome::Consumed - } + self.tabs.next(); + Update::Consumed } - _ => UpdateOutcome::Propagate(event), + _ => Update::Propagate(event), }, - _ => UpdateOutcome::Propagate(event), + _ => Update::Propagate(event), } } } diff --git a/src/tui/view/component/text_window.rs b/src/tui/view/component/text_window.rs index b79680ba..c50432aa 100644 --- a/src/tui/view/component/text_window.rs +++ b/src/tui/view/component/text_window.rs @@ -1,114 +1,124 @@ use crate::tui::{ input::Action, view::{ - component::{Component, Draw, DrawContext, Event, UpdateOutcome}, - util::layout, + component::{Component, Draw, DrawContext, Event, Update}, + theme::Theme, }, }; use derive_more::Display; -use ratatui::{ - prelude::{Alignment, Constraint, Direction, Rect}, - text::{Line, Text}, - widgets::Paragraph, -}; -use std::cmp; +use ratatui::{prelude::Rect, style::Style}; +use std::{cell::RefCell, fmt::Debug, ops::Deref}; +use tui_textarea::TextArea; -/// A view of text that can be scrolled through vertically. This should be used -/// for *immutable* text only. -/// -/// TODO try TextArea instead? -/// -/// Some day hopefully we can get rid of this in favor of a widget from ratatui -/// https://github.com/ratatui-org/ratatui/issues/174 -#[derive(Debug, Default, Display)] +/// A scrollable (but not editable) block of text. The `Key` parameter is used +/// to tell the text window when to reset its internal state. The type should be +/// cheap to compare (e.g. a `Uuid` or short string), and the value is passed to +/// the `draw` function as a prop. Whenever the value changes, the text buffer +/// will be reset to the content of the `text` prop on that draw. As such, the +/// key and text should be in sync: when one changes, the other does too. +#[derive(Debug, Display)] #[display(fmt = "TextWindow")] -pub struct TextWindow { - offset_y: u16, +pub struct TextWindow { + /// State is stored in a refcell so it can be mutated during the draw. It + /// can be very hard to drill down the text content in the update phase, so + /// this makes it transparent to the caller. + /// + /// `RefCell` is safe here because its accesses are never held across + /// phases, and all view code is synchronous. + state: RefCell>>, } -impl TextWindow { - /// Reset scroll state - pub fn reset(&mut self) { - self.offset_y = 0; - } - - fn up(&mut self) { - self.offset_y = self.offset_y.saturating_sub(1); - } - - fn down(&mut self) { - self.offset_y += 1; - } +pub struct TextWindowProps<'a, Key> { + pub key: &'a Key, + pub text: &'a str, } -pub struct TextWindowProps<'a> { - pub text: Text<'a>, +#[derive(Debug)] +struct State { + key: Key, + text_area: TextArea<'static>, } -impl Component for TextWindow { +impl Component for TextWindow { fn update( &mut self, _context: &mut super::UpdateContext, event: Event, - ) -> UpdateOutcome { - match event { - Event::Input { - action: Some(action), - .. - } => match action { - Action::Up => { - self.up(); - UpdateOutcome::Consumed + ) -> Update { + // Don't handle any events if state isn't initialized yet + if let Some(state) = self.state.get_mut() { + match event { + Event::Input { + action: Some(Action::Up), + .. + } => { + state.text_area.scroll((-1, 0)); + Update::Consumed } - Action::Down => { - self.down(); - UpdateOutcome::Consumed + Event::Input { + action: Some(Action::Down), + .. + } => { + state.text_area.scroll((1, 0)); + Update::Consumed } - _ => UpdateOutcome::Propagate(event), - }, - _ => UpdateOutcome::Propagate(event), + _ => Update::Propagate(event), + } + } else { + Update::Propagate(event) } } } -impl<'a> Draw> for TextWindow { +impl<'a, Key: Clone + Debug + PartialEq> Draw> + for TextWindow +{ fn draw( &self, context: &mut DrawContext, - props: TextWindowProps<'a>, + props: TextWindowProps<'a, Key>, chunk: Rect, ) { - let num_lines = props.text.lines.len() as u16; + // This uses a reactive pattern to initialize the text area. The key + // should change whenever the text does, and that signals to rebuild the + // text area. - let [gutter_chunk, _, text_chunk] = layout( - chunk, - Direction::Horizontal, - [ - // Size gutter based on max line number width - Constraint::Length( - (num_lines as f32).log10().floor() as u16 + 1, - ), - Constraint::Length(1), // Spacer gap - Constraint::Min(0), - ], - ); + // Check if the data is either uninitialized or outdated + { + let mut state = self.state.borrow_mut(); + match state.deref() { + Some(state) if &state.key == props.key => {} + _ => { + // (Re)create the state + *state = Some(State { + key: props.key.clone(), + text_area: init_text_area(context.theme, props.text), + }); + } + } + } - // Add line numbers to the gutter - let first_line = self.offset_y + 1; - let last_line = cmp::min(first_line + chunk.height, num_lines); - context.frame.render_widget( - Paragraph::new( - (first_line..=last_line) - .map(|n| n.to_string().into()) - .collect::>(), - ) - .alignment(Alignment::Right), - gutter_chunk, - ); + // Unwrap is safe because we know we just initialized state above + let state = self.state.borrow(); + let text_area = &state.as_ref().unwrap().text_area; + context.frame.render_widget(text_area.widget(), chunk); + } +} - context.frame.render_widget( - Paragraph::new(props.text).scroll((self.offset_y, 0)), - text_chunk, - ); +/// Derive impl applies unnecessary bound on the generic parameter +impl Default for TextWindow { + fn default() -> Self { + Self { + state: RefCell::new(None), + } } } + +fn init_text_area(theme: &Theme, text: &str) -> TextArea<'static> { + let mut text_area: TextArea = text.lines().map(str::to_owned).collect(); + // Hide cursor/line selection highlights + text_area.set_cursor_style(Style::default()); + text_area.set_cursor_line_style(Style::default()); + text_area.set_line_number_style(theme.line_number_style); + text_area +} diff --git a/src/tui/view/mod.rs b/src/tui/view/mod.rs index b48496d2..5ebf6b26 100644 --- a/src/tui/view/mod.rs +++ b/src/tui/view/mod.rs @@ -13,8 +13,8 @@ use crate::{ message::MessageSender, view::{ component::{ - Component, Draw, DrawContext, Event, IntoModal, Root, - UpdateContext, UpdateOutcome, + Component, Draw, DrawContext, Event, IntoModal, Root, Update, + UpdateContext, }, state::Notification, theme::Theme, @@ -22,14 +22,14 @@ use crate::{ }, }; use ratatui::Frame; -use std::fmt::Debug; +use std::{collections::VecDeque, fmt::Debug}; use tracing::{error, trace, trace_span}; /// Primary entrypoint for the view. This contains the main draw functions, as /// well as bindings for externally modifying the view state. We use a component /// architecture based on React, meaning the view is responsible for managing /// its own state. Certain global state (e.g. the request repository) is managed -/// by the controll and exposed via message passing. +/// by the controller and exposed via event passing. #[derive(Debug)] pub struct View { messages_tx: MessageSender, @@ -115,24 +115,28 @@ impl View { /// Process a view event by passing it to the root component and letting /// it pass it down the tree fn handle_event(&mut self, event: Event) { - let span = trace_span!("View event", ?event); - span.in_scope(|| { - let mut context = self.update_context(); - match Self::update_all(&mut self.root, &mut context, event) { - UpdateOutcome::Consumed => { - trace!("View event consumed") - } - // Consumer didn't eat the event - huh? - UpdateOutcome::Propagate(_) => { - error!("View event was unhandled"); - } - } - }); - } + let mut event_queue: VecDeque = [event].into(); - /// Context object passed to each update call - fn update_context(&self) -> UpdateContext { - UpdateContext::new(self.messages_tx.clone()) + // Each event being handled could potentially queue more. Keep going + // until the queue is drained + while let Some(event) = event_queue.pop_front() { + let span = trace_span!("View event", ?event); + span.in_scope(|| { + let mut context = UpdateContext::new( + self.messages_tx.clone(), + &mut event_queue, + ); + match Self::update_all(&mut self.root, &mut context, event) { + Update::Consumed => { + trace!("View event consumed") + } + // Consumer didn't eat the event - huh? + Update::Propagate(_) => { + error!("View event was unhandled"); + } + } + }); + } } /// Update the state of a component *and* its children, starting at the @@ -142,18 +146,20 @@ impl View { component: &mut dyn Component, context: &mut UpdateContext, mut event: Event, - ) -> UpdateOutcome { + ) -> Update { // If we have a child, send them the event. If not, eat it ourselves for child in component.children() { let outcome = Self::update_all(child, context, event); // RECURSION - if let UpdateOutcome::Propagate(returned) = outcome { - // Keep going to the next child. It's possible the child - // returned something other than the original event, which - // we'll just pass along anyway. - event = returned; - } else { - trace!(%child, "View event consumed"); - return outcome; + match outcome { + Update::Propagate(returned) => { + // Keep going to the next child. It's possible the child + // returned something other than the original event, which + // we'll just pass along anyway. + event = returned; + } + Update::Consumed => { + return outcome; + } } } diff --git a/src/tui/view/theme.rs b/src/tui/view/theme.rs index 3b4b9812..dd84d0df 100644 --- a/src/tui/view/theme.rs +++ b/src/tui/view/theme.rs @@ -6,6 +6,8 @@ pub struct Theme { pub pane_border_style: Style, pub pane_border_focus_style: Style, pub text_highlight_style: Style, + /// Style for line numbers on large text areas + pub line_number_style: Style, pub list_highlight_symbol: &'static str, } @@ -30,6 +32,7 @@ impl Default for Theme { .bg(Color::LightGreen) .fg(Color::Black) .add_modifier(Modifier::BOLD), + line_number_style: Style::default(), list_highlight_symbol: ">> ", } }