diff --git a/CHANGELOG.md b/CHANGELOG.md index 922573fe..9bb532ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [Unreleased] - ReleaseDate + +### Added + +- Option to toggle cursor capture + ## [0.8.0] - 2023-11-21 ### Added diff --git a/Cargo.lock b/Cargo.lock index b7ef46ac..4ac19fb1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -277,12 +277,6 @@ dependencies = [ "windows-sys 0.45.0", ] -[[package]] -name = "convert_case" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" - [[package]] name = "core-foundation" version = "0.9.3" @@ -326,15 +320,23 @@ dependencies = [ [[package]] name = "derive_more" -version = "0.99.17" +version = "1.0.0-beta.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7abbfc297053be59290e3152f8cbcd52c8642e0728b69ee187d991d4c1af08d" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "1.0.0-beta.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fb810d30a7c1953f91334de7244731fc3f3c10d7fe163338a35b9f640960321" +checksum = "2bba3e9872d7c58ce7ef0fcf1844fcc3e23ef2a58377b50df35dd98e42a5726e" dependencies = [ - "convert_case", "proc-macro2", "quote", - "rustc_version", - "syn 1.0.109", + "syn 2.0.38", + "unicode-xid", ] [[package]] @@ -1905,6 +1907,12 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e51733f11c9c4f72aa0c160008246859e340b00807569a0da0e7a1079b27ba85" +[[package]] +name = "unicode-xid" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f962df74c8c05a667b5ee8bcf162993134c104e96440b663c8daa176dc772d8c" + [[package]] name = "unsafe-libyaml" version = "0.2.9" diff --git a/Cargo.toml b/Cargo.toml index 221b2ef4..f9693985 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,7 +16,7 @@ async-trait = "^0.1.73" chrono = {version = "^0.4.31", default-features = false, features = ["clock", "serde", "std"]} clap = {version = "^4.4.2", features = ["derive"]} crossterm = "^0.27.0" -derive_more = "^0.99.17" +derive_more = {version = "1.0.0-beta.6", features = ["debug", "deref", "display", "from"]} dialoguer = {version = "^0.11.0", default-features = false, features = ["password"]} dirs = "^5.0.1" futures = "^0.3.28" diff --git a/src/template.rs b/src/template.rs index 7006b74a..f02abc42 100644 --- a/src/template.rs +++ b/src/template.rs @@ -41,7 +41,7 @@ pub struct TemplateContext { /// during creation to identify template keys, hence the immutability. #[derive(Clone, Debug, Deref, Display, Serialize, Deserialize)] #[cfg_attr(test, derive(PartialEq))] -#[display(fmt = "{template}")] +#[display("{template}")] #[serde(try_from = "String", into = "String")] pub struct Template { #[deref(forward)] @@ -127,10 +127,10 @@ enum TemplateKey { /// A plain field, which can come from the profile or an override Field(T), /// A value from a predefined chain of another recipe - #[display(fmt = "{}{}", "CHAIN_PREFIX", "_0")] + #[display("{CHAIN_PREFIX}{_0}")] Chain(T), /// A value pulled from the process environment - #[display(fmt = "{}{}", "ENV_PREFIX", "_0")] + #[display("{ENV_PREFIX}{_0}")] Environment(T), } diff --git a/src/tui.rs b/src/tui.rs index f01324da..e00edfb2 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -14,8 +14,9 @@ use crate::{ }; use anyhow::{anyhow, Context}; use crossterm::{ - execute, - terminal::{enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, + event::{DisableMouseCapture, EnableMouseCapture}, + terminal::{EnterAlternateScreen, LeaveAlternateScreen}, + ExecutableCommand, }; use futures::Future; use indexmap::IndexMap; @@ -65,15 +66,8 @@ impl Tui { /// because they prevent TUI execution. pub async fn start(collection_file: PathBuf) { initialize_panic_handler(); - - // Set up terminal - enable_raw_mode().expect("Error initializing terminal"); - let mut stdout = io::stdout(); - execute!(stdout, EnterAlternateScreen) - .expect("Error initializing terminal"); - let backend = CrosstermBackend::new(stdout); let terminal = - Terminal::new(backend).expect("Error initializing terminal"); + initialize_terminal().expect("Error initializing terminal"); // Create a message queue for handling async tasks let (messages_tx, messages_rx) = mpsc::unbounded_channel(); @@ -189,6 +183,10 @@ impl Tui { self.reload_collection(collection); } + Message::Error { error } => { + self.view.open_modal(error, ModalPriority::High) + } + // Manage HTTP life cycle Message::HttpBeginRequest { recipe_id, @@ -237,6 +235,8 @@ impl Tui { self.view.open_modal(prompt, ModalPriority::Low); } + Message::Quit => self.quit(), + Message::TemplatePreview { template, profile_id, @@ -249,10 +249,14 @@ impl Tui { )?; } - Message::Error { error } => { - self.view.open_modal(error, ModalPriority::High) + Message::ToggleMouseCapture { capture } => { + let mut stdout = io::stdout(); + if capture { + stdout.execute(EnableMouseCapture)?; + } else { + stdout.execute(DisableMouseCapture)?; + } } - Message::Quit => self.quit(), } Ok(()) } @@ -449,10 +453,23 @@ fn initialize_panic_handler() { })); } +/// Set up terminal for TUI +fn initialize_terminal() -> anyhow::Result>> { + crossterm::terminal::enable_raw_mode()?; + let mut stdout = io::stdout(); + crossterm::execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; + let backend = CrosstermBackend::new(stdout); + Ok(Terminal::new(backend)?) +} + /// Return terminal to initial state fn restore_terminal() -> anyhow::Result<()> { debug!("Restoring terminal"); crossterm::terminal::disable_raw_mode()?; - crossterm::execute!(std::io::stderr(), LeaveAlternateScreen)?; + crossterm::execute!( + io::stderr(), + LeaveAlternateScreen, + DisableMouseCapture + )?; Ok(()) } diff --git a/src/tui/input.rs b/src/tui/input.rs index dea3dfed..0c598ccd 100644 --- a/src/tui/input.rs +++ b/src/tui/input.rs @@ -2,10 +2,7 @@ use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use derive_more::Display; -use std::{ - collections::HashMap, - fmt::{Debug, Display}, -}; +use std::{collections::HashMap, fmt::Debug}; use tracing::trace; /// Top-level input manager. This handles things like bindings and mapping @@ -101,10 +98,10 @@ pub enum Action { ForceQuit, /// Focus the previous pane - #[display(fmt = "Prev Pane")] + #[display("Prev Pane")] PreviousPane, /// Focus the next pane - #[display(fmt = "Next Pane")] + #[display("Next Pane")] NextPane, Up, @@ -115,12 +112,12 @@ pub enum Action { /// Do a thing. E.g. select an item in a list Submit, /// Send the active request from *any* context - #[display(fmt = "Send Request")] + #[display("Send Request")] SendRequest, /// Embiggen a pane Fullscreen, /// Open the settings modal - #[display(fmt = "Settings")] + #[display("Settings")] OpenSettings, /// Close the current modal/dialog/etc. Cancel, diff --git a/src/tui/message.rs b/src/tui/message.rs index 5fee7fbe..30ae532c 100644 --- a/src/tui/message.rs +++ b/src/tui/message.rs @@ -54,6 +54,9 @@ pub enum Message { /// Store a reloaded collection value in state CollectionEndReload(RequestCollection), + /// An error occurred in some async process and should be shown to the user + Error { error: anyhow::Error }, + /// Launch an HTTP request from the given recipe/profile. HttpBeginRequest { recipe_id: RequestRecipeId, @@ -74,15 +77,18 @@ pub enum Message { /// these two cases saves a bit of boilerplate. HttpComplete(Result), + /// Show a prompt to the user, asking for some input. Use the included + /// channel to return the value. + PromptStart(Prompt), + + /// Exit the program + Quit, + /// Load the most recent response for a recipe from the repository RepositoryStartLoad { recipe_id: RequestRecipeId }, /// Finished loading a response from the repository RepositoryEndLoad { record: RequestRecord }, - /// Show a prompt to the user, asking for some input. Use the included - /// channel to return the value. - PromptStart(Prompt), - /// Render a template string, to be previewed in the UI. Ideally this could /// be launched directly by the component that needs it, but only the /// controller has the data needed to build the template context. The @@ -97,8 +103,6 @@ pub enum Message { destination: Arc>>, }, - /// An error occurred in some async process and should be shown to the user - Error { error: anyhow::Error }, - /// Exit the program - Quit, + /// Enable/disable mouse capture in the terminal + ToggleMouseCapture { capture: bool }, } diff --git a/src/tui/view.rs b/src/tui/view.rs index aa94eece..e69a0fa1 100644 --- a/src/tui/view.rs +++ b/src/tui/view.rs @@ -1,9 +1,12 @@ +mod common; mod component; +mod draw; +mod event; mod state; mod theme; mod util; -pub use component::ModalPriority; +pub use common::modal::{IntoModal, ModalPriority}; pub use state::RequestState; pub use util::PreviewPrompter; @@ -13,12 +16,10 @@ use crate::{ input::{Action, InputEngine}, message::MessageSender, view::{ - component::{ - Component, Draw, DrawContext, Event, IntoModal, Root, Update, - UpdateContext, - }, + component::root::Root, + draw::{Draw, DrawContext}, + event::{Event, EventHandler, Update, UpdateContext}, state::Notification, - theme::Theme, }, }, }; @@ -35,7 +36,6 @@ use tracing::{error, trace, trace_span}; pub struct View { messages_tx: MessageSender, config: ViewConfig, - theme: Theme, root: Root, } @@ -47,7 +47,6 @@ impl View { let mut view = Self { messages_tx, config: ViewConfig::default(), - theme: Theme::default(), root: Root::new(collection), }; // Tell the components to wake up @@ -68,7 +67,6 @@ impl View { &mut DrawContext { input_engine, config: &self.config, - theme: &self.theme, messages_tx, frame, }, @@ -150,7 +148,7 @@ impl View { /// lowest descendant. Recursively walk up the tree until a component /// consumes the event. fn update_all( - component: &mut dyn Component, + component: &mut dyn EventHandler, context: &mut UpdateContext, mut event: Event, ) -> Update { @@ -172,7 +170,8 @@ impl View { // 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", %component); + // TODO figure out a way to print just the component type name + let span = trace_span!("Component handling", ?component); span.in_scope(|| { let outcome = component.update(context, event); trace!(?outcome); @@ -184,13 +183,20 @@ impl View { /// Settings that control the behavior of the view #[derive(Debug)] struct ViewConfig { + /// Should templates be rendered inline in the UI, or should we show the + /// raw text? preview_templates: bool, + + /// Are we capture cursor events, or are they being handled by the terminal + /// emulator? This can be toggled by the user + capture_mouse: bool, } impl Default for ViewConfig { fn default() -> Self { Self { preview_templates: true, + capture_mouse: true, } } } diff --git a/src/tui/view/common.rs b/src/tui/view/common.rs new file mode 100644 index 00000000..e5b23666 --- /dev/null +++ b/src/tui/view/common.rs @@ -0,0 +1,221 @@ +//! Common reusable components for building the view. Children here should be +//! generic, i.e. usable in more than a single narrow context. + +pub mod list; +pub mod modal; +pub mod table; +pub mod tabs; +pub mod template_preview; +pub mod text_window; + +use crate::{ + collection::{Profile, RequestRecipe}, + http::{RequestBuildError, RequestError}, + tui::view::{draw::Generate, state::Notification, theme::Theme}, +}; +use chrono::{DateTime, Duration, Local, Utc}; +use itertools::Itertools; +use ratatui::{ + text::{Span, Text}, + widgets::Borders, +}; +use reqwest::header::HeaderValue; + +/// A container with a title and border +pub struct Block<'a> { + pub title: &'a str, + pub is_focused: bool, +} + +impl<'a> Generate for Block<'a> { + type Output<'this> = ratatui::widgets::Block<'this> where Self: 'this; + + fn generate<'this>(self) -> Self::Output<'this> + where + Self: 'this, + { + ratatui::widgets::Block::default() + .borders(Borders::ALL) + .border_style(Theme::get().pane_border_style(self.is_focused)) + .title(self.title) + } +} + +/// Yes or no? +pub struct Checkbox { + pub checked: bool, +} + +impl Generate for Checkbox { + type Output<'this> = Text<'static>; + + fn generate<'this>(self) -> Self::Output<'this> + where + Self: 'this, + { + if self.checked { + "[x]".into() + } else { + "[ ]".into() + } + } +} + +impl Generate for String { + /// Use `Text` because a string can be multiple lines + type Output<'this> = Text<'static>; + + fn generate<'this>(self) -> Self::Output<'this> + where + Self: 'this, + { + self.into() + } +} + +impl Generate for &String { + /// Use `Text` because a string can be multiple lines + type Output<'this> = Text<'this> where Self: 'this; + + fn generate<'this>(self) -> Self::Output<'this> + where + Self: 'this, + { + self.as_str().into() + } +} + +impl Generate for &Profile { + type Output<'this> = Span<'this> where Self: 'this; + + fn generate<'this>(self) -> Self::Output<'this> + where + Self: 'this, + { + self.name().to_owned().into() + } +} + +impl Generate for &RequestRecipe { + type Output<'this> = Span<'this> where Self: 'this; + + fn generate<'this>(self) -> Self::Output<'this> + where + Self: 'this, + { + format!("[{}] {}", self.method, self.name()).into() + } +} + +impl Generate for &Notification { + type Output<'this> = Span<'this> where Self: 'this; + + fn generate<'this>(self) -> Self::Output<'this> + where + Self: 'this, + { + format!( + "[{}] {}", + self.timestamp.with_timezone(&Local).format("%H:%M:%S"), + self.message + ) + .into() + } +} + +/// Format a timestamp in the local timezone +impl Generate for DateTime { + type Output<'this> = Span<'this> where Self: 'this; + + fn generate<'this>(self) -> Self::Output<'this> + where + Self: 'this, + { + self.with_timezone(&Local) + .format("%b %e %H:%M:%S") + .to_string() + .into() + } +} + +impl Generate for Duration { + /// 'static because string is generated + type Output<'this> = Span<'static>; + + fn generate<'this>(self) -> Self::Output<'this> + where + Self: 'this, + { + let ms = self.num_milliseconds(); + if ms < 1000 { + format!("{ms}ms").into() + } else { + format!("{:.2}s", ms as f64 / 1000.0).into() + } + } +} + +impl Generate for Option { + type Output<'this> = Span<'this> where Self: 'this; + + fn generate<'this>(self) -> Self::Output<'this> + where + Self: 'this, + { + match self { + Some(duration) => duration.generate(), + // For incomplete requests typically + None => "???".into(), + } + } +} + +/// Not all header values are UTF-8; use a placeholder if not +impl Generate for &HeaderValue { + type Output<'this> = Span<'this> where Self: 'this; + + fn generate<'this>(self) -> Self::Output<'this> + where + Self: 'this, + { + match self.to_str() { + Ok(s) => s.into(), + Err(_) => "".into(), + } + } +} + +impl Generate for &anyhow::Error { + /// 'static because string is generated + type Output<'this> = Text<'static> where Self: 'this; + + fn generate<'this>(self) -> Self::Output<'this> + where + Self: 'this, + { + self.chain().map(|err| err.to_string()).join("\n").into() + } +} + +impl Generate for &RequestBuildError { + type Output<'this> = Text<'static> where Self: 'this; + + fn generate<'this>(self) -> Self::Output<'this> + where + Self: 'this, + { + // Defer to the underlying anyhow error + self.error.generate() + } +} + +impl Generate for &RequestError { + type Output<'this> = Text<'static> where Self: 'this; + + fn generate<'this>(self) -> Self::Output<'this> + where + Self: 'this, + { + self.error.to_string().into() + } +} diff --git a/src/tui/view/common/list.rs b/src/tui/view/common/list.rs new file mode 100644 index 00000000..36539cf0 --- /dev/null +++ b/src/tui/view/common/list.rs @@ -0,0 +1,39 @@ +use crate::tui::view::{ + common::Block, draw::Generate, state::select::SelectState, theme::Theme, +}; +use ratatui::{ + text::Span, + widgets::{ListItem, ListState}, +}; + +/// A list with a border and title. Each item has to be convertible to text +pub struct List<'a, T> { + pub block: Block<'a>, + pub list: &'a SelectState, +} + +impl<'a, T> Generate for List<'a, T> +where + &'a T: Generate = Span<'a>>, +{ + type Output<'this> = ratatui::widgets::List<'this> where Self: 'this; + + fn generate<'this>(self) -> Self::Output<'this> + where + Self: 'this, + { + let block = self.block.generate(); + + // Convert each list item into text + let items: Vec> = self + .list + .items() + .iter() + .map(|i| ListItem::new(i.generate())) + .collect(); + + ratatui::widgets::List::new(items) + .block(block) + .highlight_style(Theme::get().list_highlight_style) + } +} diff --git a/src/tui/view/component/modal.rs b/src/tui/view/common/modal.rs similarity index 92% rename from src/tui/view/component/modal.rs rename to src/tui/view/common/modal.rs index 6164a2eb..16aacc56 100644 --- a/src/tui/view/component/modal.rs +++ b/src/tui/view/common/modal.rs @@ -1,13 +1,11 @@ use crate::tui::{ input::Action, view::{ - component::{ - Component, Draw, DrawContext, Event, Update, UpdateContext, - }, + draw::{Draw, DrawContext}, + event::{Event, EventHandler, Update, UpdateContext}, util::centered_rect, }, }; -use derive_more::Display; use ratatui::{ prelude::{Constraint, Rect}, widgets::{Block, BorderType, Borders, Clear}, @@ -23,7 +21,7 @@ use tracing::trace; /// 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 { +pub trait Modal: Draw<()> + EventHandler { /// Text at the top of the modal fn title(&self) -> &str; @@ -36,7 +34,7 @@ pub trait Modal: Draw<()> + Component { /// Annoying thing to cast from a modal to a base component. Remove after /// https://github.com/rust-lang/rust/issues/65991 - fn as_component(&mut self) -> &mut dyn Component; + fn as_component(&mut self) -> &mut dyn EventHandler; } /// Define how a type can be converted into a modal. Often times, implementors @@ -50,8 +48,7 @@ pub trait IntoModal { fn into_modal(self) -> Self::Target; } -#[derive(Debug, Display)] -#[display(fmt = "ModalQueue ({} in queue)", "queue.len()")] +#[derive(Debug)] pub struct ModalQueue { queue: VecDeque>, } @@ -100,7 +97,7 @@ impl ModalQueue { } } -impl Component for ModalQueue { +impl EventHandler for ModalQueue { 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 @@ -131,7 +128,7 @@ impl Component for ModalQueue { } } - fn children(&mut self) -> Vec<&mut dyn Component> { + fn children(&mut self) -> Vec<&mut dyn EventHandler> { match self.queue.front_mut() { Some(first) => vec![first.as_component()], None => vec![], diff --git a/src/tui/view/common/table.rs b/src/tui/view/common/table.rs new file mode 100644 index 00000000..2d582830 --- /dev/null +++ b/src/tui/view/common/table.rs @@ -0,0 +1,67 @@ +use crate::tui::view::{draw::Generate, theme::Theme}; +use ratatui::{ + prelude::Constraint, + widgets::{Cell, Row}, +}; + +/// Tabular data display with a static number of columns +#[derive(Debug)] +pub struct Table<'a, const COLS: usize, Rows> { + pub rows: Rows, + /// Optional header row. Length should match column length + pub header: Option<[&'a str; COLS]>, + /// Use a different styling for alternating rows + pub alternate_row_style: bool, + /// Take an array ref (NOT a slice) so we can enforce the length, but the + /// lifetime can outlive this struct + pub column_widths: &'a [Constraint; COLS], +} + +impl<'a, const COLS: usize, Rows: Default> Default for Table<'a, COLS, Rows> { + fn default() -> Self { + Self { + rows: Default::default(), + header: None, + alternate_row_style: true, + // Evenly spaced by default + column_widths: &[Constraint::Ratio(1, COLS as u32); COLS], + } + } +} + +impl<'a, const COLS: usize, Cll, Rows> Generate for Table<'a, COLS, Rows> +where + Cll: Into>, + Rows: IntoIterator, +{ + type Output<'this> = ratatui::widgets::Table<'this> + where + Self: 'this; + + fn generate<'this>(self) -> Self::Output<'this> + where + Self: 'this, + { + let theme = Theme::get(); + let rows = self.rows.into_iter().enumerate().map(|(i, row)| { + // Alternate row style for readability + let style = if self.alternate_row_style && i % 2 == 1 { + theme.table_alt_text_style + } else { + theme.table_text_style + }; + Row::new(row).style(style) + }); + let mut table = ratatui::widgets::Table::new(rows) + .highlight_style(theme.table_highlight_style) + .widths(self.column_widths); + + // Add optional header if given + if let Some(header) = self.header { + table = + table.header(Row::new(header).style(theme.table_header_style)); + } + + table + } +} diff --git a/src/tui/view/component/tabs.rs b/src/tui/view/common/tabs.rs similarity index 74% rename from src/tui/view/component/tabs.rs rename to src/tui/view/common/tabs.rs index e7867f02..bee25e56 100644 --- a/src/tui/view/component/tabs.rs +++ b/src/tui/view/common/tabs.rs @@ -1,30 +1,28 @@ use crate::tui::{ input::Action, view::{ - component::{ - Component, Draw, DrawContext, Event, Update, UpdateContext, - }, - state::{FixedSelect, StatefulSelect}, + draw::{Draw, DrawContext}, + event::{Event, EventHandler, Update, UpdateContext}, + state::select::{FixedSelect, FixedSelectState}, + theme::Theme, }, }; -use derive_more::Display; use ratatui::prelude::Rect; use std::fmt::Debug; /// Multi-tab display. Generic parameter defines the available tabs. -#[derive(Debug, Default, Display)] -#[display(fmt = "Tabs")] +#[derive(Debug, Default)] pub struct Tabs { - tabs: StatefulSelect, + tabs: FixedSelectState, } impl Tabs { - pub fn selected(&self) -> T { + pub fn selected(&self) -> &T { self.tabs.selected() } } -impl Component for Tabs { +impl EventHandler for Tabs { fn update(&mut self, _context: &mut UpdateContext, event: Event) -> Update { match event { Event::Input { @@ -54,7 +52,7 @@ impl Draw for Tabs { T::iter().map(|e| e.to_string()).collect(), ) .select(self.tabs.selected_index()) - .highlight_style(context.theme.tab_highlight_style), + .highlight_style(Theme::get().tab_highlight_style), chunk, ) } diff --git a/src/tui/view/component/template_preview.rs b/src/tui/view/common/template_preview.rs similarity index 75% rename from src/tui/view/component/template_preview.rs rename to src/tui/view/common/template_preview.rs index 6e8046db..13fa568d 100644 --- a/src/tui/view/component/template_preview.rs +++ b/src/tui/view/common/template_preview.rs @@ -4,9 +4,8 @@ use crate::{ tui::{ message::Message, view::{ - component::{Draw, DrawContext}, + draw::{Draw, DrawContext, Generate}, theme::Theme, - util::ToTui, }, }, }; @@ -26,12 +25,17 @@ use std::{ /// rendered version. This switch is stored in render context, so it can be /// changed globally. #[derive(Debug)] -pub struct TemplatePreview { - template: Template, - /// Rendered chunks. On init we send a message which will trigger a task to - /// start the render. When the task is done, it'll dump its result back - /// here. - chunks: Arc>>, +pub enum TemplatePreview { + /// Template previewing is disabled, just show the raw text + Disabled { template: Template }, + /// Template previewing is enabled, render the template + Enabled { + template: Template, + /// Rendered chunks. On init we send a message which will trigger a + /// task to start the render. When the task is done, it'll dump + /// its result back here. + chunks: Arc>>, + }, } impl TemplatePreview { @@ -42,54 +46,59 @@ impl TemplatePreview { context: &DrawContext, template: Template, profile_id: Option, + enabled: bool, ) -> Self { - // Tell the controller to start rendering the preview, and it'll store - // it back here when done - let lock = Arc::new(OnceLock::new()); - context.messages_tx.send(Message::TemplatePreview { - template: template.clone(), // If this is a bottleneck we can Arc it - profile_id, - destination: Arc::clone(&lock), - }); - - Self { - template, - chunks: lock, + if enabled { + // Tell the controller to start rendering the preview, and it'll + // store it back here when done + let lock = Arc::new(OnceLock::new()); + context.messages_tx.send(Message::TemplatePreview { + template: template.clone(), /* If this is a bottleneck we can + * Arc it */ + profile_id, + destination: Arc::clone(&lock), + }); + + Self::Enabled { + template, + chunks: lock, + } + } else { + Self::Disabled { template } } } } -impl ToTui for TemplatePreview { +impl Generate for &TemplatePreview { type Output<'this> = Text<'this> where Self: 'this; - fn to_tui(&self, context: &DrawContext) -> Self::Output<'_> { + fn generate<'this>(self) -> Self::Output<'this> + where + Self: 'this, + { // The raw template string - let raw = self.template.deref(); - - if context.config.preview_templates { - // If the preview render is ready, show it. Otherwise fall back to - // the raw - match self.chunks.get() { - Some(chunks) => TextStitcher::stitch_chunks( - &self.template, - chunks, - context.theme, - ), - // Preview still rendering - None => raw.into(), + match self { + TemplatePreview::Disabled { template } => template.deref().into(), + TemplatePreview::Enabled { template, chunks } => { + // If the preview render is ready, show it. Otherwise fall back + // to the raw + match chunks.get() { + Some(chunks) => { + TextStitcher::stitch_chunks(template, chunks) + } + // Preview still rendering + None => template.deref().into(), + } } - } else { - raw.into() } } } -/// Anything that can be converted to text can be drawn impl Draw for TemplatePreview { fn draw(&self, context: &mut DrawContext, _: (), chunk: Rect) { - let text = self.to_tui(context); + let text = self.generate(); context.frame.render_widget(Paragraph::new(text), chunk); } } @@ -111,8 +120,9 @@ impl<'a> TextStitcher<'a> { fn stitch_chunks( template: &'a Template, chunks: &'a [TemplateChunk], - theme: &Theme, ) -> Text<'a> { + let theme = Theme::get(); + // Each chunk will get its own styling, but we can't just make each // chunk a Span, because one chunk might have multiple lines. And we // can't make each chunk a Line, because multiple chunks might be @@ -202,9 +212,9 @@ mod tests { let profile = indexmap! { "user_id".into() => "๐Ÿงก\n๐Ÿ’›".into() }; let context = create!(TemplateContext, profile: profile); let chunks = template.render_chunks(&context).await; - let theme = Theme::default(); + let theme = Theme::get(); - let text = TextStitcher::stitch_chunks(&template, &chunks, &theme); + let text = TextStitcher::stitch_chunks(&template, &chunks); let rendered_style = theme.template_preview_text; let error_style = theme.template_preview_error; let expected = Text::from(vec![ diff --git a/src/tui/view/component/text_window.rs b/src/tui/view/common/text_window.rs similarity index 84% rename from src/tui/view/component/text_window.rs rename to src/tui/view/common/text_window.rs index 93f231ba..69a6f6dd 100644 --- a/src/tui/view/component/text_window.rs +++ b/src/tui/view/common/text_window.rs @@ -1,11 +1,11 @@ use crate::tui::{ input::Action, view::{ - component::{Component, Draw, DrawContext, Event, Update}, - util::{layout, ToTui}, + draw::{Draw, DrawContext, Generate}, + event::{Event, EventHandler, Update, UpdateContext}, + util::layout, }, }; -use derive_more::Display; use ratatui::{ prelude::{Alignment, Constraint, Direction, Rect}, text::{Line, Text}, @@ -19,9 +19,9 @@ use std::{cell::Cell, cmp, fmt::Debug}; /// /// The generic parameter allows for any type that can be converted to ratatui's /// `Text`, e.g. `String` or `TemplatePreview`. -#[derive(Debug, Display)] -#[display(fmt = "TextWindow")] +#[derive(derive_more::Debug)] pub struct TextWindow { + #[debug(skip)] text: T, offset_y: u16, text_height: Cell, @@ -53,12 +53,8 @@ impl TextWindow { } } -impl Component for TextWindow { - fn update( - &mut self, - _context: &mut super::UpdateContext, - event: Event, - ) -> Update { +impl EventHandler for TextWindow { + fn update(&mut self, _context: &mut UpdateContext, event: Event) -> Update { match event { Event::Input { action: Some(Action::Up), @@ -79,9 +75,12 @@ impl Component for TextWindow { } } -impl<'a, T: 'a + ToTui = Text<'a>>> Draw for &'a TextWindow { +impl<'a, T> Draw for &'a TextWindow +where + &'a T: 'a + Generate = Text<'a>>, +{ fn draw(&self, context: &mut DrawContext, _: (), chunk: Rect) { - let text = self.text.to_tui(context); + let text = self.text.generate(); // TODO how do we handle text longer than 65k lines? let text_height = text.lines.len() as u16; self.text_height.set(text_height); @@ -115,8 +114,7 @@ impl<'a, T: 'a + ToTui = Text<'a>>> Draw for &'a TextWindow { // Darw the text content context.frame.render_widget( - Paragraph::new(self.text.to_tui(context)) - .scroll((self.offset_y, 0)), + Paragraph::new(self.text.generate()).scroll((self.offset_y, 0)), text_chunk, ); } diff --git a/src/tui/view/component/misc.rs b/src/tui/view/component/misc.rs index 187946c5..f4fbcba0 100644 --- a/src/tui/view/component/misc.rs +++ b/src/tui/view/component/misc.rs @@ -6,27 +6,23 @@ use crate::{ tui::{ input::Action, view::{ - component::{ - modal::IntoModal, primary::PrimaryPane, root::FullscreenMode, - Component, Draw, Event, Modal, Update, UpdateContext, - }, + common::modal::{IntoModal, Modal}, + component::{primary::PrimaryPane, root::FullscreenMode}, + draw::{Draw, DrawContext, Generate}, + event::{Event, EventHandler, Update, UpdateContext}, state::Notification, - util::{layout, ButtonBrick, ToTui}, - DrawContext, }, }, }; -use derive_more::Display; use itertools::Itertools; use ratatui::{ - prelude::{Alignment, Constraint, Direction, Rect}, + prelude::{Constraint, Rect}, widgets::{Paragraph, Wrap}, }; use std::fmt::Debug; use tui_textarea::TextArea; -#[derive(Debug, Display)] -#[display(fmt = "ErrorModal")] +#[derive(Debug)] pub struct ErrorModal(anyhow::Error); impl Modal for ErrorModal { @@ -38,12 +34,12 @@ impl Modal for ErrorModal { (Constraint::Percentage(60), Constraint::Percentage(20)) } - fn as_component(&mut self) -> &mut dyn Component { + fn as_component(&mut self) -> &mut dyn EventHandler { self } } -impl Component for ErrorModal { +impl EventHandler for ErrorModal { fn update(&mut self, context: &mut UpdateContext, event: Event) -> Update { match event { // Extra close action @@ -62,28 +58,9 @@ impl Component for ErrorModal { impl Draw for ErrorModal { fn draw(&self, context: &mut DrawContext, _: (), chunk: Rect) { - let [content_chunk, footer_chunk] = layout( - chunk, - Direction::Vertical, - [Constraint::Min(0), Constraint::Length(1)], - ); - - context.frame.render_widget( - Paragraph::new(self.0.to_tui(context)).wrap(Wrap::default()), - content_chunk, - ); - - // Prompt the user to get out of here context.frame.render_widget( - Paragraph::new( - ButtonBrick { - text: "OK", - is_highlighted: true, - } - .to_tui(context), - ) - .alignment(Alignment::Center), - footer_chunk, + Paragraph::new(self.0.generate()).wrap(Wrap::default()), + chunk, ); } } @@ -98,8 +75,7 @@ impl IntoModal for anyhow::Error { /// Inner state forfn update(&mut self, context:&mut UpdateContext, message: /// Event) -> UpdateOutcome the prompt modal -#[derive(Debug, Display)] -#[display(fmt = "PromptModal")] +#[derive(Debug)] pub struct PromptModal { /// Prompt currently being shown prompt: Prompt, @@ -141,12 +117,12 @@ impl Modal for PromptModal { } } - fn as_component(&mut self) -> &mut dyn Component { + fn as_component(&mut self) -> &mut dyn EventHandler { self } } -impl Component for PromptModal { +impl EventHandler for PromptModal { fn update(&mut self, context: &mut UpdateContext, event: Event) -> Update { match event { // Submit @@ -267,9 +243,8 @@ impl NotificationText { impl Draw for NotificationText { fn draw(&self, context: &mut DrawContext, _: (), chunk: Rect) { - context.frame.render_widget( - Paragraph::new(self.notification.to_tui(context)), - chunk, - ); + context + .frame + .render_widget(Paragraph::new(self.notification.generate()), chunk); } } diff --git a/src/tui/view/component/mod.rs b/src/tui/view/component/mod.rs new file mode 100644 index 00000000..5c991929 --- /dev/null +++ b/src/tui/view/component/mod.rs @@ -0,0 +1,10 @@ +//! Specific single-use components + +pub mod misc; +pub mod primary; +pub mod request; +pub mod response; +pub mod root; +pub mod settings; + +pub use root::{FullscreenMode, Root}; diff --git a/src/tui/view/component/primary.rs b/src/tui/view/component/primary.rs index a3e10de9..29609de4 100644 --- a/src/tui/view/component/primary.rs +++ b/src/tui/view/component/primary.rs @@ -6,15 +6,20 @@ use crate::{ input::Action, message::Message, view::{ + common::{list::List, Block}, component::{ request::{RequestPane, RequestPaneProps}, response::{ResponsePane, ResponsePaneProps}, settings::SettingsModal, - Component, Draw, Event, Update, UpdateContext, }, - state::{FixedSelect, RequestState, StatefulList, StatefulSelect}, - util::{layout, BlockBrick, ListBrick, ToTui}, - DrawContext, ModalPriority, + draw::{Draw, DrawContext, Generate}, + event::{Event, EventHandler, Update, UpdateContext}, + state::{ + select::{FixedSelectState, SelectState}, + RequestState, + }, + util::layout, + ModalPriority, }, }, }; @@ -23,11 +28,10 @@ use ratatui::prelude::{Constraint, Direction, Rect}; use strum::EnumIter; /// Primary TUI view, which shows request/response panes -#[derive(Debug, Display)] -#[display(fmt = "PrimaryView")] +#[derive(Debug)] pub struct PrimaryView { // Own state - selected_pane: StatefulSelect, + selected_pane: FixedSelectState, // Children profile_list_pane: ProfileListPane, @@ -41,25 +45,21 @@ pub struct PrimaryViewProps<'a> { } /// Selectable panes in the primary view mode -#[derive( - Copy, Clone, Debug, Default, derive_more::Display, EnumIter, PartialEq, -)] +#[derive(Copy, Clone, Debug, Default, Display, EnumIter, PartialEq)] pub enum PrimaryPane { - #[display(fmt = "Profiles")] + #[display("Profiles")] ProfileList, #[default] - #[display(fmt = "Recipes")] + #[display("Recipes")] RecipeList, Request, Response, } -impl FixedSelect for PrimaryPane {} - impl PrimaryView { pub fn new(collection: &RequestCollection) -> Self { Self { - selected_pane: StatefulSelect::default(), + selected_pane: Default::default(), profile_list_pane: ProfileListPane::new( collection.profiles.to_owned(), @@ -86,7 +86,7 @@ impl PrimaryView { /// Which pane is selected? pub fn selected_pane(&self) -> PrimaryPane { - self.selected_pane.selected() + *self.selected_pane.selected() } /// Expose request pane, for fullscreening @@ -110,7 +110,7 @@ impl PrimaryView { } } -impl Component for PrimaryView { +impl EventHandler for PrimaryView { fn update(&mut self, context: &mut UpdateContext, event: Event) -> Update { match event { // Send HTTP request (bubbled up from child *or* queued by parent) @@ -156,10 +156,10 @@ impl Component for PrimaryView { } } - fn children(&mut self) -> Vec<&mut dyn Component> { + fn children(&mut self) -> Vec<&mut dyn EventHandler> { vec![match self.selected_pane.selected() { PrimaryPane::ProfileList => { - &mut self.profile_list_pane as &mut dyn Component + &mut self.profile_list_pane as &mut dyn EventHandler } PrimaryPane::RecipeList => &mut self.recipe_list_pane, PrimaryPane::Request => &mut self.request_pane, @@ -241,10 +241,9 @@ impl<'a> Draw> for PrimaryView { } } -#[derive(Debug, Display)] -#[display(fmt = "ProfileListPane")] +#[derive(Debug)] struct ProfileListPane { - profiles: StatefulList, + profiles: SelectState, } struct ListPaneProps { @@ -254,30 +253,14 @@ struct ListPaneProps { impl ProfileListPane { pub fn new(profiles: Vec) -> Self { Self { - profiles: StatefulList::with_items(profiles), + profiles: SelectState::new(profiles), } } } -impl Component for ProfileListPane { - fn update(&mut self, _context: &mut UpdateContext, event: Event) -> Update { - match event { - Event::Input { - action: Some(Action::Up), - .. - } => { - self.profiles.previous(); - Update::Consumed - } - Event::Input { - action: Some(Action::Down), - .. - } => { - self.profiles.next(); - Update::Consumed - } - _ => Update::Propagate(event), - } +impl EventHandler for ProfileListPane { + fn children(&mut self) -> Vec<&mut dyn EventHandler> { + vec![&mut self.profiles] } } @@ -288,36 +271,35 @@ impl Draw for ProfileListPane { props: ListPaneProps, chunk: Rect, ) { - let list = ListBrick { - block: BlockBrick { - title: PrimaryPane::ProfileList.to_string(), + let list = List { + block: Block { + title: &PrimaryPane::ProfileList.to_string(), is_focused: props.is_selected, }, list: &self.profiles, }; context.frame.render_stateful_widget( - list.to_tui(context), + list.generate(), chunk, &mut self.profiles.state_mut(), ) } } -#[derive(Debug, Display)] -#[display(fmt = "RecipeListPane")] +#[derive(Debug)] struct RecipeListPane { - recipes: StatefulList, + recipes: SelectState, } impl RecipeListPane { pub fn new(recipes: Vec) -> Self { Self { - recipes: StatefulList::with_items(recipes), + recipes: SelectState::new(recipes), } } } -impl Component for RecipeListPane { +impl EventHandler for RecipeListPane { 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() { @@ -338,6 +320,7 @@ impl Component for RecipeListPane { context.queue_event(Event::HttpSendRequest); Update::Consumed } + // TODO use input handling from StatefulList Event::Input { action: Some(Action::Up), .. @@ -365,15 +348,15 @@ impl Draw for RecipeListPane { chunk: Rect, ) { let pane_kind = PrimaryPane::RecipeList; - let list = ListBrick { - block: BlockBrick { - title: pane_kind.to_string(), + let list = List { + block: Block { + title: &pane_kind.to_string(), is_focused: props.is_selected, }, list: &self.recipes, }; context.frame.render_stateful_widget( - list.to_tui(context), + list.generate(), chunk, &mut self.recipes.state_mut(), ) diff --git a/src/tui/view/component/request.rs b/src/tui/view/component/request.rs index 801368f9..7d517edb 100644 --- a/src/tui/view/component/request.rs +++ b/src/tui/view/component/request.rs @@ -4,18 +4,15 @@ use crate::{ tui::{ input::Action, view::{ - component::{ - primary::PrimaryPane, - root::FullscreenMode, - table::{Table, TableProps}, - tabs::Tabs, - template_preview::TemplatePreview, - text_window::TextWindow, - Component, Draw, Event, Update, UpdateContext, + common::{ + table::Table, tabs::Tabs, template_preview::TemplatePreview, + text_window::TextWindow, Block, }, - state::{FixedSelect, StateCell}, - util::{layout, BlockBrick, ToTui}, - DrawContext, + component::{primary::PrimaryPane, root::FullscreenMode}, + draw::{Draw, DrawContext, Generate}, + event::{Event, EventHandler, Update, UpdateContext}, + state::StateCell, + util::layout, }, }, }; @@ -23,19 +20,17 @@ use derive_more::Display; use itertools::Itertools; use ratatui::{ prelude::{Constraint, Direction, Rect}, - text::Text, widgets::Paragraph, }; use strum::EnumIter; /// Display a request recipe -#[derive(Debug, Display, Default)] -#[display(fmt = "RequestPane")] +#[derive(Debug, Default)] pub struct RequestPane { tabs: Tabs, /// All UI state derived from the recipe is stored together, and reset when /// the recipe or profile changes - recipe_state: StateCell<(Option, RequestRecipeId), RecipeState>, + recipe_state: StateCell, } pub struct RequestPaneProps<'a> { @@ -44,6 +39,14 @@ pub struct RequestPaneProps<'a> { pub selected_profile_id: Option<&'a ProfileId>, } +/// Template preview state will be recalculated when any of these fields change +#[derive(Debug, PartialEq)] +struct RecipeStateKey { + selected_profile_id: Option, + recipe_id: RequestRecipeId, + preview_templates: bool, +} + #[derive(Debug)] struct RecipeState { url: TemplatePreview, @@ -60,9 +63,7 @@ enum Tab { Headers, } -impl FixedSelect for Tab {} - -impl Component for RequestPane { +impl EventHandler for RequestPane { fn update(&mut self, context: &mut UpdateContext, event: Event) -> Update { match event { // Toggle fullscreen @@ -80,8 +81,8 @@ impl Component for RequestPane { } } - fn children(&mut self) -> Vec<&mut dyn Component> { - let mut children: Vec<&mut dyn Component> = vec![&mut self.tabs]; + fn children(&mut self) -> Vec<&mut dyn EventHandler> { + let mut children: Vec<&mut dyn EventHandler> = vec![&mut self.tabs]; // If the body is initialized and present, send events there too if let Some(body) = self .recipe_state @@ -103,11 +104,11 @@ impl<'a> Draw> for RequestPane { ) { // Render outermost block let pane_kind = PrimaryPane::Request; - let block = BlockBrick { - title: pane_kind.to_string(), + let block = Block { + title: &pane_kind.to_string(), is_focused: props.is_selected, }; - let block = block.to_tui(context); + let block = block.generate(); let inner_chunk = block.inner(chunk); context.frame.render_widget(block, chunk); @@ -139,12 +140,17 @@ impl<'a> Draw> for RequestPane { // would require reloading the whole collection which will reset // UI state. let recipe_state = self.recipe_state.get_or_update( - (props.selected_profile_id.cloned(), recipe.id.clone()), + RecipeStateKey { + selected_profile_id: props.selected_profile_id.cloned(), + recipe_id: recipe.id.clone(), + preview_templates: context.config.preview_templates, + }, || RecipeState { url: TemplatePreview::new( context, recipe.url.clone(), props.selected_profile_id.cloned(), + context.config.preview_templates, ), query: to_template_previews( context, @@ -161,6 +167,7 @@ impl<'a> Draw> for RequestPane { context, body.clone(), props.selected_profile_id.cloned(), + context.config.preview_templates, )) }), }, @@ -183,22 +190,34 @@ impl<'a> Draw> for RequestPane { body.draw(context, (), content_chunk); } } - Tab::Query => Table.draw( - context, - TableProps { - key_label: "Parameter", - value_label: "Value", - data: to_table_text(context, &recipe_state.query), - }, + Tab::Query => context.frame.render_widget( + Table { + rows: recipe_state + .query + .iter() + .map(|(param, value)| { + [param.as_str().into(), value.generate()] + }) + .collect_vec(), + header: Some(["Parameter", "Value"]), + ..Default::default() + } + .generate(), content_chunk, ), - Tab::Headers => Table.draw( - context, - TableProps { - key_label: "Header", - value_label: "Value", - data: to_table_text(context, &recipe_state.headers), - }, + Tab::Headers => context.frame.render_widget( + Table { + rows: recipe_state + .headers + .iter() + .map(|(param, value)| { + [param.as_str().into(), value.generate()] + }) + .collect_vec(), + header: Some(["Header", "Value"]), + ..Default::default() + } + .generate(), content_chunk, ), } @@ -218,20 +237,13 @@ fn to_template_previews<'a>( .map(|(k, v)| { ( k.clone(), - TemplatePreview::new(context, v.clone(), profile_id.cloned()), + TemplatePreview::new( + context, + v.clone(), + profile_id.cloned(), + context.config.preview_templates, + ), ) }) .collect() } - -/// Convert a map of (string, template preview) to (text, text) so it can be -/// displayed in a table. -fn to_table_text<'a>( - context: &DrawContext, - iter: impl IntoIterator, -) -> Vec<(Text<'a>, Text<'a>)> { - iter.into_iter() - .map(|(param, value)| (param.as_str().into(), value.to_tui(context))) - // Collect required to drop reference to context - .collect_vec() -} diff --git a/src/tui/view/component/response.rs b/src/tui/view/component/response.rs index 5bd1b9ae..050c7f94 100644 --- a/src/tui/view/component/response.rs +++ b/src/tui/view/component/response.rs @@ -3,17 +3,14 @@ use crate::{ tui::{ input::Action, view::{ - component::{ - primary::PrimaryPane, - root::FullscreenMode, - table::{Table, TableProps}, - tabs::Tabs, - text_window::TextWindow, - Component, Draw, Event, Update, UpdateContext, + common::{ + table::Table, tabs::Tabs, text_window::TextWindow, Block, }, - state::{FixedSelect, RequestState, StateCell}, - util::{layout, BlockBrick, ToTui}, - DrawContext, + component::{primary::PrimaryPane, root::FullscreenMode}, + draw::{Draw, DrawContext, Generate}, + event::{Event, EventHandler, Update, UpdateContext}, + state::{RequestState, StateCell}, + util::layout, }, }, }; @@ -21,7 +18,7 @@ use derive_more::Display; use itertools::Itertools; use ratatui::{ prelude::{Alignment, Constraint, Direction, Rect}, - text::Line, + text::{Line, Text}, widgets::{Paragraph, Wrap}, }; use std::ops::Deref; @@ -29,8 +26,7 @@ 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")] +#[derive(Debug, Default)] pub struct ResponsePane { content: ResponseContent, } @@ -47,9 +43,7 @@ enum Tab { Headers, } -impl FixedSelect for Tab {} - -impl Component for ResponsePane { +impl EventHandler for ResponsePane { fn update(&mut self, context: &mut UpdateContext, event: Event) -> Update { match event { // Toggle fullscreen @@ -67,7 +61,7 @@ impl Component for ResponsePane { } } - fn children(&mut self) -> Vec<&mut dyn Component> { + fn children(&mut self) -> Vec<&mut dyn EventHandler> { vec![&mut self.content] } } @@ -81,11 +75,11 @@ impl<'a> Draw> for ResponsePane { ) { // Render outermost block let pane_kind = PrimaryPane::Response; - let block = BlockBrick { - title: pane_kind.to_string(), + let block = Block { + title: &pane_kind.to_string(), is_focused: props.is_selected, }; - let block = block.to_tui(context); + let block = block.generate(); let inner_chunk = block.inner(chunk); context.frame.render_widget(block, chunk); @@ -110,9 +104,9 @@ impl<'a> Draw> for ResponsePane { { context.frame.render_widget( Paragraph::new(Line::from(vec![ - start_time.to_tui(context), + start_time.generate(), " / ".into(), - duration.to_tui(context), + duration.generate(), ])) .alignment(Alignment::Right), header_right_chunk, @@ -130,8 +124,7 @@ impl<'a> Draw> for ResponsePane { // :( RequestState::BuildError { error } => { context.frame.render_widget( - Paragraph::new(error.to_tui(context)) - .wrap(Wrap::default()), + Paragraph::new(error.generate()).wrap(Wrap::default()), content_chunk, ); } @@ -167,8 +160,7 @@ impl<'a> Draw> for ResponsePane { // Sadge RequestState::RequestError { error } => { context.frame.render_widget( - Paragraph::new(error.to_tui(context)) - .wrap(Wrap::default()), + Paragraph::new(error.generate()).wrap(Wrap::default()), content_chunk, ); } @@ -178,8 +170,7 @@ impl<'a> Draw> for ResponsePane { } /// Display response success state (tab container) -#[derive(Debug, Default, Display)] -#[display(fmt = "ResponsePane")] +#[derive(Debug, Default)] struct ResponseContent { tabs: Tabs, /// Persist the response body to track view state. Update whenever the @@ -192,7 +183,7 @@ struct ResponseContentProps<'a> { pretty_body: Option<&'a str>, } -impl Component for ResponseContent { +impl EventHandler for ResponseContent { fn update(&mut self, context: &mut UpdateContext, event: Event) -> Update { match event { // Toggle fullscreen @@ -210,8 +201,8 @@ impl Component for ResponseContent { } } - fn children(&mut self) -> Vec<&mut dyn Component> { - let mut children: Vec<&mut dyn Component> = vec![&mut self.tabs]; + fn children(&mut self) -> Vec<&mut dyn EventHandler> { + let mut children: Vec<&mut dyn EventHandler> = vec![&mut self.tabs]; if let Some(body) = self.body.get_mut() { children.push(body); } @@ -252,20 +243,19 @@ impl<'a> Draw> for ResponseContent { }); body.deref().draw(context, (), content_chunk) } - Tab::Headers => Table.draw( - context, - TableProps { - key_label: "Header", - value_label: "Value", - data: response + Tab::Headers => context.frame.render_widget( + Table { + rows: response .headers .iter() .map(|(k, v)| { - (k.as_str().into(), v.to_tui(context).into()) + [Text::from(k.as_str()), v.generate().into()] }) - // Collect required to close context ref .collect_vec(), - }, + header: Some(["Header", "Value"]), + ..Default::default() + } + .generate(), content_chunk, ), } diff --git a/src/tui/view/component/root.rs b/src/tui/view/component/root.rs index 36820771..1043829a 100644 --- a/src/tui/view/component/root.rs +++ b/src/tui/view/component/root.rs @@ -4,27 +4,25 @@ use crate::{ input::Action, message::Message, view::{ + common::modal::ModalQueue, component::{ misc::{HelpText, HelpTextProps, NotificationText}, - modal::ModalQueue, primary::{PrimaryView, PrimaryViewProps}, request::RequestPaneProps, response::ResponsePaneProps, - Component, Draw, Event, Update, UpdateContext, }, + draw::{Draw, DrawContext}, + event::{Event, EventHandler, Update, UpdateContext}, state::RequestState, util::layout, - DrawContext, }, }, }; -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")] +#[derive(derive_more::Debug)] pub struct Root { // ===== Own State ===== /// Cached request state. A recipe will appear in this map if two @@ -39,9 +37,11 @@ pub struct Root { // ==== 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 + #[debug(skip)] primary_view: PrimaryView, - // fullscreen_view: Option, + #[debug(skip)] modal_queue: ModalQueue, + #[debug(skip)] notification_text: Option, } @@ -103,7 +103,7 @@ impl Root { } } -impl Component for Root { +impl EventHandler for Root { fn update(&mut self, context: &mut UpdateContext, event: Event) -> Update { match event { Event::Init => { @@ -148,9 +148,10 @@ impl Component for Root { Update::Consumed } - fn children(&mut self) -> Vec<&mut dyn Component> { + fn children(&mut self) -> Vec<&mut dyn EventHandler> { let modal_open = self.modal_queue.is_open(); - let mut children: Vec<&mut dyn Component> = vec![&mut self.modal_queue]; + let mut children: Vec<&mut dyn EventHandler> = + vec![&mut self.modal_queue]; // If a modal is open, don't allow *any* input to the background. We'll // still accept input ourselves though, which should only be diff --git a/src/tui/view/component/settings.rs b/src/tui/view/component/settings.rs index 2632364d..fb329197 100644 --- a/src/tui/view/component/settings.rs +++ b/src/tui/view/component/settings.rs @@ -1,33 +1,32 @@ use crate::tui::{ input::Action, + message::Message, view::{ - component::{ - Component, Draw, DrawContext, Event, Modal, Update, UpdateContext, - }, - util::{Checkbox, ToTui}, + common::{modal::Modal, table::Table, Checkbox}, + draw::{Draw, DrawContext, Generate}, + event::{Event, EventHandler, Update, UpdateContext}, + state::select::FixedSelectState, + ViewConfig, }, }; use derive_more::Display; +use itertools::Itertools; use ratatui::{ prelude::{Constraint, Rect}, - widgets::{Cell, Row, Table, TableState}, + widgets::{Cell, TableState}, }; -use std::{cell::RefCell, ops::DerefMut}; -use tracing::error; +use strum::{EnumIter, IntoEnumIterator}; /// Modal to view and modify user/view configuration -#[derive(Debug, Display)] -#[display(fmt = "SettingsModal")] +#[derive(Debug)] pub struct SettingsModal { - table_state: RefCell, + table: FixedSelectState, } impl Default for SettingsModal { fn default() -> Self { Self { - table_state: RefCell::new( - TableState::default().with_selected(Some(0)), - ), + table: FixedSelectState::new(), } } } @@ -41,64 +40,84 @@ impl Modal for SettingsModal { (Constraint::Length(30), Constraint::Length(5)) } - fn as_component(&mut self) -> &mut dyn Component { + fn as_component(&mut self) -> &mut dyn EventHandler { self } } -impl Component for SettingsModal { +impl EventHandler for SettingsModal { fn update(&mut self, context: &mut UpdateContext, event: Event) -> Update { - let table_state = self.table_state.get_mut(); match event { Event::Input { - action: Some(action), + action: Some(Action::Submit), .. - } => match action { - // There are no other settings to scroll through yet, implement - // that when necessary - Action::Up => Update::Consumed, - Action::Down => Update::Consumed, - Action::Submit => { - match table_state.selected() { - Some(0) => { - context.config.preview_templates = - !context.config.preview_templates; - } - other => { - // Huh? - error!( - state = ?other, - "Unexpected settings table select state" - ); - } + } => { + match self.table.selected() { + Setting::PreviewTemplates => { + context.config().preview_templates ^= true; + } + Setting::CaptureMouse => { + context.config().capture_mouse ^= true; + let capture = context.config().capture_mouse; + context.send_message(Message::ToggleMouseCapture { + capture, + }); } - Update::Consumed } - _ => Update::Propagate(event), - }, + Update::Consumed + } _ => Update::Propagate(event), } } + + fn children(&mut self) -> Vec<&mut dyn EventHandler> { + vec![&mut self.table] + } } impl Draw for SettingsModal { fn draw(&self, context: &mut DrawContext, _: (), chunk: Rect) { - let preview_templates_checkbox = Checkbox { - checked: context.config.preview_templates, - }; - let rows = vec![Row::new(vec![ - Cell::from("Preview Templates"), - preview_templates_checkbox.to_tui(context).into(), - ])]; - let table = Table::new(rows) - .style(context.theme.table_text_style) - .highlight_style(context.theme.table_highlight_style) - .widths(&[Constraint::Percentage(80), Constraint::Percentage(20)]); - context.frame.render_stateful_widget( - table, + Table { + rows: Setting::iter() + .map::<[Cell; 2], _>(|setting| { + [ + setting.to_string().into(), + Checkbox { + checked: setting.get_value(context.config), + } + .generate() + .into(), + ] + }) + .collect_vec(), + alternate_row_style: false, + column_widths: &[Constraint::Min(24), Constraint::Length(3)], + ..Default::default() + } + .generate(), chunk, - self.table_state.borrow_mut().deref_mut(), + &mut self.table.state_mut(), ); } } + +/// Various configurable settings +#[derive(Copy, Clone, Debug, Default, Display, EnumIter, PartialEq)] +enum Setting { + #[default] + #[display("Preview Templates")] + PreviewTemplates, + #[display("Capture Mouse")] + CaptureMouse, +} + +impl Setting { + /// Get the value of a setting from the config + fn get_value(self, config: &ViewConfig) -> bool { + match self { + Self::PreviewTemplates => config.preview_templates, + Self::CaptureMouse => config.capture_mouse, + } + } +} diff --git a/src/tui/view/component/table.rs b/src/tui/view/component/table.rs deleted file mode 100644 index ba41e7c8..00000000 --- a/src/tui/view/component/table.rs +++ /dev/null @@ -1,48 +0,0 @@ -use crate::tui::view::component::{Draw, DrawContext}; -use derive_more::Display; -use ratatui::{ - prelude::{Constraint, Rect}, - text::Text, - widgets::Row, -}; - -/// 2-column tabular data display -#[derive(Debug, Display)] -pub struct Table; - -pub struct TableProps<'a, T> { - pub key_label: &'a str, - pub value_label: &'a str, - pub data: T, -} - -/// An iterator of (key, value) text pairs can be a table -impl<'a, Iter, Data> Draw> for Table -where - Iter: Iterator, Text<'a>)>, - Data: IntoIterator, Text<'a>), IntoIter = Iter>, -{ - fn draw( - &self, - context: &mut DrawContext, - props: TableProps<'a, Data>, - chunk: Rect, - ) { - let rows = props.data.into_iter().enumerate().map(|(i, (k, v))| { - // Alternate row style for readability - let style = if i % 2 == 0 { - context.theme.table_text_style - } else { - context.theme.table_alt_text_style - }; - Row::new(vec![k, v]).style(style) - }); - let table = ratatui::widgets::Table::new(rows) - .header( - Row::new(vec![props.key_label, props.value_label]) - .style(context.theme.table_header_style), - ) - .widths(&[Constraint::Percentage(50), Constraint::Percentage(50)]); - context.frame.render_widget(table, chunk) - } -} diff --git a/src/tui/view/draw.rs b/src/tui/view/draw.rs new file mode 100644 index 00000000..a844d3aa --- /dev/null +++ b/src/tui/view/draw.rs @@ -0,0 +1,49 @@ +//! Traits for rendering stuff + +use crate::tui::{ + input::InputEngine, message::MessageSender, view::ViewConfig, +}; +use ratatui::{layout::Rect, Frame}; + +/// Something that can be drawn onto screen as one or more TUI widgets. +/// +/// Conceptually this is bascially part of `Component`, but having it separate +/// allows the `Props` associated type. Otherwise, there's no way to make a +/// trait object from `Component` across components with different props. +/// +/// Props are additional temporary values that a struct may need in order +/// to render. Useful for passing down state values that are managed by +/// the parent, to avoid duplicating that state in the child. `Props` probably +/// would make more sense as an associated type, because you generally wouldn't +/// implement `Draw` for a single type with more than one value of `Props`. But +/// attaching a lifetime to the associated type makes using this in a trait +/// object very difficult (maybe impossible?). This is an easy shortcut. +pub trait Draw { + fn draw(&self, context: &mut DrawContext, props: Props, chunk: Rect); +} + +/// Global data that various components need during rendering. A mutable +/// reference to this is passed around to give access to the frame, but please +/// don't modify anything :) +#[derive(Debug)] +pub struct DrawContext<'a, 'f> { + pub input_engine: &'a InputEngine, + pub config: &'a ViewConfig, + /// Allows draw functions to trigger async operations, if the drawn content + /// needs some async calculation (e.g. template previews) + pub messages_tx: MessageSender, + pub frame: &'a mut Frame<'f>, +} + +/// A helper for building a UI. It can be converted into some UI element to be +/// drawn. +pub trait Generate { + type Output<'this> + where + Self: 'this; + + /// Build a UI element + fn generate<'this>(self) -> Self::Output<'this> + where + Self: 'this; +} diff --git a/src/tui/view/component.rs b/src/tui/view/event.rs similarity index 50% rename from src/tui/view/component.rs rename to src/tui/view/event.rs index 2082373f..47ba7172 100644 --- a/src/tui/view/component.rs +++ b/src/tui/view/event.rs @@ -1,54 +1,27 @@ -//! The building blocks of the view - -mod misc; -mod modal; -mod primary; -mod request; -mod response; -mod root; -mod settings; -mod table; -mod tabs; -mod template_preview; -mod text_window; - -pub use modal::{IntoModal, Modal, ModalPriority}; -pub use root::Root; +//! Utilities for handling input events from users, as well as external async +//! events (e.g. HTTP responses) use crate::{ collection::RequestRecipeId, tui::{ - input::{Action, InputEngine}, + input::Action, message::{Message, MessageSender}, view::{ - component::root::FullscreenMode, + common::modal::{Modal, ModalPriority}, + component::FullscreenMode, state::{Notification, RequestState}, - theme::Theme, - Frame, ViewConfig, + ViewConfig, }, }, }; -use ratatui::prelude::Rect; -use std::{ - collections::VecDeque, - fmt::{Debug, Display}, -}; +use std::{collections::VecDeque, fmt::Debug}; use tracing::trace; -/// The main building block that makes up the view. This is modeled after React, -/// with some key differences: -/// -/// - State can be exposed from child to parent -/// - This is arguably an anti-pattern, but it's a simple solution. Rust makes -/// it possible to expose only immutable references, so I think it's fine. -/// - State changes are managed via message passing rather that callbacks. See -/// [Component::update_all] and [Component::update]. This happens during the -/// message phase of the TUI. -/// - Rendering is provided by a separate trait: [Draw] -/// -/// Requires `Display` impl for tracing. Typically the impl can just be the -/// component name. -pub trait Component: Debug + Display { +/// A UI element that can handle user/async input. This trait facilitates an +/// on-demand tree structure, where each element can furnish its list of +/// children. Events will be propagated bottom-up (i.e. leff-to-root), and each +/// element has the opportunity to consume the event so it stops bubbling. +pub trait EventHandler: Debug { /// 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. @@ -60,7 +33,7 @@ pub trait Component: Debug + Display { /// focused component will receive first dibs on any update messages, in /// the order of the returned list. If none of the children consume the /// message, it will be passed to this component. - fn children(&mut self) -> Vec<&mut dyn Component> { + fn children(&mut self) -> Vec<&mut dyn EventHandler> { Vec::new() } } @@ -96,37 +69,10 @@ impl<'a> UpdateContext<'a> { trace!(?event, "Queueing subsequent event"); self.event_queue.push_back(event); } -} - -/// Something that can be drawn onto screen as one or more TUI widgets. -/// -/// Conceptually this is bascially part of `Component`, but having it separate -/// allows the `Props` associated type. Otherwise, there's no way to make a -/// trait object from `Component` across components with different props. -/// -/// Props are additional temporary values that a struct may need in order -/// to render. Useful for passing down state values that are managed by -/// the parent, to avoid duplicating that state in the child. `Props` probably -/// would make more sense as an associated type, because you generally wouldn't -/// implement `Draw` for a single type with more than one value of `Props`. But -/// attaching a lifetime to the associated type makes using this in a trait -/// object very difficult (maybe impossible?). This is an easy shortcut. -pub trait Draw { - fn draw(&self, context: &mut DrawContext, props: Props, chunk: Rect); -} -/// Global data that various components need during rendering. A mutable -/// reference to this is passed around to give access to the frame, but please -/// don't modify anything :) -#[derive(Debug)] -pub struct DrawContext<'a, 'f> { - pub input_engine: &'a InputEngine, - pub config: &'a ViewConfig, - pub theme: &'a Theme, - /// Allows draw functions to trigger async operations, if the drawn content - /// needs some async calculation (e.g. template previews) - pub messages_tx: MessageSender, - pub frame: &'a mut Frame<'f>, + pub fn config(&mut self) -> &mut ViewConfig { + self.config + } } /// A trigger for state change in the view. Events are handled by @@ -138,6 +84,7 @@ pub struct DrawContext<'a, 'f> { /// This is conceptually different from [Message] in that view messages never /// queued, they are handled immediately. Maybe "message" is a misnomer here and /// we should rename this? +#[derive(derive_more::Debug)] pub enum Event { /// Sent when the view is first opened. If a component is created after the /// initial view setup, it will *not* receive this message. @@ -156,6 +103,7 @@ pub enum Event { /// Update our state based on external HTTP events HttpSetState { recipe_id: RequestRecipeId, + #[debug(skip)] state: RequestState, }, @@ -189,33 +137,3 @@ pub enum Update { /// has a chance to respond to the entire event. Propagate(Event), } - -/// Custom impl to prevent monster tracing messages -impl Debug for Event { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Init => write!(f, "Init"), - Self::Input { event, action } => f - .debug_struct("Input") - .field("event", event) - .field("action", action) - .finish(), - Self::HttpSendRequest => write!(f, "HttpSendRequest"), - Self::HttpSetState { recipe_id, state } => f - .debug_struct("HttpSetState") - .field("recipe_id", recipe_id) - .field("request_id", &state.id()) - .finish(), - Self::ToggleFullscreen(arg0) => { - f.debug_tuple("ToggleFullscreen").field(arg0).finish() - } - Self::OpenModal { modal, priority } => f - .debug_struct("OpenModal") - .field("modal", modal) - .field("priority", priority) - .finish(), - Self::CloseModal => write!(f, "CloseModal"), - Self::Notify(arg0) => f.debug_tuple("Notify").field(arg0).finish(), - } - } -} diff --git a/src/tui/view/state.rs b/src/tui/view/state.rs index 88323b2f..958302ce 100644 --- a/src/tui/view/state.rs +++ b/src/tui/view/state.rs @@ -1,16 +1,13 @@ //! State types for the view. +pub mod select; + use crate::http::{RequestBuildError, RequestError, RequestId, RequestRecord}; use chrono::{DateTime, Duration, Utc}; -use itertools::Itertools; -use ratatui::widgets::*; use std::{ cell::{Ref, RefCell}, - fmt::Display, - marker::PhantomData, - ops::{Deref, DerefMut}, + ops::Deref, }; -use strum::IntoEnumIterator; /// An internally mutable cell for UI state. Certain state needs to be updated /// during the draw phase, typically because it's derived from parent data @@ -188,141 +185,3 @@ impl Notification { } } } - -/// A list of items in the UI -#[derive(Debug)] -pub struct StatefulList { - /// Use interior mutability because this needs to be modified during the - /// draw phase, by [Frame::render_stateful_widget]. This allows rendering - /// without a mutable reference. - state: RefCell, - pub items: Vec, -} - -impl StatefulList { - pub fn with_items(items: Vec) -> StatefulList { - let mut state = ListState::default(); - // Pre-select the first item if possible - if !items.is_empty() { - state.select(Some(0)); - } - StatefulList { - state: RefCell::new(state), - items, - } - } - - /// Get the currently selected item (if any) - pub fn selected(&self) -> Option<&T> { - self.items.get(self.state.borrow().selected()?) - } - - /// Get the number of items in the list - pub fn len(&self) -> usize { - self.items.len() - } - - /// Get a mutable reference to state. This uses `RefCell` underneath so it - /// will panic if aliased. Only call this during the draw phase! - pub fn state_mut(&self) -> impl DerefMut + '_ { - self.state.borrow_mut() - } - - /// Select the previous item in the list. This should only be called during - /// the message phase, so we can take `&mut self`. - pub fn previous(&mut self) { - let state = self.state.get_mut(); - let i = match state.selected() { - Some(i) => { - // Avoid underflow here - if i == 0 { - self.items.len() - 1 - } else { - i - 1 - } - } - None => 0, - }; - state.select(Some(i)); - } - - /// Select the next item in the list. This should only be called during the - /// message phase, so we can take `&mut self`. - pub fn next(&mut self) { - let state = self.state.get_mut(); - let i = match state.selected() { - Some(i) => (i + 1) % self.items.len(), - None => 0, - }; - state.select(Some(i)); - } -} - -/// A fixed-size collection of selectable items, e.g. panes or tabs. User can -/// cycle between them. -#[derive(Debug)] -pub struct StatefulSelect { - selected_index: usize, - _phantom: PhantomData, -} - -/// Friendly marker trait indicating a type can be cycled through, e.g. a set -/// of panes or tabs -pub trait FixedSelect: - Default + Display + IntoEnumIterator + PartialEq -{ -} - -impl StatefulSelect { - pub fn new() -> Self { - Self { - // Find the index of the select type's default value - selected_index: T::iter() - .find_position(|value| value == &T::default()) - .unwrap() - .0, - _phantom: PhantomData, - } - } - - /// Get the index of the selected element - pub fn selected_index(&self) -> usize { - self.selected_index - } - - /// Get the selected element - pub fn selected(&self) -> T { - T::iter() - .nth(self.selected_index) - .expect("StatefulSelect index out of bounds") - } - - /// Is the given item selected? - pub fn is_selected(&self, item: &T) -> bool { - &self.selected() == item - } - - /// Select previous item, returning whether the selection changed - pub fn previous(&mut self) -> bool { - // Prevent underflow - let old = self.selected_index; - self.selected_index = self - .selected_index - .checked_sub(1) - .unwrap_or(T::iter().count() - 1); - old != self.selected_index - } - - /// Select next item, returning whether the selection changed - pub fn next(&mut self) -> bool { - let old = self.selected_index; - self.selected_index = (self.selected_index + 1) % T::iter().count(); - old != self.selected_index - } -} - -impl Default for StatefulSelect { - fn default() -> Self { - Self::new() - } -} diff --git a/src/tui/view/state/select.rs b/src/tui/view/state/select.rs new file mode 100644 index 00000000..073e76ce --- /dev/null +++ b/src/tui/view/state/select.rs @@ -0,0 +1,258 @@ +use crate::tui::{ + input::Action, + view::event::{Event, EventHandler, Update, UpdateContext}, +}; +use itertools::Itertools; +use ratatui::widgets::{ListState, TableState}; +use std::{ + cell::RefCell, + fmt::{Debug, Display}, + ops::DerefMut, +}; +use strum::IntoEnumIterator; + +/// State manager for a dynamic list of items. This supports a generic type for +/// the state "backend", which is the ratatui type that stores the selection +/// state. Typically you want `ListState` or `TableState`. +#[derive(Debug)] +pub struct SelectState { + /// Use interior mutability because this needs to be modified during the + /// draw phase, by [Frame::render_stateful_widget]. This allows rendering + /// without a mutable reference. + state: RefCell, + items: Vec, +} + +impl SelectState { + pub fn new(items: Vec) -> Self { + let mut state = State::default(); + // Pre-select the first item if possible + if !items.is_empty() { + state.select(0); + } + SelectState { + state: RefCell::new(state), + items, + } + } + + pub fn items(&self) -> &[Item] { + &self.items + } + + /// Get the index of the currently selected item (if any) + pub fn selected_index(&self) -> Option { + self.state.borrow().selected() + } + + /// Get the currently selected item (if any) + pub fn selected(&self) -> Option<&Item> { + self.items.get(self.state.borrow().selected()?) + } + + /// Get the number of items in the list + pub fn len(&self) -> usize { + self.items.len() + } + + /// Get a mutable reference to state. This uses `RefCell` underneath so it + /// will panic if aliased. Only call this during the draw phase! + pub fn state_mut(&self) -> impl DerefMut + '_ { + self.state.borrow_mut() + } + + /// Select the previous item in the list. This should only be called during + /// the message phase, so we can take `&mut self`. + pub fn previous(&mut self) { + let state = self.state.get_mut(); + let i = match state.selected() { + Some(i) => { + // Avoid underflow here + if i == 0 { + self.items.len() - 1 + } else { + i - 1 + } + } + None => 0, + }; + state.select(i); + } + + /// Select the next item in the list. This should only be called during the + /// message phase, so we can take `&mut self`. + pub fn next(&mut self) { + let state = self.state.get_mut(); + let i = match state.selected() { + Some(i) => (i + 1) % self.items.len(), + None => 0, + }; + state.select(i); + } +} + +/// Handle input events to cycle between items +impl EventHandler + for SelectState +{ + fn update(&mut self, _context: &mut UpdateContext, event: Event) -> Update { + match event { + Event::Input { + action: Some(action), + .. + } => match action { + Action::Up => { + self.previous(); + Update::Consumed + } + Action::Down => { + self.next(); + Update::Consumed + } + _ => Update::Propagate(event), + }, + _ => Update::Propagate(event), + } + } +} + +/// State manager for a fixed-size collection of statically known selectable +/// items, e.g. panes or tabs. User can cycle between them. This is mostly a +/// wrapper around [SelectState], with some extra convenience based around the +/// fact that we statically know the available options. +#[derive(Debug)] +pub struct FixedSelectState< + Item: FixedSelect, + State: SelectStateData = ListState, +> { + /// Internally use a dynamic list. We know it's not empty though, so we can + /// assume that an item is always selected. + state: SelectState, +} + +impl FixedSelectState +where + Item: FixedSelect, + State: SelectStateData, +{ + pub fn new() -> Self { + let items = Item::iter().collect_vec(); + let mut state = State::default(); + // Pre-select the default item + let selected = items + .iter() + .find_position(|value| *value == &Item::default()) + .expect("Empty fixed select") + .0; + state.select(selected); + + Self { + state: SelectState { + state: RefCell::new(state), + items, + }, + } + } + + /// Get the index of the selected element + pub fn selected_index(&self) -> usize { + self.state.selected_index().unwrap() + } + + /// Get the selected element + pub fn selected(&self) -> &Item { + self.state.selected().unwrap() + } + + /// Is the given item selected? + pub fn is_selected(&self, item: &Item) -> bool { + self.selected() == item + } + + /// Select previous item + pub fn previous(&mut self) { + self.state.previous() + } + + /// Select next item + pub fn next(&mut self) { + self.state.next() + } + + /// Get a mutable reference to state. This uses `RefCell` underneath so it + /// will panic if aliased. Only call this during the draw phase! + pub fn state_mut(&self) -> impl DerefMut + '_ { + self.state.state_mut() + } +} + +impl Default for FixedSelectState +where + Item: FixedSelect, + State: SelectStateData, +{ + fn default() -> Self { + Self::new() + } +} + +/// Handle input events to cycle between items +impl EventHandler for FixedSelectState +where + Item: FixedSelect, + State: Debug + SelectStateData, +{ + fn update(&mut self, context: &mut UpdateContext, event: Event) -> Update { + self.state.update(context, event) + } +} + +/// Inner state for [SelectState]. This is an abstraction to allow [SelectState] +/// to support multiple state "backends" from Ratatui. This enables usage with +/// different stateful widgets. +pub trait SelectStateData: Default { + fn selected(&self) -> Option; + + fn select(&mut self, option: usize); +} + +impl SelectStateData for ListState { + fn selected(&self) -> Option { + self.selected() + } + + fn select(&mut self, option: usize) { + self.select(Some(option)) + } +} + +impl SelectStateData for TableState { + fn selected(&self) -> Option { + self.selected() + } + + fn select(&mut self, option: usize) { + self.select(Some(option)) + } +} + +impl SelectStateData for usize { + fn selected(&self) -> Option { + Some(*self) + } + + fn select(&mut self, option: usize) { + *self = option; + } +} + +/// Trait alias for a static list of items to be cycled through +pub trait FixedSelect: + Debug + Default + Display + IntoEnumIterator + PartialEq +{ +} + +impl FixedSelect + for T +{ +} diff --git a/src/tui/view/theme.rs b/src/tui/view/theme.rs index 8c16cccd..67fe9703 100644 --- a/src/tui/view/theme.rs +++ b/src/tui/view/theme.rs @@ -1,4 +1,8 @@ use ratatui::style::{Color, Modifier, Style}; +use std::sync::OnceLock; + +/// The theme is a singleton so we don't have to pass it everywhere +static THEME: OnceLock = OnceLock::new(); // Ideally these should be part of the theme, but that requires some sort of // two-stage themeing @@ -40,6 +44,13 @@ impl Theme { self.pane_border_style } } + + /// Get a reference to the global theme + pub fn get() -> &'static Self { + // Right now the theme isn't configurable so this is fine. To make it + // configurable we'll need to populate the static value during startup + THEME.get_or_init(Self::default) + } } impl Default for Theme { diff --git a/src/tui/view/util.rs b/src/tui/view/util.rs index c4e9481b..1930ec3a 100644 --- a/src/tui/view/util.rs +++ b/src/tui/view/util.rs @@ -1,221 +1,7 @@ //! Helper structs and functions for building components -use crate::{ - collection::{Profile, RequestRecipe}, - http::{RequestBuildError, RequestError}, - template::{Prompt, Prompter}, - tui::view::{ - state::{Notification, StatefulList}, - DrawContext, - }, -}; -use chrono::{DateTime, Duration, Local, Utc}; -use itertools::Itertools; -use ratatui::{ - prelude::*, - text::{Span, Text}, - widgets::{Block, Borders, List, ListItem}, -}; -use reqwest::header::HeaderValue; - -/// A helper for building a UI. It can be converted into some UI element to be -/// drawn. -pub trait ToTui { - type Output<'this> - where - Self: 'this; - - /// Build a UI element - fn to_tui<'a>(&'a self, context: &DrawContext) -> Self::Output<'a>; -} - -/// A container with a title and border -pub struct BlockBrick { - pub title: String, - pub is_focused: bool, -} - -impl ToTui for BlockBrick { - type Output<'this> = Block<'this> where Self: 'this; - - fn to_tui(&self, context: &DrawContext) -> Self::Output<'_> { - Block::default() - .borders(Borders::ALL) - .border_style(context.theme.pane_border_style(self.is_focused)) - .title(self.title.as_str()) - } -} - -/// A piece of text that looks interactable -pub struct ButtonBrick<'a> { - pub text: &'a str, - pub is_highlighted: bool, -} - -impl<'a> ToTui for ButtonBrick<'a> { - type Output<'this> = Text<'this> where Self: 'this; - - fn to_tui(&self, context: &DrawContext) -> Self::Output<'_> { - Text::styled(self.text, context.theme.list_highlight_style) - } -} - -/// A list with a border and title. Each item has to be convertible to text -pub struct ListBrick<'a, T: ToTui = Span<'a>>> { - pub block: BlockBrick, - pub list: &'a StatefulList, -} - -impl<'a, T: ToTui = Span<'a>>> ToTui for ListBrick<'a, T> { - type Output<'this> = List<'this> where Self: 'this; - - fn to_tui(&self, context: &DrawContext) -> Self::Output<'_> { - let block = self.block.to_tui(context); - - // Convert each list item into text - let items: Vec> = self - .list - .items - .iter() - .map(|i| ListItem::new(i.to_tui(context))) - .collect(); - - List::new(items) - .block(block) - .highlight_style(context.theme.list_highlight_style) - } -} - -/// Yes or no? -pub struct Checkbox { - pub checked: bool, -} - -impl ToTui for Checkbox { - type Output<'this> = Text<'this>; - - fn to_tui<'a>(&'a self, _context: &DrawContext) -> Self::Output<'a> { - if self.checked { - "[x]".into() - } else { - "[ ]".into() - } - } -} - -impl ToTui for String { - /// Use `Text` because a string can be multiple lines - type Output<'this> = Text<'this> where Self: 'this; - - fn to_tui(&self, _context: &DrawContext) -> Self::Output<'_> { - self.as_str().into() - } -} - -impl ToTui for Profile { - type Output<'this> = Span<'this> where Self: 'this; - - fn to_tui(&self, _context: &DrawContext) -> Self::Output<'_> { - self.name().to_owned().into() - } -} - -impl ToTui for RequestRecipe { - type Output<'this> = Span<'this> where Self: 'this; - - fn to_tui(&self, _context: &DrawContext) -> Self::Output<'_> { - format!("[{}] {}", self.method, self.name()).into() - } -} - -impl ToTui for Notification { - type Output<'this> = Span<'this> where Self: 'this; - - fn to_tui(&self, _context: &DrawContext) -> Self::Output<'_> { - format!( - "[{}] {}", - self.timestamp.with_timezone(&Local).format("%H:%M:%S"), - self.message - ) - .into() - } -} - -/// Format a timestamp in the local timezone -impl ToTui for DateTime { - type Output<'this> = Span<'this> where Self: 'this; - - fn to_tui(&self, _context: &DrawContext) -> Self::Output<'_> { - self.with_timezone(&Local) - .format("%b %e %H:%M:%S") - .to_string() - .into() - } -} - -impl ToTui for Duration { - /// 'static because string is generated - type Output<'this> = Span<'static>; - - fn to_tui(&self, _context: &DrawContext) -> Self::Output<'_> { - let ms = self.num_milliseconds(); - if ms < 1000 { - format!("{ms}ms").into() - } else { - format!("{:.2}s", ms as f64 / 1000.0).into() - } - } -} - -impl ToTui for Option { - type Output<'this> = Span<'this> where Self: 'this; - - fn to_tui(&self, context: &DrawContext) -> Self::Output<'_> { - match self { - Some(duration) => duration.to_tui(context), - // For incomplete requests typically - None => "???".into(), - } - } -} - -/// Not all header values are UTF-8; use a placeholder if not -impl ToTui for HeaderValue { - type Output<'this> = Span<'this> where Self: 'this; - - fn to_tui(&self, _context: &DrawContext) -> Self::Output<'_> { - match self.to_str() { - Ok(s) => s.into(), - Err(_) => "".into(), - } - } -} - -impl ToTui for anyhow::Error { - /// 'static because string is generated - type Output<'this> = Text<'static>; - - fn to_tui(&self, _context: &DrawContext) -> Self::Output<'_> { - self.chain().map(|err| err.to_string()).join("\n").into() - } -} - -impl ToTui for RequestBuildError { - type Output<'this> = Text<'static>; - - fn to_tui(&self, context: &DrawContext) -> Self::Output<'_> { - // Defer to the underlying anyhow error - self.error.to_tui(context) - } -} - -impl ToTui for RequestError { - type Output<'this> = Text<'static>; - - fn to_tui(&self, _context: &DrawContext) -> Self::Output<'_> { - self.error.to_string().into() - } -} +use crate::template::{Prompt, Prompter}; +use ratatui::layout::{Constraint, Direction, Layout, Rect}; /// A prompter that returns a static value; used for template previews, where /// user interaction isn't possible