From 145a69392f1a8e53adf802ffb044d5d69a4d13f7 Mon Sep 17 00:00:00 2001 From: Lucas Pickering Date: Fri, 29 Dec 2023 19:57:52 +0100 Subject: [PATCH] Add Copy URL action for recipe This involved refactoring the action menu a lot so it's no longer provided by the text window. --- CHANGELOG.md | 1 + Cargo.lock | 1 + Cargo.toml | 65 +++++++------ src/tui/view/common.rs | 1 + src/tui/view/common/actions.rs | 76 +++++++++++++++ src/tui/view/common/template_preview.rs | 102 +++++++++++--------- src/tui/view/common/text_window.rs | 123 ++---------------------- src/tui/view/component/recipe.rs | 109 ++++++++++++++++++--- src/tui/view/component/response.rs | 53 ++++++++-- src/tui/view/draw.rs | 23 ++++- src/tui/view/event.rs | 36 ++++++- src/tui/view/state/select.rs | 21 +++- 12 files changed, 388 insertions(+), 223 deletions(-) create mode 100644 src/tui/view/common/actions.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index f907d92c..ebc5a2a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - Right now the only supported field is `preview_templates` - Toggle query parameters and headers in recipe pane ([#30](https://github.com/LucasPickering/slumber/issues/30)) - You can easily enable/disable parameters and headers without having to modify the collection file now +- Add Copy URL action, to get the full URL that a request will generate ([#93](https://github.com/LucasPickering/slumber/issues/93)) ### Changed diff --git a/Cargo.lock b/Cargo.lock index 1782a76d..18f7607b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1774,6 +1774,7 @@ dependencies = [ "tracing", "tracing-subscriber", "tui-textarea", + "url", "uuid", ] diff --git a/Cargo.toml b/Cargo.toml index eefcdb33..dbd8c5eb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,38 +11,39 @@ version = "0.11.0" rust-version = "1.74.0" [dependencies] -anyhow = {version = "^1.0.75", features = ["backtrace"]} -async-trait = "^0.1.73" -chrono = {version = "^0.4.31", default-features = false, features = ["clock", "serde", "std"]} -clap = {version = "^4.4.2", features = ["derive"]} -cli-clipboard = "0.4.0" -crossterm = "^0.27.0" -derive_more = {version = "1.0.0-beta.6", features = ["debug", "deref", "deref_mut", "display", "from"]} -dialoguer = {version = "^0.11.0", default-features = false, features = ["password"]} -dirs = "^5.0.1" -equivalent = "^1" -futures = "^0.3.28" -indexmap = {version = "^2.0.1", features = ["serde"]} -itertools = "^0.12.0" -nom = "7.1.3" -notify = {version = "^6.1.1", default-features = false, features = ["macos_fsevent"]} -ratatui = "^0.25.0" -reqwest = {version = "^0.11.20", default-features = false, features = ["rustls-tls"]} -rmp-serde = "^1.1.2" -rusqlite = {version = "^0.30.0", default-features = false, features = ["bundled", "chrono", "uuid"]} -rusqlite_migration = "^1.1.0" -serde = {version = "^1.0.188", features = ["derive"]} -serde_json = {version = "^1.0.107", default-features = false} -serde_json_path = "^0.6.3" -serde_yaml = {version = "^0.9.25", default-features = false} -signal-hook = "^0.3.17" -strum = {version = "^0.25.0", default-features = false, features = ["derive"]} -thiserror = "^1.0.48" -tokio = {version = "^1.32.0", default-features = false, features = ["full"]} -tracing = "^0.1.37" -tracing-subscriber = {version = "^0.3.17", default-features = false, features = ["env-filter", "fmt", "registry"]} -tui-textarea = "^0.4.0" -uuid = {version = "^1.4.1", default-features = false, features = ["serde", "v4"]} +anyhow = {version = "^1.0.75", features = ["backtrace"]} +async-trait = "^0.1.73" +chrono = {version = "^0.4.31", default-features = false, features = ["clock", "serde", "std"]} +clap = {version = "^4.4.2", features = ["derive"]} +cli-clipboard = "0.4.0" +crossterm = "^0.27.0" +derive_more = {version = "1.0.0-beta.6", features = ["debug", "deref", "deref_mut", "display", "from"]} +dialoguer = {version = "^0.11.0", default-features = false, features = ["password"]} +dirs = "^5.0.1" +equivalent = "^1" +futures = "^0.3.28" +indexmap = {version = "^2.0.1", features = ["serde"]} +itertools = "^0.12.0" +nom = "7.1.3" +notify = {version = "^6.1.1", default-features = false, features = ["macos_fsevent"]} +ratatui = "^0.25.0" +reqwest = {version = "^0.11.20", default-features = false, features = ["rustls-tls"]} +rmp-serde = "^1.1.2" +rusqlite = {version = "^0.30.0", default-features = false, features = ["bundled", "chrono", "uuid"]} +rusqlite_migration = "^1.1.0" +serde = {version = "^1.0.188", features = ["derive"]} +serde_json = {version = "^1.0.107", default-features = false} +serde_json_path = "^0.6.3" +serde_yaml = {version = "^0.9.25", default-features = false} +signal-hook = "^0.3.17" +strum = {version = "^0.25.0", default-features = false, features = ["derive"]} +thiserror = "^1.0.48" +tokio = {version = "^1.32.0", default-features = false, features = ["full"]} +tracing = "^0.1.37" +tracing-subscriber = {version = "^0.3.17", default-features = false, features = ["env-filter", "fmt", "registry"]} +tui-textarea = "^0.4.0" +url = "*" # Use the version from reqwest +uuid = {version = "^1.4.1", default-features = false, features = ["serde", "v4"]} [dev-dependencies] factori = "1.1.0" diff --git a/src/tui/view/common.rs b/src/tui/view/common.rs index dc17c57d..87122a84 100644 --- a/src/tui/view/common.rs +++ b/src/tui/view/common.rs @@ -1,6 +1,7 @@ //! Common reusable components for building the view. Children here should be //! generic, i.e. usable in more than a single narrow context. +pub mod actions; pub mod list; pub mod modal; pub mod table; diff --git a/src/tui/view/common/actions.rs b/src/tui/view/common/actions.rs new file mode 100644 index 00000000..9d526be0 --- /dev/null +++ b/src/tui/view/common/actions.rs @@ -0,0 +1,76 @@ +use crate::tui::view::{ + common::{list::List, modal::Modal}, + component::Component, + draw::{Draw, Generate}, + event::{Event, EventHandler, UpdateContext}, + state::select::{Fixed, FixedSelect, SelectState}, +}; +use ratatui::{ + layout::{Constraint, Rect}, + text::Span, + widgets::ListState, + Frame, +}; + +/// Modal to list and trigger arbitrary actions. The list of available actions +/// is defined by the generic parameter +#[derive(Debug)] +pub struct ActionsModal { + actions: Component>, +} + +impl Default for ActionsModal { + fn default() -> Self { + let wrapper = move |context: &mut UpdateContext, action: &mut T| { + // Close the modal *first*, so the parent can handle the callback + // event. Jank but it works + context.queue_event(Event::CloseModal); + context.queue_event(Event::other(*action)); + }; + + Self { + actions: SelectState::fixed().on_submit(wrapper).into(), + } + } +} + +impl Modal for ActionsModal +where + T: FixedSelect, + ActionsModal: Draw, +{ + fn title(&self) -> &str { + "Actions" + } + + fn dimensions(&self) -> (Constraint, Constraint) { + ( + Constraint::Length(30), + Constraint::Length(T::iter().count() as u16), + ) + } +} + +impl EventHandler for ActionsModal { + fn children(&mut self) -> Vec> { + vec![self.actions.as_child()] + } +} + +impl Draw for ActionsModal +where + T: 'static + FixedSelect, + for<'a> &'a T: Generate = Span<'a>>, +{ + fn draw(&self, frame: &mut Frame, _: (), area: Rect) { + let list = List { + block: None, + list: &self.actions, + }; + frame.render_stateful_widget( + list.generate(), + area, + &mut self.actions.state_mut(), + ); + } +} diff --git a/src/tui/view/common/template_preview.rs b/src/tui/view/common/template_preview.rs index fdb62ccb..513399b0 100644 --- a/src/tui/view/common/template_preview.rs +++ b/src/tui/view/common/template_preview.rs @@ -12,7 +12,6 @@ use ratatui::{ widgets::{Paragraph, Widget}, }; use std::{ - fmt::{self, Display, Formatter}, mem, sync::{Arc, OnceLock}, }; @@ -58,6 +57,39 @@ impl TemplatePreview { Self::Disabled { template } } } + + /// Convert rendered template to plain text for the purposes of copypasta. + /// If the render isn't ready, return the raw template. If any chunk failed, + /// it will be left empty. + pub fn to_copy_text(&self) -> String { + // If the preview render is ready, show it. Otherwise fall back to raw + match self { + TemplatePreview::Disabled { template } => template.to_string(), + TemplatePreview::Enabled { template, chunks } => match chunks.get() + { + // The goal here is to minimize "wonky" output, so loading and + // errors are replaced with minimal placeholders + Some(chunks) => { + let mut s = String::new(); + for chunk in chunks { + let content = match chunk { + TemplateChunk::Raw(span) => { + template.substring(*span) + } + TemplateChunk::Rendered { value, .. } => { + value.as_str() + } + TemplateChunk::Error(_) => "", + }; + s.push_str(content); + } + s + } + // Preview still rendering + None => template.to_string(), + }, + } + } } impl Generate for &TemplatePreview { @@ -90,28 +122,6 @@ impl Widget for &TemplatePreview { } } -/// Convert to raw text. Useful for copypasta -impl Display for TemplatePreview { - fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { - match self { - TemplatePreview::Disabled { template } => write!(f, "{template}"), - // If the preview render is ready, show it. Otherwise fall back - // to the raw - TemplatePreview::Enabled { template, chunks } => match chunks.get() - { - Some(chunks) => { - for chunk in chunks { - write!(f, "{}", get_chunk_text(template, chunk))?; - } - Ok(()) - } - // Preview still rendering - None => write!(f, "{template}"), - }, - } - } -} - /// A helper for stitching rendered template chunks into ratatui `Text`. This /// requires some effort because ratatui *loves* line breaks, so we have to /// very manually construct the text to make sure the structure reflects the @@ -139,7 +149,7 @@ impl<'a> TextStitcher<'a> { // manually split the lines let mut stitcher = Self::default(); for chunk in chunks { - let chunk_text = get_chunk_text(template, chunk); + let chunk_text = Self::get_chunk_text(template, chunk); let style = match &chunk { TemplateChunk::Raw(_) => Style::default(), TemplateChunk::Rendered { .. } => theme.template_preview_text, @@ -168,6 +178,28 @@ impl<'a> TextStitcher<'a> { } } + /// Get the renderable text for a chunk of a template + fn get_chunk_text( + template: &'a Template, + chunk: &'a TemplateChunk, + ) -> &'a str { + match chunk { + TemplateChunk::Raw(span) => template.substring(*span), + TemplateChunk::Rendered { value, sensitive } => { + if *sensitive { + // Hide sensitive values. Ratatui has a Masked type, but + // it complicates the string ownership a lot and also + // exposes the length of the sensitive text + "" + } else { + value.as_str() + } + } + // There's no good way to render the entire error inline + TemplateChunk::Error(_) => "Error", + } + } + fn add_span(&mut self, text: &'a str, style: Style) { if !text.is_empty() { self.next_line.push(Span::styled(text, style)); @@ -189,28 +221,6 @@ impl<'a> TextStitcher<'a> { } } -/// Get the plain text for a chunk of a template -fn get_chunk_text<'a>( - template: &'a Template, - chunk: &'a TemplateChunk, -) -> &'a str { - match chunk { - TemplateChunk::Raw(span) => template.substring(*span), - TemplateChunk::Rendered { value, sensitive } => { - if *sensitive { - // Hide sensitive values. Ratatui has a Masked type, but - // it complicates the string ownership a lot and also - // exposes the length of the sensitive text - "" - } else { - value.as_str() - } - } - // There's no good way to render the entire error inline - TemplateChunk::Error(_) => "Error", - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/src/tui/view/common/text_window.rs b/src/tui/view/common/text_window.rs index 11582183..ff3d00ec 100644 --- a/src/tui/view/common/text_window.rs +++ b/src/tui/view/common/text_window.rs @@ -1,26 +1,19 @@ use crate::tui::{ context::TuiContext, input::Action, - message::Message, view::{ - common::{list::List, modal::Modal}, draw::{Draw, Generate}, event::{Event, EventHandler, Update, UpdateContext}, - state::select::{Fixed, SelectState}, util::layout, - Component, ModalPriority, }, }; -use anyhow::anyhow; -use derive_more::Display; use ratatui::{ prelude::{Alignment, Constraint, Direction, Rect}, - text::{Line, Span, Text}, - widgets::{ListState, Paragraph}, + text::{Line, Text}, + widgets::Paragraph, Frame, }; use std::{cell::Cell, cmp, fmt::Debug}; -use strum::{EnumCount, EnumIter}; /// A scrollable (but not editable) block of text. Text is not externally /// mutable. If you need to update the text, store this in a `StateCell` and @@ -47,6 +40,10 @@ impl TextWindow { } } + pub fn text(&self) -> &T { + &self.text + } + /// Get the final line that we can't scroll past. This will be the first /// line of the last page of text fn max_scroll_line(&self) -> u16 { @@ -68,30 +65,10 @@ impl TextWindow { fn scroll_to(&mut self, line: u16) { self.offset_y = cmp::min(line, self.max_scroll_line()); } - - /// Copy all text in the window to the clipboard - fn copy_text(&self, context: &mut UpdateContext) - where - T: ToString, - { - match cli_clipboard::set_contents(self.text.to_string()) { - Ok(()) => { - context.notify("Copied text to clipboard"); - } - Err(error) => { - // Returned error doesn't impl 'static so we can't - // directly convert it to anyhow - TuiContext::send_message(Message::Error { - error: anyhow!("Error copying text: {error}"), - }) - } - } - } } -/// ToString required for copy action -impl EventHandler for TextWindow { - fn update(&mut self, context: &mut 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), @@ -103,13 +80,8 @@ impl EventHandler for TextWindow { Action::PageDown => self.scroll_down(self.window_height.get()), Action::Home => self.scroll_to(0), Action::End => self.scroll_to(u16::MAX), - Action::OpenActions => context.open_modal( - TextWindowActionsModal::default(), - ModalPriority::Low, - ), _ => return Update::Propagate(event), }, - Event::CopyText => self.copy_text(context), _ => return Update::Propagate(event), } Update::Consumed @@ -161,82 +133,3 @@ where ); } } - -/// Modal to trigger useful commands -#[derive(Debug)] -struct TextWindowActionsModal { - actions: Component>, -} - -impl Default for TextWindowActionsModal { - fn default() -> Self { - fn on_submit( - context: &mut UpdateContext, - action: &mut TextWindowAction, - ) { - // Close the modal *first*, so the action event gets handled by our - // parent rather than the modal. Jank but it works - context.queue_event(Event::CloseModal); - match action { - TextWindowAction::Copy => context.queue_event(Event::CopyText), - } - } - - Self { - actions: SelectState::fixed().on_submit(on_submit).into(), - } - } -} - -impl Modal for TextWindowActionsModal { - fn title(&self) -> &str { - "Actions" - } - - fn dimensions(&self) -> (Constraint, Constraint) { - ( - Constraint::Length(30), - Constraint::Length(TextWindowAction::COUNT as u16), - ) - } -} - -impl EventHandler for TextWindowActionsModal { - fn children(&mut self) -> Vec> { - vec![self.actions.as_child()] - } -} - -impl Draw for TextWindowActionsModal { - fn draw(&self, frame: &mut Frame, _: (), area: Rect) { - let list = List { - block: None, - list: &self.actions, - }; - frame.render_stateful_widget( - list.generate(), - area, - &mut self.actions.state_mut(), - ); - } -} - -/// Items in the actions popup menu -#[derive( - Copy, Clone, Debug, Default, Display, EnumCount, EnumIter, PartialEq, -)] -enum TextWindowAction { - #[default] - Copy, -} - -impl Generate for &TextWindowAction { - type Output<'this> = Span<'this> where Self: 'this; - - fn generate<'this>(self) -> Self::Output<'this> - where - Self: 'this, - { - self.to_string().into() - } -} diff --git a/src/tui/view/component/recipe.rs b/src/tui/view/component/recipe.rs index 06d52058..7701af56 100644 --- a/src/tui/view/component/recipe.rs +++ b/src/tui/view/component/recipe.rs @@ -2,23 +2,29 @@ use crate::{ collection::{ProfileId, Recipe, RecipeId}, http::RecipeOptions, template::Template, - tui::view::{ - common::{ - table::Table, tabs::Tabs, template_preview::TemplatePreview, - text_window::TextWindow, Checkbox, Pane, + tui::{ + input::Action, + view::{ + common::{ + actions::ActionsModal, table::Table, tabs::Tabs, + template_preview::TemplatePreview, text_window::TextWindow, + Checkbox, Pane, + }, + component::primary::PrimaryPane, + draw::{Draw, Generate, ToStringGenerate}, + event::{Event, EventHandler, Update, UpdateContext}, + state::{ + persistence::{Persistable, Persistent, PersistentKey}, + select::{Dynamic, SelectState}, + StateCell, + }, + util::layout, + Component, }, - component::primary::PrimaryPane, - draw::{Draw, Generate}, - event::{EventHandler, UpdateContext}, - state::{ - persistence::{Persistable, Persistent, PersistentKey}, - select::{Dynamic, SelectState}, - StateCell, - }, - util::layout, - Component, }, + util::ResultExt, }; +use anyhow::Context; use derive_more::Display; use itertools::Itertools; use ratatui::{ @@ -30,6 +36,7 @@ use ratatui::{ use serde::{Deserialize, Serialize}; use std::collections::HashSet; use strum::EnumIter; +use url::Url; /// Display a request recipe #[derive(Debug)] @@ -96,6 +103,16 @@ struct RowState { enabled: Persistent, } +/// Items in the actions popup menu +#[derive(Copy, Clone, Debug, Default, Display, EnumIter, PartialEq)] +enum MenuAction { + #[default] + #[display("Copy URL")] + CopyUrl, + #[display("Copy Body")] + CopyBody, +} + impl RecipePane { /// Generate a [RecipeOptions] instance based on current UI state pub fn recipe_options(&self) -> RecipeOptions { @@ -122,9 +139,71 @@ impl RecipePane { RecipeOptions::default() } } + + /// Get the full URL (including query params) of the request, for copypasta + fn url(&self) -> Option { + self.recipe_state.get().and_then(|state| { + // Parse the base URL + let mut url = Url::parse(&state.url.to_copy_text()) + .context("Error parsing URL") + .traced() + .ok()?; + + // Add additional query params. We need to make sure to append, in + // case the base URL includes some query params already + { + let mut query_pairs = url.query_pairs_mut(); + for row in state.query.items() { + // Don't include disabled params + if *row.enabled { + query_pairs + .append_pair(&row.key, &row.value.to_copy_text()); + } + } + } + + Some(url.to_string()) + }) + } + + /// Get the full *prettified* body of the request, for copypasta + fn body(&self) -> Option { + self.recipe_state.get().and_then(|state| { + state + .body + .as_ref() + .map(|body| body.inner().text().to_copy_text()) + }) + } } impl EventHandler for RecipePane { + fn update(&mut self, context: &mut UpdateContext, event: Event) -> Update { + match &event { + Event::Input { + action: Some(Action::OpenActions), + .. + } => context.open_modal_default::>(), + Event::Other(callback) => { + match callback.downcast_ref::() { + Some(MenuAction::CopyUrl) => { + if let Some(url) = self.url() { + context.copy_text(url); + } + } + Some(MenuAction::CopyBody) => { + if let Some(body) = self.body() { + context.copy_text(body); + } + } + None => return Update::Propagate(event), + } + } + _ => return Update::Propagate(event), + } + Update::Consumed + } + fn children(&mut self) -> Vec> { let selected_tab = *self.tabs.selected(); let mut children = vec![self.tabs.as_child()]; @@ -357,3 +436,5 @@ impl PartialEq for String { self == &other.key } } + +impl ToStringGenerate for MenuAction {} diff --git a/src/tui/view/component/response.rs b/src/tui/view/component/response.rs index 94b05054..cbc4f0eb 100644 --- a/src/tui/view/component/response.rs +++ b/src/tui/view/component/response.rs @@ -1,13 +1,19 @@ use crate::{ http::{RequestId, RequestRecord}, - tui::view::{ - common::{table::Table, tabs::Tabs, text_window::TextWindow, Pane}, - component::primary::PrimaryPane, - draw::{Draw, Generate}, - event::EventHandler, - state::{persistence::PersistentKey, RequestState, StateCell}, - util::layout, - Component, + tui::{ + input::Action, + view::{ + common::{ + actions::ActionsModal, table::Table, tabs::Tabs, + text_window::TextWindow, Pane, + }, + component::primary::PrimaryPane, + draw::{Draw, Generate, ToStringGenerate}, + event::{Event, EventHandler, Update, UpdateContext}, + state::{persistence::PersistentKey, RequestState, StateCell}, + util::layout, + Component, + }, }, }; use derive_more::Display; @@ -34,6 +40,14 @@ pub struct ResponsePaneProps<'a> { pub active_request: Option<&'a RequestState>, } +/// Items in the actions popup menu +#[derive(Copy, Clone, Debug, Default, Display, EnumIter, PartialEq)] +enum MenuAction { + #[default] + #[display("Copy Body")] + CopyBody, +} + impl EventHandler for ResponsePane { fn children(&mut self) -> Vec> { vec![self.content.as_child()] @@ -184,6 +198,27 @@ enum Tab { } impl EventHandler for ResponseContent { + fn update(&mut self, context: &mut UpdateContext, event: Event) -> Update { + match &event { + Event::Input { + action: Some(Action::OpenActions), + .. + } => context.open_modal_default::>(), + Event::Other(callback) => { + match callback.downcast_ref::() { + Some(MenuAction::CopyBody) => { + if let Some(body) = self.body.get() { + context.copy_text(body.inner().text().to_owned()) + } + } + None => return Update::Propagate(event), + } + } + _ => return Update::Propagate(event), + } + Update::Consumed + } + fn children(&mut self) -> Vec> { let selected_tab = *self.tabs.selected(); let mut children = vec![self.tabs.as_child()]; @@ -251,3 +286,5 @@ impl<'a> Draw> for ResponseContent { } } } + +impl ToStringGenerate for MenuAction {} diff --git a/src/tui/view/draw.rs b/src/tui/view/draw.rs index e7906ea8..221a6ade 100644 --- a/src/tui/view/draw.rs +++ b/src/tui/view/draw.rs @@ -1,6 +1,7 @@ //! Traits for rendering stuff -use ratatui::{layout::Rect, Frame}; +use ratatui::{layout::Rect, text::Span, Frame}; +use std::fmt::Display; /// Something that can be drawn onto screen as one or more TUI widgets. /// @@ -31,3 +32,23 @@ pub trait Generate { where Self: 'this; } + +/// Marker trait th pull in a blanket impl of [Generate], which simply calls +/// [ToString::to_string] on the value to create a [ratatui::text::Span]. +pub trait ToStringGenerate: Display {} + +impl Generate for &T +where + T: ToStringGenerate, +{ + type Output<'this> = Span<'this> + where + Self: 'this; + + fn generate<'this>(self) -> Self::Output<'this> + where + Self: 'this, + { + self.to_string().into() + } +} diff --git a/src/tui/view/event.rs b/src/tui/view/event.rs index f8432175..f94dac97 100644 --- a/src/tui/view/event.rs +++ b/src/tui/view/event.rs @@ -4,7 +4,9 @@ use crate::{ collection::{ProfileId, RecipeId}, tui::{ + context::TuiContext, input::Action, + message::Message, view::{ common::modal::{Modal, ModalPriority}, state::{Notification, RequestState}, @@ -12,8 +14,9 @@ use crate::{ }, }, }; +use anyhow::anyhow; use crossterm::event::{MouseEvent, MouseEventKind}; -use std::{collections::VecDeque, fmt::Debug}; +use std::{any::Any, collections::VecDeque, fmt::Debug}; use tracing::trace; /// A UI element that can handle user/async input. This trait facilitates an @@ -75,6 +78,22 @@ impl<'a> UpdateContext<'a> { pub fn notify(&mut self, message: impl ToString) { self.queue_event(Event::Notify(Notification::new(message.to_string()))); } + + /// Copy text to the user's clipboard, and notify them + pub fn copy_text(&mut self, text: String) { + match cli_clipboard::set_contents(text) { + Ok(()) => { + self.notify("Copied text to clipboard"); + } + Err(error) => { + // Returned error doesn't impl 'static so we can't + // directly convert it to anyhow + TuiContext::send_message(Message::Error { + error: anyhow!("Error copying text: {error}"), + }) + } + } + } } /// A trigger for state change in the view. Events are handled by @@ -126,9 +145,18 @@ pub enum Event { /// Tell the user something informational Notify(Notification), - // Niche component-specific actions (yuck) - /// Copy current text. Which text to copy is contextual - CopyText, + /// A dynamically dispatched variant, which can hold any type. This is + /// useful for passing component-specific action types, e.g. when bubbling + /// up a callback. Use [Any::downcast_ref] to convert into the expected + /// type. + Other(Box), +} + +impl Event { + /// Helper for creating a dynamic "other" variant + pub fn other(value: T) -> Event { + Event::Other(Box::new(value)) + } } impl Event { diff --git a/src/tui/view/state/select.rs b/src/tui/view/state/select.rs index ef6fbf48..907076ca 100644 --- a/src/tui/view/state/select.rs +++ b/src/tui/view/state/select.rs @@ -377,11 +377,26 @@ impl SelectStateData for usize { /// Trait alias for a static list of items to be cycled through pub trait FixedSelect: - Debug + Default + Display + IntoEnumIterator + PartialEq + 'static + + Copy + + Clone + + Debug + + Default + + Display + + IntoEnumIterator + + PartialEq { } -impl FixedSelect - for T +/// Auto-impl for anything we can +impl FixedSelect for T where + T: 'static + + Copy + + Clone + + Debug + + Default + + Display + + IntoEnumIterator + + PartialEq { }