From 49b7a6ea67d1faf28cabd61d2005dbf7d30110c1 Mon Sep 17 00:00:00 2001 From: Lucas Pickering Date: Sat, 4 Nov 2023 08:20:37 -0400 Subject: [PATCH 1/2] Size profile list dynamically --- src/tui/view/component/primary.rs | 9 ++++++++- src/tui/view/state.rs | 5 +++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/tui/view/component/primary.rs b/src/tui/view/component/primary.rs index 5ede0d89..271dc05d 100644 --- a/src/tui/view/component/primary.rs +++ b/src/tui/view/component/primary.rs @@ -176,7 +176,14 @@ impl<'a> Draw> for PrimaryView { let [profiles_chunk, recipes_chunk] = layout( left_chunk, Direction::Vertical, - [Constraint::Max(16), Constraint::Min(0)], + [ + // Make profile list as small as possible, with a max size + Constraint::Max( + self.profile_list_pane.profiles.len().clamp(1, 16) as u16 + + 2, // Account for top/bottom border + ), + Constraint::Min(0), + ], ); // Split right column vertically diff --git a/src/tui/view/state.rs b/src/tui/view/state.rs index 01439fd8..c135b9ce 100644 --- a/src/tui/view/state.rs +++ b/src/tui/view/state.rs @@ -156,6 +156,11 @@ impl StatefulList { 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 + '_ { From e3b89817970fbcc3066e94bb5f03fb519bbd8ac1 Mon Sep 17 00:00:00 2001 From: Lucas Pickering Date: Sat, 4 Nov 2023 08:28:31 -0400 Subject: [PATCH 2/2] Add Table component for query/header tables --- CHANGELOG.md | 6 ++++ slumber.yml | 2 ++ src/tui/view/component/mod.rs | 1 + src/tui/view/component/request.rs | 38 +++++++++++++------- src/tui/view/component/response.rs | 14 ++++++-- src/tui/view/component/table.rs | 51 +++++++++++++++++++++++++++ src/tui/view/component/tabs.rs | 2 +- src/tui/view/theme.rs | 56 ++++++++++++++++++++++-------- src/tui/view/util.rs | 54 ++++++++++------------------ 9 files changed, 158 insertions(+), 66 deletions(-) create mode 100644 src/tui/view/component/table.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 52b35317..5d58fc54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,12 @@ - Differentiate history between different collections [#10](https://github.com/LucasPickering/slumber/issues/10) - Ensure ctrl-c can't get eaten by text boxes (it guarantees exit now) [#18](https://github.com/LucasPickering/slumber/issues/18) +### Changed + +- Adjust size of profile list dynamically based on number of profiles +- Use structured table display format for query parameters and headers +- Tweak list and tab styling + ## [0.4.0] - 2023-11-02 ### Added diff --git a/slumber.yml b/slumber.yml index 8f7af3c9..6d2d61d7 100644 --- a/slumber.yml +++ b/slumber.yml @@ -35,6 +35,8 @@ requests: - id: login method: POST url: "{{host}}/anything/login" + query: + sudo: yes_please body: | { "username": "{{chains.username}}", diff --git a/src/tui/view/component/mod.rs b/src/tui/view/component/mod.rs index 524d0205..d7c1f44e 100644 --- a/src/tui/view/component/mod.rs +++ b/src/tui/view/component/mod.rs @@ -6,6 +6,7 @@ mod primary; mod request; mod response; mod root; +mod table; mod tabs; mod text_window; diff --git a/src/tui/view/component/request.rs b/src/tui/view/component/request.rs index 8a22b4c5..d79753b1 100644 --- a/src/tui/view/component/request.rs +++ b/src/tui/view/component/request.rs @@ -6,6 +6,7 @@ use crate::{ component::{ primary::PrimaryPane, root::FullscreenMode, + table::{Table, TableProps}, tabs::Tabs, text_window::{TextWindow, TextWindowProps}, Component, Draw, Event, Update, UpdateContext, @@ -110,22 +111,33 @@ impl<'a> Draw> for RequestPane { // Request content match self.tabs.selected() { Tab::Body => { - let text = recipe.body.as_deref().unwrap_or_default(); - self.text_window.draw( - context, - TextWindowProps { - key: &recipe.id, - text, - }, - content_chunk, - ); + if let Some(text) = recipe.body.as_deref() { + self.text_window.draw( + context, + TextWindowProps { + key: &recipe.id, + text, + }, + content_chunk, + ); + } } - Tab::Query => context.frame.render_widget( - Paragraph::new(recipe.query.to_tui(context)), + Tab::Query => Table.draw( + context, + TableProps { + key_label: "Parameter", + value_label: "Value", + data: &recipe.query, + }, content_chunk, ), - Tab::Headers => context.frame.render_widget( - Paragraph::new(recipe.headers.to_tui(context)), + Tab::Headers => Table.draw( + context, + TableProps { + key_label: "Header", + value_label: "Value", + data: &recipe.headers, + }, content_chunk, ), } diff --git a/src/tui/view/component/response.rs b/src/tui/view/component/response.rs index faffcaaa..292e448d 100644 --- a/src/tui/view/component/response.rs +++ b/src/tui/view/component/response.rs @@ -6,12 +6,13 @@ use crate::{ component::{ primary::PrimaryPane, root::FullscreenMode, + table::{Table, TableProps}, tabs::Tabs, text_window::{TextWindow, TextWindowProps}, Component, Draw, Event, Update, UpdateContext, }, state::{FixedSelect, RequestState}, - util::{layout, BlockBrick, ToTui}, + util::{layout, BlockBrick, HeaderValueDisplay, ToTui}, DrawContext, }, }, @@ -175,8 +176,15 @@ impl<'a> Draw> for ResponsePane { }, content_chunk, ), - Tab::Headers => context.frame.render_widget( - Paragraph::new(response.headers.to_tui(context)), + Tab::Headers => Table.draw( + context, + TableProps { + key_label: "Header", + value_label: "Value", + data: response.headers.iter().map(|(k, v)| { + (k, HeaderValueDisplay::from(v)) + }), + }, content_chunk, ), } diff --git a/src/tui/view/component/table.rs b/src/tui/view/component/table.rs new file mode 100644 index 00000000..c582eae3 --- /dev/null +++ b/src/tui/view/component/table.rs @@ -0,0 +1,51 @@ +use crate::tui::view::component::{Draw, DrawContext}; +use derive_more::Display; +use ratatui::{ + prelude::{Constraint, Rect}, + widgets::Row, +}; +use std::fmt::Display; + +/// 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, +} + +/// Any (key, value) iterator can be drawn as a table, as long as the key and +/// value implement `Display` +impl<'a, K, V, Iter, Data> Draw> for Table +where + K: Display, + V: Display, + Iter: Iterator, + Data: IntoIterator, +{ + 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.to_string(), v.to_string()]).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/component/tabs.rs b/src/tui/view/component/tabs.rs index 262be5b1..e7867f02 100644 --- a/src/tui/view/component/tabs.rs +++ b/src/tui/view/component/tabs.rs @@ -54,7 +54,7 @@ impl Draw for Tabs { T::iter().map(|e| e.to_string()).collect(), ) .select(self.tabs.selected_index()) - .highlight_style(context.theme.text_highlight_style), + .highlight_style(context.theme.tab_highlight_style), chunk, ) } diff --git a/src/tui/view/theme.rs b/src/tui/view/theme.rs index dd84d0df..a8a91b5f 100644 --- a/src/tui/view/theme.rs +++ b/src/tui/view/theme.rs @@ -1,20 +1,36 @@ use ratatui::style::{Color, Modifier, Style}; +// Ideally this should be part of the theme, but that requires some sort of +// two-stage themeing +pub const PRIMARY_COLOR: Color = Color::LightGreen; + /// Configurable visual settings for the UI #[derive(Debug)] pub struct Theme { - pub pane_border_style: Style, - pub pane_border_focus_style: Style, - pub text_highlight_style: Style, - /// Style for line numbers on large text areas + /// Line numbers on large text areas pub line_number_style: Style, - pub list_highlight_symbol: &'static str, + + /// Highlighted item in a list + pub list_highlight_style: Style, + + /// Pane border when not selected/focused + pub pane_border_style: Style, + /// Pane border when selected/focused + pub pane_border_selected_style: Style, + + /// Highlighted tab in a tab group + pub tab_highlight_style: Style, + + /// Table column header text + pub table_header_style: Style, + pub table_text_style: Style, + pub table_alt_text_style: Style, } impl Theme { pub fn pane_border_style(&self, is_focused: bool) -> Style { if is_focused { - self.pane_border_focus_style + self.pane_border_selected_style } else { self.pane_border_style } @@ -24,16 +40,28 @@ impl Theme { impl Default for Theme { fn default() -> Self { Self { - pane_border_style: Style::default(), - pane_border_focus_style: Style::default() - .fg(Color::LightGreen) - .add_modifier(Modifier::BOLD), - text_highlight_style: Style::default() - .bg(Color::LightGreen) + line_number_style: Style::default(), + + list_highlight_style: Style::default() + .bg(PRIMARY_COLOR) .fg(Color::Black) .add_modifier(Modifier::BOLD), - line_number_style: Style::default(), - list_highlight_symbol: ">> ", + + pane_border_style: Style::default(), + pane_border_selected_style: Style::default() + .fg(PRIMARY_COLOR) + .add_modifier(Modifier::BOLD), + + tab_highlight_style: Style::default() + .fg(PRIMARY_COLOR) + .add_modifier(Modifier::BOLD) + .add_modifier(Modifier::UNDERLINED), + + table_header_style: Style::default() + .add_modifier(Modifier::BOLD) + .add_modifier(Modifier::UNDERLINED), + table_text_style: Style::default(), + table_alt_text_style: Style::default().bg(Color::DarkGray), } } } diff --git a/src/tui/view/util.rs b/src/tui/view/util.rs index 829c06fd..1df077c6 100644 --- a/src/tui/view/util.rs +++ b/src/tui/view/util.rs @@ -9,14 +9,14 @@ use crate::{ }, }; use chrono::{DateTime, Duration, Local, Utc}; -use indexmap::IndexMap; +use derive_more::From; use ratatui::{ prelude::*, text::{Line, Span, Text}, widgets::{Block, Borders, List, ListItem}, }; -use reqwest::header::HeaderMap; -use std::fmt::Display; +use reqwest::header::HeaderValue; +use std::fmt::{Display, Formatter}; /// A helper for building a UI. It can be converted into some UI element to be /// drawn. @@ -56,7 +56,7 @@ 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.text_highlight_style) + Text::styled(self.text, context.theme.list_highlight_style) } } @@ -82,8 +82,7 @@ impl<'a, T: ToTui = Span<'a>>> ToTui for ListBrick<'a, T> { List::new(items) .block(block) - .highlight_style(context.theme.text_highlight_style) - .highlight_symbol(context.theme.list_highlight_symbol) + .highlight_style(context.theme.list_highlight_style) } } @@ -154,35 +153,6 @@ impl ToTui for Option { } } -impl ToTui for IndexMap { - type Output<'this> = Text<'this> where Self: 'this; - - fn to_tui(&self, _context: &DrawContext) -> Self::Output<'_> { - self.iter() - .map(|(key, value)| format!("{key} = {value}").into()) - .collect::>() - .into() - } -} - -impl ToTui for HeaderMap { - /// 'static because string is generated - type Output<'this> = Text<'static>; - - fn to_tui(&self, _context: &DrawContext) -> Self::Output<'_> { - self.iter() - .map(|(key, value)| { - format!( - "{key} = {}", - value.to_str().unwrap_or("") - ) - .into() - }) - .collect::>() - .into() - } -} - impl ToTui for anyhow::Error { /// 'static because string is generated type Output<'this> = Text<'static>; @@ -216,6 +186,20 @@ impl ToTui for RequestError { } } +/// Wrapper to implement `Display` for an HTTP header value. Uses a placeholder +/// value for non-UTF-8 values +#[derive(Debug, From)] +pub struct HeaderValueDisplay<'a>(&'a HeaderValue); + +impl<'a> Display for HeaderValueDisplay<'a> { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self.0.to_str() { + Ok(s) => write!(f, "{}", s), + Err(_) => write!(f, ""), + } + } +} + /// Helper for building a layout with a fixed number of constraints pub fn layout( area: Rect,