diff --git a/src/template.rs b/src/template.rs index c432b41d..6059b565 100644 --- a/src/template.rs +++ b/src/template.rs @@ -27,6 +27,7 @@ static TEMPLATE_REGEX: OnceLock = OnceLock::new(); /// A string that can contain templated content #[derive(Clone, Debug, Deref, Display, From, Serialize, Deserialize)] +#[deref(forward)] pub struct TemplateString(String); /// A little container struct for all the data that the user can access via diff --git a/src/tui/input.rs b/src/tui/input.rs index 07614242..4abe2f32 100644 --- a/src/tui/input.rs +++ b/src/tui/input.rs @@ -31,6 +31,7 @@ impl InputEngine { }), }, InputBinding::new(KeyCode::Char('r'), Action::ReloadCollection), + InputBinding::new(KeyCode::Char(' '), Action::Fullscreen), InputBinding::new(KeyCode::BackTab, Action::FocusPrevious), InputBinding::new(KeyCode::Tab, Action::FocusNext), InputBinding::new(KeyCode::Up, Action::Up), @@ -105,6 +106,8 @@ pub enum Action { Right, /// Do a thing. E.g. select an item in a list Interact, + /// Embiggen a pane + Fullscreen, /// Close the current modal/dialog/etc. Cancel, } diff --git a/src/tui/view/component/misc.rs b/src/tui/view/component/misc.rs index 043ba6d4..bccf2068 100644 --- a/src/tui/view/component/misc.rs +++ b/src/tui/view/component/misc.rs @@ -16,6 +16,7 @@ use crate::{ }, }, }; +use derive_more::Display; use itertools::Itertools; use ratatui::{ prelude::{Alignment, Constraint, Direction, Rect}, @@ -24,7 +25,8 @@ use ratatui::{ use std::fmt::Debug; use tui_textarea::TextArea; -#[derive(Debug)] +#[derive(Debug, Display)] +#[display(fmt = "ErrorModal")] pub struct ErrorModal(anyhow::Error); impl Modal for ErrorModal { @@ -41,7 +43,7 @@ impl Component for ErrorModal { fn update(&mut self, message: ViewMessage) -> UpdateOutcome { match message { // Extra close action - ViewMessage::InputAction { + ViewMessage::Input { action: Some(Action::Interact), .. } => UpdateOutcome::Propagate(ViewMessage::CloseModal), @@ -94,13 +96,13 @@ impl IntoModal for anyhow::Error { } /// Inner state for the prompt modal -#[derive(Debug)] +#[derive(Debug, Display)] +#[display(fmt = "PromptModal")] pub struct PromptModal { - // Prompt currently being shown + /// Prompt currently being shown prompt: Prompt, /// A queue of additional prompts to shown. If the queue is populated, /// closing one prompt will open a the next one. - // queue: VecDeque, text_area: TextArea<'static>, /// Flag set before closing to indicate if we should submit in `on_close`` submit: bool, @@ -142,7 +144,7 @@ impl Component for PromptModal { fn update(&mut self, message: ViewMessage) -> UpdateOutcome { match message { // Submit - ViewMessage::InputAction { + ViewMessage::Input { action: Some(Action::Interact), .. } => { @@ -153,7 +155,7 @@ impl Component for PromptModal { } // All other input gets forwarded to the text editor (except cancel) - ViewMessage::InputAction { event, action } + ViewMessage::Input { event, action } if action != Some(Action::Cancel) => { self.text_area.input(event); @@ -201,6 +203,7 @@ impl Draw for HelpText { Action::ReloadCollection, Action::FocusNext, Action::FocusPrevious, + Action::Fullscreen, Action::Cancel, ]; let text = actions diff --git a/src/tui/view/component/mod.rs b/src/tui/view/component/mod.rs index f34bba40..eecf88ec 100644 --- a/src/tui/view/component/mod.rs +++ b/src/tui/view/component/mod.rs @@ -3,37 +3,28 @@ mod misc; mod modal; mod primary; +mod request; +mod response; +mod root; pub use modal::{IntoModal, Modal}; +pub use root::Root; use crate::{ - config::{RequestCollection, RequestRecipeId}, + config::RequestRecipeId, tui::{ input::Action, message::Message, view::{ - component::{ - misc::{HelpText, NotificationText}, - modal::ModalQueue, - primary::{ - ListPaneProps, ProfileListPane, RecipeListPane, - RequestPane, RequestPaneProps, ResponsePane, - ResponsePaneProps, - }, - }, - state::{Notification, PrimaryPane, RequestState, StatefulSelect}, - util::layout, + component::root::RootMode, + state::{Notification, RequestState}, Frame, RenderContext, }, }, }; use crossterm::event::Event; -use ratatui::prelude::{Constraint, Direction, Rect}; -use std::{ - collections::{hash_map::Entry, HashMap}, - fmt::Debug, -}; -use tracing::trace; +use ratatui::prelude::Rect; +use std::fmt::{Debug, Display}; /// The main building block that makes up the view. This is modeled after React, /// with some key differences: @@ -45,7 +36,10 @@ use tracing::trace; /// [Component::update_all] and [Component::update]. This happens during the /// message phase of the TUI. /// - Rendering is provided by a separate trait: [Draw] -pub trait Component: Debug { +/// +/// Requires `Display` impl for tracing. Typically the impl can just be the +/// component name. +pub trait Component: Debug + Display { /// Update the state of *just* this component according to the message. /// Returned outcome indicates what to do afterwards. fn update(&mut self, message: ViewMessage) -> UpdateOutcome { @@ -53,27 +47,6 @@ pub trait Component: Debug { UpdateOutcome::Propagate(message) } - /// Update the state of this component *and* its children, starting at the - /// lowest descendant. Recursively walk up the tree until a component - /// consumes the message. - fn update_all(&mut self, mut message: ViewMessage) -> UpdateOutcome { - // If we have a child, send them the message. If not, eat it ourselves - for child in self.focused_children() { - let outcome = child.update_all(message); - if let UpdateOutcome::Propagate(returned) = outcome { - // Keep going to the next child. It's possible the child - // returned something other than the original message, which - // we'll just pass along anyway. - message = returned; - } else { - trace!(?child, "View message consumed"); - return outcome; - } - } - // None of our children handled it, we'll take it ourselves - self.update(message) - } - /// Which, if any, of this component's children currently has focus? The /// focused component will receive first dibs on any update messages, in /// the order of the returned list. If none of the children consume the @@ -119,7 +92,7 @@ pub trait Draw { pub enum ViewMessage { /// Input from the user, which may or may not correspond to a bound action. /// Most components just care about the action, but some require raw input - InputAction { + Input { event: Event, action: Option, }, @@ -133,6 +106,9 @@ pub enum ViewMessage { state: RequestState, }, + /// Change the root view mode + OpenView(RootMode), + /// Show a modal to the user OpenModal(Box), /// Close the current modal. This is useful for the contents of the modal @@ -170,227 +146,3 @@ pub enum UpdateOutcome { /// so it can be handled asyncronously. SideEffect(Message), } - -/// The root view component -#[derive(Debug)] -pub struct Root { - // ===== Own State ===== - /// Cached request state. A recipe will appear in this map if two - /// conditions are met: - /// - It has at least one *successful* request in history - /// - It has beed focused by the user during this process - /// This will be populated on-demand when a user selects a recipe in the - /// list. - active_requests: HashMap, - primary_panes: StatefulSelect, - - // ==== Children ===== - profile_list_pane: ProfileListPane, - recipe_list_pane: RecipeListPane, - request_pane: RequestPane, - response_pane: ResponsePane, - modal_queue: ModalQueue, - notification_text: Option, -} - -impl Root { - pub fn new(collection: &RequestCollection) -> Self { - Self { - // State - active_requests: HashMap::new(), - primary_panes: StatefulSelect::new(), - - // Children - profile_list_pane: ProfileListPane::new( - collection.profiles.to_owned(), - ), - recipe_list_pane: RecipeListPane::new( - collection.recipes.to_owned(), - ), - request_pane: RequestPane::new(), - response_pane: ResponsePane::new(), - modal_queue: ModalQueue::new(), - notification_text: None, - } - } - - /// Get the request state to be displayed - fn active_request(&self) -> Option<&RequestState> { - let recipe = self.recipe_list_pane.selected_recipe()?; - self.active_requests.get(&recipe.id) - } -} - -impl Component for Root { - fn update(&mut self, message: ViewMessage) -> UpdateOutcome { - match message { - // HTTP state messages - ViewMessage::HttpSendRequest => { - if let Some(recipe) = self.recipe_list_pane.selected_recipe() { - return UpdateOutcome::SideEffect( - Message::HttpBeginRequest { - // Reach into the children to grab state (ugly!) - recipe_id: recipe.id.clone(), - profile_id: self - .profile_list_pane - .selected_profile() - .map(|profile| profile.id.clone()), - }, - ); - } - } - ViewMessage::HttpSetState { recipe_id, state } => { - // Update the state if any of these conditions match: - // - There's nothing there yet - // - This is a new request - // - This is an update to the request already in place - match self.active_requests.entry(recipe_id) { - Entry::Vacant(entry) => { - entry.insert(state); - } - Entry::Occupied(mut entry) - if state.is_initial() - || entry.get().id() == state.id() => - { - entry.insert(state); - } - Entry::Occupied(_) => { - // State is already holding a different request, throw - // this update away - } - } - } - - // Other state messages - ViewMessage::Notify(notification) => { - self.notification_text = - Some(NotificationText::new(notification)); - } - - // Input messages - ViewMessage::InputAction { - action: Some(Action::Quit), - .. - } => return UpdateOutcome::SideEffect(Message::Quit), - ViewMessage::InputAction { - action: Some(Action::ReloadCollection), - .. - } => { - return UpdateOutcome::SideEffect( - Message::CollectionStartReload, - ) - } - ViewMessage::InputAction { - action: Some(Action::FocusPrevious), - .. - } => self.primary_panes.previous(), - ViewMessage::InputAction { - action: Some(Action::FocusNext), - .. - } => self.primary_panes.next(), - - // Everything else gets ate - _ => {} - } - UpdateOutcome::Consumed - } - - fn focused_children(&mut self) -> Vec<&mut dyn Component> { - vec![ - &mut self.modal_queue, - match self.primary_panes.selected() { - PrimaryPane::ProfileList => { - &mut self.profile_list_pane as &mut dyn Component - } - PrimaryPane::RecipeList => &mut self.recipe_list_pane, - PrimaryPane::Request => &mut self.request_pane, - PrimaryPane::Response => &mut self.response_pane, - }, - ] - } -} - -impl Draw for Root { - fn draw( - &self, - context: &RenderContext, - _: (), - frame: &mut Frame, - chunk: Rect, - ) { - // Create layout - let [main_chunk, footer_chunk] = layout( - chunk, - Direction::Vertical, - [Constraint::Min(0), Constraint::Length(1)], - ); - // Split the main pane horizontally - let [left_chunk, right_chunk] = layout( - main_chunk, - Direction::Horizontal, - [Constraint::Max(40), Constraint::Percentage(50)], - ); - - // Split left column vertically - let [profiles_chunk, recipes_chunk] = layout( - left_chunk, - Direction::Vertical, - [Constraint::Max(16), Constraint::Min(0)], - ); - - // Split right column vertically - let [request_chunk, response_chunk] = layout( - right_chunk, - Direction::Vertical, - [Constraint::Percentage(50), Constraint::Percentage(50)], - ); - - // Primary panes - let panes = &self.primary_panes; - self.profile_list_pane.draw( - context, - ListPaneProps { - is_selected: panes.is_selected(&PrimaryPane::ProfileList), - }, - frame, - profiles_chunk, - ); - self.recipe_list_pane.draw( - context, - ListPaneProps { - is_selected: panes.is_selected(&PrimaryPane::RecipeList), - }, - frame, - recipes_chunk, - ); - self.request_pane.draw( - context, - RequestPaneProps { - is_selected: panes.is_selected(&PrimaryPane::Request), - selected_recipe: self.recipe_list_pane.selected_recipe(), - }, - frame, - request_chunk, - ); - self.response_pane.draw( - context, - ResponsePaneProps { - is_selected: panes.is_selected(&PrimaryPane::Response), - active_request: self.active_request(), - }, - frame, - response_chunk, - ); - - // Footer - match &self.notification_text { - Some(notification_text) => { - notification_text.draw(context, (), frame, footer_chunk) - } - None => HelpText.draw(context, (), frame, footer_chunk), - } - - // Render modals last so they go on top - self.modal_queue.draw(context, (), frame, frame.size()); - } -} diff --git a/src/tui/view/component/modal.rs b/src/tui/view/component/modal.rs index 19b787e9..cb289ae8 100644 --- a/src/tui/view/component/modal.rs +++ b/src/tui/view/component/modal.rs @@ -5,6 +5,7 @@ use crate::tui::{ util::centered_rect, }, }; +use derive_more::Display; use ratatui::{ prelude::Constraint, widgets::{Block, BorderType, Borders, Clear}, @@ -16,6 +17,10 @@ use tracing::trace; /// user. It may be informational (e.g. an error message) or interactive (e.g. /// an input prompt). Any type that implements this trait can be used as a /// modal. +/// +/// Modals cannot take props because they are rendered by the root component +/// with dynamic dispatch, and therefore all modals must take the same props +/// (none). pub trait Modal: Draw<()> + Component { /// Text at the top of the modal fn title(&self) -> &str; @@ -39,7 +44,8 @@ pub trait IntoModal { fn into_modal(self) -> Self::Target; } -#[derive(Debug)] +#[derive(Debug, Display)] +#[display(fmt = "ModalQueue ({} in queue)", "queue.len()")] pub struct ModalQueue { queue: VecDeque>, } @@ -67,22 +73,26 @@ impl ModalQueue { impl Component for ModalQueue { fn update(&mut self, message: ViewMessage) -> UpdateOutcome { match message { - // Close the active modal - ViewMessage::InputAction { + // Close the active modal. If there's no modal open, we'll propagate + // the event down + ViewMessage::Input { action: Some(Action::Cancel), .. } - | ViewMessage::CloseModal => match self.close() { - Some(modal) => { - // Inform the modal of its terminal status - modal.on_close(); - UpdateOutcome::Consumed + | ViewMessage::CloseModal => { + match self.close() { + Some(modal) => { + // Inform the modal of its terminal status + modal.on_close(); + UpdateOutcome::Consumed + } + // Modal wasn't open, so don't consume the event + None => UpdateOutcome::Propagate(message), } - // Modal wasn't open, so don't consume the event - None => UpdateOutcome::Propagate(message), - }, + } // Open a new modal + // TODO allow pushing high priority modals (errors) to the front ViewMessage::OpenModal(modal) => { self.open(modal); UpdateOutcome::Consumed diff --git a/src/tui/view/component/primary.rs b/src/tui/view/component/primary.rs index e188749e..847d125e 100644 --- a/src/tui/view/component/primary.rs +++ b/src/tui/view/component/primary.rs @@ -1,57 +1,245 @@ -//! Primary pane components +//! Components for the "primary" view, which is the paned request/response view use crate::{ - config::{Profile, RequestRecipe}, + config::{Profile, RequestCollection, RequestRecipe}, tui::{ input::Action, message::Message, view::{ - component::{Component, Draw, UpdateOutcome, ViewMessage}, - state::{ - PrimaryPane, RequestState, RequestTab, ResponseTab, - StatefulList, StatefulSelect, + component::{ + request::{RequestPane, RequestPaneProps}, + response::{ResponsePane, ResponsePaneProps}, + Component, Draw, UpdateOutcome, ViewMessage, }, - util::{layout, BlockBrick, ListBrick, TabBrick, ToTui}, + state::{FixedSelect, RequestState, StatefulList, StatefulSelect}, + util::{layout, BlockBrick, ListBrick, ToTui}, Frame, RenderContext, }, }, }; -use ratatui::{ - prelude::{Alignment, Constraint, Direction, Rect}, - text::{Line, Text}, - widgets::{Paragraph, Wrap}, -}; +use derive_more::Display; +use ratatui::prelude::{Constraint, Direction, Rect}; +use strum::EnumIter; + +/// Primary TUI view, which shows request/response panes +#[derive(Debug, Display)] +#[display(fmt = "PrimaryView")] +pub struct PrimaryView { + // Own state + selected_pane: StatefulSelect, + + // Children + profile_list_pane: ProfileListPane, + recipe_list_pane: RecipeListPane, + request_pane: RequestPane, + response_pane: ResponsePane, +} -#[derive(Debug)] -pub struct ProfileListPane { - profiles: StatefulList, +pub struct PrimaryViewProps<'a> { + pub active_request: Option<&'a RequestState>, } -impl ProfileListPane { - pub fn new(profiles: Vec) -> Self { +/// Selectable panes in the primary view mode +#[derive(Copy, Clone, Debug, derive_more::Display, EnumIter, PartialEq)] +pub enum PrimaryPane { + #[display(fmt = "Profiles")] + ProfileList, + #[display(fmt = "Recipes")] + RecipeList, + Request, + Response, +} + +impl FixedSelect for PrimaryPane { + const DEFAULT_INDEX: usize = 1; +} + +impl PrimaryView { + pub fn new(collection: &RequestCollection) -> Self { Self { - profiles: StatefulList::with_items(profiles), + selected_pane: StatefulSelect::default(), + + profile_list_pane: ProfileListPane::new( + collection.profiles.to_owned(), + ), + recipe_list_pane: RecipeListPane::new( + collection.recipes.to_owned(), + ), + request_pane: RequestPane::default(), + response_pane: ResponsePane::default(), } } + /// Which recipe in the recipe list is selected? `None` iff the list is + /// empty. + pub fn selected_recipe(&self) -> Option<&RequestRecipe> { + self.recipe_list_pane.recipes.selected() + } + /// Which profile in the list is selected? `None` iff the list is empty. /// Exposing inner state is hacky but it's an easy shortcut pub fn selected_profile(&self) -> Option<&Profile> { - self.profiles.selected() + self.profile_list_pane.profiles.selected() + } + + /// Expose response pane, for fullscreening + pub fn response_pane(&self) -> &ResponsePane { + &self.response_pane + } + + /// Expose response pane, for fullscreening + pub fn response_pane_mut(&mut self) -> &mut ResponsePane { + &mut self.response_pane + } +} + +impl Component for PrimaryView { + fn update(&mut self, message: ViewMessage) -> UpdateOutcome { + match message { + // Send HTTP request (bubbled up from child) + ViewMessage::HttpSendRequest => { + if let Some(recipe) = self.selected_recipe() { + UpdateOutcome::SideEffect(Message::HttpBeginRequest { + // Reach into the children to grab state (ugly!) + recipe_id: recipe.id.clone(), + profile_id: self + .selected_profile() + .map(|profile| profile.id.clone()), + }) + } else { + UpdateOutcome::Consumed + } + } + + // Input messages + ViewMessage::Input { + action: Some(Action::FocusPrevious), + .. + } => { + self.selected_pane.previous(); + UpdateOutcome::Consumed + } + ViewMessage::Input { + action: Some(Action::FocusNext), + .. + } => { + self.selected_pane.next(); + UpdateOutcome::Consumed + } + + _ => UpdateOutcome::Propagate(message), + } + } + + fn focused_children(&mut self) -> Vec<&mut dyn Component> { + vec![match self.selected_pane.selected() { + PrimaryPane::ProfileList => { + &mut self.profile_list_pane as &mut dyn Component + } + PrimaryPane::RecipeList => &mut self.recipe_list_pane, + PrimaryPane::Request => &mut self.request_pane, + PrimaryPane::Response => &mut self.response_pane, + }] + } +} + +impl<'a> Draw> for PrimaryView { + fn draw( + &self, + context: &RenderContext, + props: PrimaryViewProps<'a>, + frame: &mut Frame, + chunk: Rect, + ) { + // Split the main pane horizontally + let [left_chunk, right_chunk] = layout( + chunk, + Direction::Horizontal, + [Constraint::Max(40), Constraint::Percentage(50)], + ); + + // Split left column vertically + let [profiles_chunk, recipes_chunk] = layout( + left_chunk, + Direction::Vertical, + [Constraint::Max(16), Constraint::Min(0)], + ); + + // Split right column vertically + let [request_chunk, response_chunk] = layout( + right_chunk, + Direction::Vertical, + [Constraint::Percentage(50), Constraint::Percentage(50)], + ); + + // Primary panes + let panes = &self.selected_pane; + self.profile_list_pane.draw( + context, + ListPaneProps { + is_selected: panes.is_selected(&PrimaryPane::ProfileList), + }, + frame, + profiles_chunk, + ); + self.recipe_list_pane.draw( + context, + ListPaneProps { + is_selected: panes.is_selected(&PrimaryPane::RecipeList), + }, + frame, + recipes_chunk, + ); + self.request_pane.draw( + context, + RequestPaneProps { + is_selected: panes.is_selected(&PrimaryPane::Request), + selected_recipe: self.selected_recipe(), + }, + frame, + request_chunk, + ); + self.response_pane.draw( + context, + ResponsePaneProps { + is_selected: panes.is_selected(&PrimaryPane::Response), + active_request: props.active_request, + }, + frame, + response_chunk, + ); + } +} + +#[derive(Debug, Display)] +#[display(fmt = "ProfileListPane")] +struct ProfileListPane { + profiles: StatefulList, +} + +struct ListPaneProps { + is_selected: bool, +} + +impl ProfileListPane { + pub fn new(profiles: Vec) -> Self { + Self { + profiles: StatefulList::with_items(profiles), + } } } impl Component for ProfileListPane { fn update(&mut self, message: ViewMessage) -> UpdateOutcome { match message { - ViewMessage::InputAction { + ViewMessage::Input { action: Some(Action::Up), .. } => { self.profiles.previous(); UpdateOutcome::Consumed } - ViewMessage::InputAction { + ViewMessage::Input { action: Some(Action::Down), .. } => { @@ -86,8 +274,9 @@ impl Draw for ProfileListPane { } } -#[derive(Debug)] -pub struct RecipeListPane { +#[derive(Debug, Display)] +#[display(fmt = "RecipeListPane")] +struct RecipeListPane { recipes: StatefulList, } @@ -97,12 +286,6 @@ impl RecipeListPane { recipes: StatefulList::with_items(recipes), } } - - /// Which recipe in the list is selected? `None` iff the list is empty. - /// Exposing inner state is hacky but it's an easy shortcut - pub fn selected_recipe(&self) -> Option<&RequestRecipe> { - self.recipes.selected() - } } impl Component for RecipeListPane { @@ -121,7 +304,7 @@ impl Component for RecipeListPane { } match message { - ViewMessage::InputAction { + ViewMessage::Input { action: Some(Action::Interact), .. } => { @@ -129,14 +312,14 @@ impl Component for RecipeListPane { // it also needs access to the profile list state UpdateOutcome::Propagate(ViewMessage::HttpSendRequest) } - ViewMessage::InputAction { + ViewMessage::Input { action: Some(Action::Up), .. } => { self.recipes.previous(); load_from_repo(self) } - ViewMessage::InputAction { + ViewMessage::Input { action: Some(Action::Down), .. } => { @@ -171,265 +354,3 @@ impl Draw for RecipeListPane { ) } } - -#[derive(Debug)] -pub struct RequestPane { - tabs: StatefulSelect, -} - -impl RequestPane { - pub fn new() -> Self { - Self { - tabs: StatefulSelect::default(), - } - } -} - -impl Component for RequestPane { - fn update(&mut self, message: ViewMessage) -> UpdateOutcome { - match message { - ViewMessage::InputAction { - action: Some(Action::Left), - .. - } => { - self.tabs.previous(); - UpdateOutcome::Consumed - } - ViewMessage::InputAction { - action: Some(Action::Right), - .. - } => { - self.tabs.next(); - UpdateOutcome::Consumed - } - _ => UpdateOutcome::Propagate(message), - } - } -} - -impl<'a> Draw> for RequestPane { - fn draw( - &self, - context: &RenderContext, - props: RequestPaneProps<'a>, - frame: &mut Frame, - chunk: Rect, - ) { - // Render outermost block - let pane_kind = PrimaryPane::Request; - let block = BlockBrick { - title: pane_kind.to_string(), - is_focused: props.is_selected, - }; - let block = block.to_tui(context); - let inner_chunk = block.inner(chunk); - frame.render_widget(block, chunk); - - // Render request contents - if let Some(recipe) = props.selected_recipe { - let [url_chunk, tabs_chunk, content_chunk] = layout( - inner_chunk, - Direction::Vertical, - [ - Constraint::Length(1), - Constraint::Length(1), - Constraint::Min(0), - ], - ); - - // URL - frame.render_widget( - Paragraph::new(format!("{} {}", recipe.method, recipe.url)), - url_chunk, - ); - - // Navigation tabs - let tabs = TabBrick { tabs: &self.tabs }; - frame.render_widget(tabs.to_tui(context), tabs_chunk); - - // Request content - let text: Text = match self.tabs.selected() { - RequestTab::Body => recipe - .body - .as_ref() - .map(|b| b.to_string()) - .unwrap_or_default() - .into(), - RequestTab::Query => recipe.query.to_tui(context), - RequestTab::Headers => recipe.headers.to_tui(context), - }; - frame.render_widget(Paragraph::new(text), content_chunk); - } - } -} - -#[derive(Debug)] -pub struct ResponsePane { - tabs: StatefulSelect, -} - -impl ResponsePane { - pub fn new() -> Self { - Self { - tabs: StatefulSelect::default(), - } - } -} - -impl Component for ResponsePane { - fn update(&mut self, message: ViewMessage) -> UpdateOutcome { - match message { - ViewMessage::InputAction { - action: Some(Action::Left), - .. - } => { - self.tabs.previous(); - UpdateOutcome::Consumed - } - ViewMessage::InputAction { - action: Some(Action::Right), - .. - } => { - self.tabs.next(); - UpdateOutcome::Consumed - } - _ => UpdateOutcome::Propagate(message), - } - } -} - -impl<'a> Draw> for ResponsePane { - fn draw( - &self, - context: &RenderContext, - props: ResponsePaneProps<'a>, - frame: &mut Frame, - chunk: Rect, - ) { - // Render outermost block - let pane_kind = PrimaryPane::Response; - let block = BlockBrick { - title: pane_kind.to_string(), - is_focused: props.is_selected, - }; - let block = block.to_tui(context); - let inner_chunk = block.inner(chunk); - frame.render_widget(block, chunk); - - // Don't render anything else unless we have a request state - if let Some(request_state) = props.active_request { - let [header_chunk, content_chunk] = layout( - inner_chunk, - Direction::Vertical, - [Constraint::Length(1), Constraint::Min(0)], - ); - let [header_left_chunk, header_right_chunk] = layout( - header_chunk, - Direction::Horizontal, - [Constraint::Length(20), Constraint::Min(0)], - ); - - // Time-related data. start_time and duration should always be - // defined together - if let (Some(start_time), Some(duration)) = - (request_state.start_time(), request_state.duration()) - { - frame.render_widget( - Paragraph::new(Line::from(vec![ - start_time.to_tui(context), - " / ".into(), - duration.to_tui(context), - ])) - .alignment(Alignment::Right), - header_right_chunk, - ); - } - - match &request_state { - RequestState::Building { .. } => { - frame.render_widget( - Paragraph::new("Initializing request..."), - header_left_chunk, - ); - } - - // :( - RequestState::BuildError { error } => { - frame.render_widget( - Paragraph::new(error.to_tui(context)) - .wrap(Wrap::default()), - content_chunk, - ); - } - - RequestState::Loading { .. } => { - frame.render_widget( - Paragraph::new("Loading..."), - header_left_chunk, - ); - } - - RequestState::Response { - record, - pretty_body, - } => { - let response = &record.response; - // Status code - frame.render_widget( - Paragraph::new(response.status.to_string()), - header_left_chunk, - ); - - // Split the main chunk again to allow tabs - let [tabs_chunk, content_chunk] = layout( - content_chunk, - Direction::Vertical, - [Constraint::Length(1), Constraint::Min(0)], - ); - - // Navigation tabs - let tabs = TabBrick { tabs: &self.tabs }; - frame.render_widget(tabs.to_tui(context), tabs_chunk); - - // Main content for the response - let tab_text = match self.tabs.selected() { - // Render the pretty body if it's available, otherwise - // fall back to the regular one - ResponseTab::Body => pretty_body - .as_deref() - .unwrap_or(response.body.text()) - .into(), - ResponseTab::Headers => { - response.headers.to_tui(context) - } - }; - frame - .render_widget(Paragraph::new(tab_text), content_chunk); - } - - // Sadge - RequestState::RequestError { error, .. } => { - frame.render_widget( - Paragraph::new(error.to_tui(context)) - .wrap(Wrap::default()), - content_chunk, - ); - } - } - } - } -} - -pub struct ListPaneProps { - pub is_selected: bool, -} - -pub struct RequestPaneProps<'a> { - pub is_selected: bool, - pub selected_recipe: Option<&'a RequestRecipe>, -} - -pub struct ResponsePaneProps<'a> { - pub is_selected: bool, - pub active_request: Option<&'a RequestState>, -} diff --git a/src/tui/view/component/request.rs b/src/tui/view/component/request.rs new file mode 100644 index 00000000..03f6a95a --- /dev/null +++ b/src/tui/view/component/request.rs @@ -0,0 +1,129 @@ +use crate::{ + config::RequestRecipe, + tui::{ + input::Action, + view::{ + component::{ + primary::PrimaryPane, Component, Draw, UpdateOutcome, + ViewMessage, + }, + state::{FixedSelect, StatefulSelect}, + util::{layout, BlockBrick, TabBrick, ToTui}, + Frame, RenderContext, + }, + }, +}; +use derive_more::Display; +use ratatui::{ + prelude::{Constraint, Direction, Rect}, + widgets::Paragraph, +}; +use strum::EnumIter; + +/// Display a request recipe +#[derive(Debug, Display, Default)] +#[display(fmt = "RequestPane")] +pub struct RequestPane { + tabs: StatefulSelect, +} + +pub struct RequestPaneProps<'a> { + pub is_selected: bool, + pub selected_recipe: Option<&'a RequestRecipe>, +} + +#[derive(Copy, Clone, Debug, derive_more::Display, EnumIter, PartialEq)] +enum RequestTab { + Body, + Query, + Headers, +} + +impl FixedSelect for RequestTab {} + +impl Component for RequestPane { + fn update(&mut self, message: ViewMessage) -> UpdateOutcome { + match message { + ViewMessage::Input { + action: Some(action), + .. + } => match action { + Action::Left => { + self.tabs.previous(); + UpdateOutcome::Consumed + } + Action::Right => { + self.tabs.next(); + UpdateOutcome::Consumed + } + _ => UpdateOutcome::Propagate(message), + }, + _ => UpdateOutcome::Propagate(message), + } + } +} + +impl<'a> Draw> for RequestPane { + fn draw( + &self, + context: &RenderContext, + props: RequestPaneProps<'a>, + frame: &mut Frame, + chunk: Rect, + ) { + // Render outermost block + let pane_kind = PrimaryPane::Request; + let block = BlockBrick { + title: pane_kind.to_string(), + is_focused: props.is_selected, + }; + let block = block.to_tui(context); + let inner_chunk = block.inner(chunk); + frame.render_widget(block, chunk); + + // Render request contents + if let Some(recipe) = props.selected_recipe { + let [url_chunk, tabs_chunk, content_chunk] = layout( + inner_chunk, + Direction::Vertical, + [ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Min(0), + ], + ); + + // URL + frame.render_widget( + Paragraph::new(format!("{} {}", recipe.method, recipe.url)), + url_chunk, + ); + + // Navigation tabs + let tabs = TabBrick { tabs: &self.tabs }; + frame.render_widget(tabs.to_tui(context), tabs_chunk); + + // Request content + match self.tabs.selected() { + RequestTab::Body => { + if let Some(body) = recipe.body.as_deref() { + frame + .render_widget(Paragraph::new(body), content_chunk); + } + } + RequestTab::Query => { + frame.render_widget( + Paragraph::new(recipe.query.to_tui(context)), + content_chunk, + ); + } + RequestTab::Headers => { + 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 new file mode 100644 index 00000000..e9dc308d --- /dev/null +++ b/src/tui/view/component/response.rs @@ -0,0 +1,203 @@ +use crate::tui::{ + input::Action, + view::{ + component::{ + primary::PrimaryPane, root::RootMode, Component, Draw, + UpdateOutcome, ViewMessage, + }, + state::{FixedSelect, RequestState, StatefulSelect}, + util::{layout, BlockBrick, TabBrick, ToTui}, + Frame, RenderContext, + }, +}; +use derive_more::Display; +use ratatui::{ + prelude::{Alignment, Constraint, Direction, Rect}, + text::Line, + widgets::{Paragraph, Wrap}, +}; +use strum::EnumIter; + +/// Display HTTP response state, which could be in progress, complete, or +/// failed. This can be used in both a paned and fullscreen view. +#[derive(Debug, Default, Display)] +#[display(fmt = "ResponsePane")] +pub struct ResponsePane { + tabs: StatefulSelect, +} + +pub struct ResponsePaneProps<'a> { + pub is_selected: bool, + pub active_request: Option<&'a RequestState>, +} + +#[derive(Copy, Clone, Debug, derive_more::Display, EnumIter, PartialEq)] +enum ResponseTab { + Body, + Headers, +} + +impl FixedSelect for ResponseTab {} + +impl Component for ResponsePane { + fn update(&mut self, message: ViewMessage) -> UpdateOutcome { + match message { + ViewMessage::Input { + action: Some(action), + .. + } => match action { + // Switch tabs + Action::Left => { + self.tabs.previous(); + UpdateOutcome::Consumed + } + Action::Right => { + self.tabs.next(); + UpdateOutcome::Consumed + } + + // Enter fullscreen + Action::Fullscreen => UpdateOutcome::Propagate( + ViewMessage::OpenView(RootMode::Response), + ), + // Exit fullscreen + Action::Cancel => UpdateOutcome::Propagate( + ViewMessage::OpenView(RootMode::Primary), + ), + + _ => UpdateOutcome::Propagate(message), + }, + _ => UpdateOutcome::Propagate(message), + } + } +} + +impl<'a> Draw> for ResponsePane { + fn draw( + &self, + context: &RenderContext, + props: ResponsePaneProps<'a>, + frame: &mut Frame, + chunk: Rect, + ) { + // Render outermost block + let pane_kind = PrimaryPane::Response; + let block = BlockBrick { + title: pane_kind.to_string(), + is_focused: props.is_selected, + }; + let block = block.to_tui(context); + let inner_chunk = block.inner(chunk); + frame.render_widget(block, chunk); + + // Don't render anything else unless we have a request state + if let Some(request_state) = props.active_request { + let [header_chunk, content_chunk] = layout( + inner_chunk, + Direction::Vertical, + [Constraint::Length(1), Constraint::Min(0)], + ); + let [header_left_chunk, header_right_chunk] = layout( + header_chunk, + Direction::Horizontal, + [Constraint::Length(20), Constraint::Min(0)], + ); + + // Time-related data. start_time and duration should always be + // defined together + if let (Some(start_time), Some(duration)) = + (request_state.start_time(), request_state.duration()) + { + frame.render_widget( + Paragraph::new(Line::from(vec![ + start_time.to_tui(context), + " / ".into(), + duration.to_tui(context), + ])) + .alignment(Alignment::Right), + header_right_chunk, + ); + } + + match &request_state { + RequestState::Building { .. } => { + frame.render_widget( + Paragraph::new("Initializing request..."), + header_left_chunk, + ); + } + + // :( + RequestState::BuildError { error } => { + frame.render_widget( + Paragraph::new(error.to_tui(context)) + .wrap(Wrap::default()), + content_chunk, + ); + } + + RequestState::Loading { .. } => { + frame.render_widget( + Paragraph::new("Loading..."), + header_left_chunk, + ); + } + + RequestState::Response { + record, + pretty_body, + } => { + let response = &record.response; + // Status code + frame.render_widget( + Paragraph::new(response.status.to_string()), + header_left_chunk, + ); + + // Split the main chunk again to allow tabs + let [tabs_chunk, content_chunk] = layout( + content_chunk, + Direction::Vertical, + [Constraint::Length(1), Constraint::Min(0)], + ); + + // Navigation tabs + let tabs = TabBrick { tabs: &self.tabs }; + frame.render_widget(tabs.to_tui(context), tabs_chunk); + + // Main content for the response + match self.tabs.selected() { + ResponseTab::Body => { + // Render the pretty body if it's available, + // otherwise fall back to the regular one + let body: &str = pretty_body + .as_deref() + .unwrap_or(response.body.text()); + frame.render_widget( + Paragraph::new(body), + content_chunk, + ); + } + ResponseTab::Headers => { + frame.render_widget( + Paragraph::new( + response.headers.to_tui(context), + ), + content_chunk, + ); + } + }; + } + + // Sadge + RequestState::RequestError { error, .. } => { + frame.render_widget( + Paragraph::new(error.to_tui(context)) + .wrap(Wrap::default()), + content_chunk, + ); + } + } + } + } +} diff --git a/src/tui/view/component/root.rs b/src/tui/view/component/root.rs new file mode 100644 index 00000000..52e6fb2c --- /dev/null +++ b/src/tui/view/component/root.rs @@ -0,0 +1,200 @@ +use crate::{ + config::{RequestCollection, RequestRecipeId}, + tui::{ + input::Action, + message::Message, + view::{ + component::{ + misc::{HelpText, NotificationText}, + modal::ModalQueue, + primary::{PrimaryView, PrimaryViewProps}, + response::ResponsePaneProps, + Component, Draw, UpdateOutcome, ViewMessage, + }, + state::RequestState, + util::layout, + Frame, RenderContext, + }, + }, +}; +use derive_more::Display; +use ratatui::prelude::{Constraint, Direction, Rect}; +use std::collections::{hash_map::Entry, HashMap}; + +/// The root view component +#[derive(Debug, Display)] +#[display(fmt = "Root")] +pub struct Root { + // ===== Own State ===== + /// Cached request state. A recipe will appear in this map if two + /// conditions are met: + /// - It has at least one *successful* request in history + /// - It has beed focused by the user during this process + /// This will be populated on-demand when a user selects a recipe in the + /// list. + #[display(fmt = "")] + active_requests: HashMap, + /// What is we lookin at? + mode: RootMode, + + // ==== Children ===== + /// We hold onto the primary view even when it's not visible, because we + /// don't want the state to reset when changing views + primary_view: PrimaryView, + modal_queue: ModalQueue, + notification_text: Option, +} + +/// View mode of the root component +#[derive(Copy, Clone, Debug, Default)] +pub enum RootMode { + /// Show the normal pane view + #[default] + Primary, + /// Fullscreen the active response + Response, +} + +impl Root { + pub fn new(collection: &RequestCollection) -> Self { + Self { + // State + active_requests: HashMap::new(), + mode: RootMode::default(), + + // Children + primary_view: PrimaryView::new(collection), + modal_queue: ModalQueue::new(), + notification_text: None, + } + } + + /// Get the request state to be displayed + fn active_request(&self) -> Option<&RequestState> { + let recipe = self.primary_view.selected_recipe()?; + self.active_requests.get(&recipe.id) + } + + /// Update the active HTTP request state + fn update_request( + &mut self, + recipe_id: RequestRecipeId, + state: RequestState, + ) { + // Update the state if any of these conditions match: + // - There's nothing there yet + // - This is a new request + // - This is an update to the request already in place + match self.active_requests.entry(recipe_id) { + Entry::Vacant(entry) => { + entry.insert(state); + } + Entry::Occupied(mut entry) + if state.is_initial() || entry.get().id() == state.id() => + { + entry.insert(state); + } + Entry::Occupied(_) => { + // State is already holding a different request, throw + // this update away + } + } + } +} + +impl Component for Root { + fn update(&mut self, message: ViewMessage) -> UpdateOutcome { + match message { + // Update state of HTTP request + ViewMessage::HttpSetState { recipe_id, state } => { + self.update_request(recipe_id, state); + UpdateOutcome::Consumed + } + + // Other state messages + ViewMessage::OpenView(mode) => { + self.mode = mode; + UpdateOutcome::Consumed + } + ViewMessage::Notify(notification) => { + self.notification_text = + Some(NotificationText::new(notification)); + UpdateOutcome::Consumed + } + + // Input messages + ViewMessage::Input { + action: Some(Action::Quit), + .. + } => UpdateOutcome::SideEffect(Message::Quit), + ViewMessage::Input { + action: Some(Action::ReloadCollection), + .. + } => UpdateOutcome::SideEffect(Message::CollectionStartReload), + // Any other user input should get thrown away + ViewMessage::Input { .. } => UpdateOutcome::Consumed, + + // There shouldn't be anything left unhandled. Bubble up to log it + _ => UpdateOutcome::Propagate(message), + } + } + + fn focused_children(&mut self) -> Vec<&mut dyn Component> { + vec![ + &mut self.modal_queue, + match self.mode { + RootMode::Primary => &mut self.primary_view, + RootMode::Response => self.primary_view.response_pane_mut(), + }, + ] + } +} + +impl Draw for Root { + fn draw( + &self, + context: &RenderContext, + _: (), + frame: &mut Frame, + chunk: Rect, + ) { + // Create layout + let [main_chunk, footer_chunk] = layout( + chunk, + Direction::Vertical, + [Constraint::Min(0), Constraint::Length(1)], + ); + + // Main content + match self.mode { + RootMode::Primary => self.primary_view.draw( + context, + PrimaryViewProps { + active_request: self.active_request(), + }, + frame, + main_chunk, + ), + RootMode::Response => self.primary_view.response_pane().draw( + context, + ResponsePaneProps { + active_request: self.active_request(), + is_selected: false, + }, + frame, + main_chunk, + ), + } + + // Footer + match &self.notification_text { + Some(notification_text) => { + notification_text.draw(context, (), frame, footer_chunk) + } + None => HelpText.draw(context, (), frame, footer_chunk), + } + + // Render modals last so they go on top + self.modal_queue.draw(context, (), frame, frame.size()); + } +} diff --git a/src/tui/view/mod.rs b/src/tui/view/mod.rs index c1040967..b2c49c4f 100644 --- a/src/tui/view/mod.rs +++ b/src/tui/view/mod.rs @@ -94,7 +94,7 @@ impl View { /// a bound action is provided which tells us what abstract action the /// input maps to. pub fn handle_input(&mut self, event: Event, action: Option) { - self.handle_message(ViewMessage::InputAction { event, action }) + self.handle_message(ViewMessage::Input { event, action }) } /// Process a view message by passing it to the root component and letting @@ -102,7 +102,7 @@ impl View { fn handle_message(&mut self, message: ViewMessage) { let span = trace_span!("View message", ?message); span.in_scope(|| { - match self.root.update_all(message) { + match Self::update_all(&mut self.root, message) { UpdateOutcome::Consumed => { trace!("View message consumed") } @@ -118,6 +118,37 @@ impl View { } }); } + + /// Update the state of a component *and* its children, starting at the + /// lowest descendant. Recursively walk up the tree until a component + /// consumes the message. + fn update_all( + component: &mut dyn Component, + mut message: ViewMessage, + ) -> UpdateOutcome { + // If we have a child, send them the message. If not, eat it ourselves + for child in component.focused_children() { + let outcome = Self::update_all(child, message); // RECURSION + if let UpdateOutcome::Propagate(returned) = outcome { + // Keep going to the next child. It's possible the child + // returned something other than the original message, which + // we'll just pass along anyway. + message = returned; + } else { + trace!(%child, "View message consumed"); + return outcome; + } + } + + // None of our children handled it, we'll take it ourselves. + // Message is already traced in the parent span, so don't dupe it. + let span = trace_span!("Component handling message", %component); + span.in_scope(|| { + let outcome = component.update(message); + trace!(?outcome); + outcome + }) + } } /// Global readonly data that various components need during rendering diff --git a/src/tui/view/state.rs b/src/tui/view/state.rs index f1ffd134..ebf46b13 100644 --- a/src/tui/view/state.rs +++ b/src/tui/view/state.rs @@ -4,7 +4,7 @@ use crate::http::{RequestBuildError, RequestError, RequestId, RequestRecord}; use chrono::{DateTime, Duration, Utc}; use ratatui::widgets::*; use std::{cell::RefCell, fmt::Display, ops::DerefMut}; -use strum::{EnumIter, IntoEnumIterator}; +use strum::IntoEnumIterator; /// State of an HTTP response, which can be in various states of /// completion/failure. Each request *recipe* should have one request state @@ -253,34 +253,3 @@ impl Default for StatefulSelect { Self::new() } } - -#[derive(Copy, Clone, Debug, derive_more::Display, EnumIter, PartialEq)] -pub enum PrimaryPane { - #[display(fmt = "Profiles")] - ProfileList, - #[display(fmt = "Recipes")] - RecipeList, - Request, - Response, -} - -impl FixedSelect for PrimaryPane { - const DEFAULT_INDEX: usize = 1; -} - -#[derive(Copy, Clone, Debug, derive_more::Display, EnumIter, PartialEq)] -pub enum RequestTab { - Body, - Query, - Headers, -} - -impl FixedSelect for RequestTab {} - -#[derive(Copy, Clone, Debug, derive_more::Display, EnumIter, PartialEq)] -pub enum ResponseTab { - Body, - Headers, -} - -impl FixedSelect for ResponseTab {}