From 01c28465f6d330e68a4a36edfdfd6939c2ea9552 Mon Sep 17 00:00:00 2001 From: Peter Simonsson Date: Tue, 3 Sep 2024 20:47:11 +0200 Subject: [PATCH] Clean up picker rendering (#122) Make use of the serde support in ratatui to parse colors. Have the default colors in one place instead of spread out all over the place. Move out selection update from the render function. Move out the matcher update from the render function. Simplify the selection if statement in render_preview function. --- Cargo.lock | 5 +++ Cargo.toml | 2 +- src/cli.rs | 21 ++++----- src/configs.rs | 85 ++++++++++++++++++----------------- src/keymap.rs | 1 - src/picker.rs | 120 +++++++++++++++++++++---------------------------- tests/cli.rs | 33 +++++++------- 7 files changed, 128 insertions(+), 139 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bd642dc..f62edeb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -111,6 +111,9 @@ name = "bitflags" version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" +dependencies = [ + "serde", +] [[package]] name = "bstr" @@ -221,6 +224,7 @@ dependencies = [ "itoa", "rustversion", "ryu", + "serde", "static_assertions", ] @@ -813,6 +817,7 @@ dependencies = [ "itertools", "lru", "paste", + "serde", "strum", "strum_macros", "unicode-segmentation", diff --git a/Cargo.toml b/Cargo.toml index 14c077d..8daedf8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,7 +29,7 @@ config = { version = "0.14", default-features = false, features = ["toml"] } toml = "0.8" dirs = "5.0.1" nucleo = "0.5.0" -ratatui = "0.28" +ratatui = { version = "0.28", features = ["serde"] } crossterm = "0.28" clap_complete = "4.5" diff --git a/src/cli.rs b/src/cli.rs index 7129123..f99302d 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -18,6 +18,7 @@ use clap::{Args, Command, CommandFactory, Parser, Subcommand}; use clap_complete::{generate, Generator, Shell}; use error_stack::ResultExt; use git2::{build::RepoBuilder, FetchOptions, RemoteCallbacks, Repository}; +use ratatui::style::Color; #[derive(Debug, Parser)] #[command(author, version)] @@ -89,19 +90,19 @@ pub struct ConfigCommand { max_depths: Option>, #[arg(long, value_name = "#rrggbb")] /// Background color of the highlighted item in the picker - picker_highlight_color: Option, + picker_highlight_color: Option, #[arg(long, value_name = "#rrggbb")] /// Text color of the hightlighted item in the picker - picker_highlight_text_color: Option, + picker_highlight_text_color: Option, #[arg(long, value_name = "#rrggbb")] /// Color of the borders between widgets in the picker - picker_border_color: Option, + picker_border_color: Option, #[arg(long, value_name = "#rrggbb")] /// Color of the item count in the picker - picker_info_color: Option, + picker_info_color: Option, #[arg(long, value_name = "#rrggbb")] /// Color of the prompt in the picker - picker_prompt_color: Option, + picker_prompt_color: Option, #[arg(long, value_name = "Alphabetical | LastAttached")] /// Set the sort order of the sessions in the switch command session_sort_order: Option, @@ -392,27 +393,27 @@ fn config_command(args: &ConfigCommand, mut config: Config) -> Result<()> { if let Some(color) = &args.picker_highlight_color { let mut picker_colors = config.picker_colors.unwrap_or_default(); - picker_colors.highlight_color = Some(color.to_string()); + picker_colors.highlight_color = Some(*color); config.picker_colors = Some(picker_colors); } if let Some(color) = &args.picker_highlight_text_color { let mut picker_colors = config.picker_colors.unwrap_or_default(); - picker_colors.highlight_text_color = Some(color.to_string()); + picker_colors.highlight_text_color = Some(*color); config.picker_colors = Some(picker_colors); } if let Some(color) = &args.picker_border_color { let mut picker_colors = config.picker_colors.unwrap_or_default(); - picker_colors.border_color = Some(color.to_string()); + picker_colors.border_color = Some(*color); config.picker_colors = Some(picker_colors); } if let Some(color) = &args.picker_info_color { let mut picker_colors = config.picker_colors.unwrap_or_default(); - picker_colors.info_color = Some(color.to_string()); + picker_colors.info_color = Some(*color); config.picker_colors = Some(picker_colors); } if let Some(color) = &args.picker_prompt_color { let mut picker_colors = config.picker_colors.unwrap_or_default(); - picker_colors.prompt_color = Some(color.to_string()); + picker_colors.prompt_color = Some(*color); config.picker_colors = Some(picker_colors); } diff --git a/src/configs.rs b/src/configs.rs index 7c76f2d..05c5676 100644 --- a/src/configs.rs +++ b/src/configs.rs @@ -3,7 +3,7 @@ use error_stack::ResultExt; use serde_derive::{Deserialize, Serialize}; use std::{collections::HashMap, env, fmt::Display, fs::canonicalize, io::Write, path::PathBuf}; -use ratatui::style::{Color, Style}; +use ratatui::style::{Color, Style, Stylize}; use crate::{error::Suggestion, keymap::Keymap}; @@ -142,8 +142,7 @@ impl Config { .change_context(ConfigError::IoError)? .to_string(); - let path = canonicalize(expanded_path) - .change_context(ConfigError::IoError)?; + let path = canonicalize(expanded_path).change_context(ConfigError::IoError)?; Ok(SearchDirectory::new(path, search_dir.depth)) }) @@ -255,70 +254,74 @@ pub struct Window { #[derive(Default, Debug, Serialize, Deserialize, PartialEq, Eq)] pub struct Pane {} -#[derive(Default, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Clone, Default, Debug, Serialize, Deserialize, PartialEq, Eq)] pub struct PickerColorConfig { - pub highlight_color: Option, - pub highlight_text_color: Option, - pub border_color: Option, - pub info_color: Option, - pub prompt_color: Option, + pub highlight_color: Option, + pub highlight_text_color: Option, + pub border_color: Option, + pub info_color: Option, + pub prompt_color: Option, } +const HIGHLIGHT_COLOR_DEFAULT: Color = Color::LightBlue; +const HIGHLIGHT_TEXT_COLOR_DEFAULT: Color = Color::Black; +const BORDER_COLOR_DEFAULT: Color = Color::DarkGray; +const INFO_COLOR_DEFAULT: Color = Color::LightYellow; +const PROMPT_COLOR_DEFAULT: Color = Color::LightGreen; + impl PickerColorConfig { + pub fn default_colors() -> Self { + PickerColorConfig { + highlight_color: Some(HIGHLIGHT_COLOR_DEFAULT), + highlight_text_color: Some(HIGHLIGHT_TEXT_COLOR_DEFAULT), + border_color: Some(BORDER_COLOR_DEFAULT), + info_color: Some(INFO_COLOR_DEFAULT), + prompt_color: Some(PROMPT_COLOR_DEFAULT), + } + } + pub fn highlight_style(&self) -> Style { - let mut style = Style::default().bg(Color::LightBlue).fg(Color::Black); + let mut style = Style::default() + .bg(HIGHLIGHT_COLOR_DEFAULT) + .fg(HIGHLIGHT_TEXT_COLOR_DEFAULT) + .bold(); - if let Some(color) = &self.highlight_color { - if let Some(color) = rgb_to_color(color) { - style = style.bg(color); - } + if let Some(color) = self.highlight_color { + style = style.bg(color); } - if let Some(color) = &self.highlight_text_color { - if let Some(color) = rgb_to_color(color) { - style = style.fg(color); - } + if let Some(color) = self.highlight_text_color { + style = style.fg(color); } style } - pub fn border_color(&self) -> Option { - if let Some(color) = &self.border_color { - rgb_to_color(color) + pub fn border_color(&self) -> Color { + if let Some(color) = self.border_color { + color } else { - None + BORDER_COLOR_DEFAULT } } - pub fn info_color(&self) -> Option { - if let Some(color) = &self.info_color { - rgb_to_color(color) + pub fn info_color(&self) -> Color { + if let Some(color) = self.info_color { + color } else { - None + INFO_COLOR_DEFAULT } } - pub fn prompt_color(&self) -> Option { - if let Some(color) = &self.prompt_color { - rgb_to_color(color) + pub fn prompt_color(&self) -> Color { + if let Some(color) = self.prompt_color { + color } else { - None + PROMPT_COLOR_DEFAULT } } } -fn rgb_to_color(color: &str) -> Option { - if color.len() == 7 && color.starts_with('#') { - let red = u8::from_str_radix(&color[1..3], 16).ok()?; - let green = u8::from_str_radix(&color[3..5], 16).ok()?; - let blue = u8::from_str_radix(&color[5..7], 16).ok()?; - Some(Color::Rgb(red, green, blue)) - } else { - None - } -} - #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] pub enum SessionSortOrderConfig { Alphabetical, diff --git a/src/keymap.rs b/src/keymap.rs index 4068c8a..f11d83e 100644 --- a/src/keymap.rs +++ b/src/keymap.rs @@ -4,7 +4,6 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use serde::de::Error as DeError; use serde::ser::Error as SerError; use serde::{Deserialize, Serialize}; -use serde_derive::{Deserialize, Serialize}; use crate::error::TmsError; diff --git a/src/picker.rs b/src/picker.rs index 3ede331..88fd5ad 100644 --- a/src/picker.rs +++ b/src/picker.rs @@ -13,7 +13,7 @@ use crossterm::{ }; use nucleo::{ pattern::{CaseMatching, Normalization}, - Nucleo, Snapshot, + Nucleo, }; use ratatui::{ backend::CrosstermBackend, @@ -116,6 +116,8 @@ impl<'a> Picker<'a> { terminal: &mut Terminal>, ) -> Result> { loop { + self.matcher.tick(10); + self.update_selection(); terminal .draw(|f| self.render(f)) .map_err(|e| TmsError::TuiError(e.to_string()))?; @@ -126,7 +128,7 @@ impl<'a> Picker<'a> { Some(PickerAction::Cancel) => return Ok(None), Some(PickerAction::Confirm) => { if let Some(selected) = self.get_selected() { - return Ok(Some(selected)); + return Ok(Some(selected.to_owned())); } } Some(PickerAction::Backspace) => self.remove_filter(), @@ -152,6 +154,20 @@ impl<'a> Picker<'a> { } } + fn update_selection(&mut self) { + let snapshot = self.matcher.snapshot(); + if let Some(selected) = self.selection.selected() { + if snapshot.matched_item_count() == 0 { + self.selection.select(None); + } else if selected > snapshot.matched_item_count() as usize { + self.selection + .select(Some(snapshot.matched_item_count() as usize - 1)); + } + } else if snapshot.matched_item_count() > 0 { + self.selection.select(Some(0)); + } + } + fn render(&mut self, f: &mut Frame) { let preview_direction; let picker_pane; @@ -188,57 +204,27 @@ impl<'a> Picker<'a> { ) .split(preview_split[picker_pane]); - self.matcher.tick(10); let snapshot = self.matcher.snapshot(); let matches = snapshot .matched_items(..snapshot.matched_item_count()) .map(|item| ListItem::new(item.data.as_str())); - if let Some(selected) = self.selection.selected() { - if snapshot.matched_item_count() == 0 { - self.selection.select(None); - } else if selected > snapshot.matched_item_count() as usize { - self.selection - .select(Some(snapshot.matched_item_count() as usize - 1)); - } - } else if snapshot.matched_item_count() > 0 { - self.selection.select(Some(0)); - } - - let mut selected_style = Style::default() - .bg(Color::LightBlue) - .fg(Color::Black) - .bold(); - let mut border_color = Color::DarkGray; - let mut info_color = Color::LightYellow; - let mut prompt_color = Color::LightGreen; - - if let Some(colors) = &self.colors { - selected_style = colors.highlight_style().bold(); - - if let Some(color) = colors.border_color() { - border_color = color; - } - - if let Some(color) = colors.info_color() { - info_color = color; - } - - if let Some(color) = colors.prompt_color() { - prompt_color = color; - } - } + let colors = if let Some(colors) = self.colors { + colors.to_owned() + } else { + PickerColorConfig::default_colors() + }; let table = List::new(matches) - .highlight_style(selected_style) + .highlight_style(colors.highlight_style()) .direction(ListDirection::BottomToTop) .highlight_spacing(HighlightSpacing::Always) .highlight_symbol("> ") .block( Block::default() .borders(Borders::BOTTOM) - .border_style(Style::default().fg(border_color)) - .title_style(Style::default().fg(info_color)) + .border_style(Style::default().fg(colors.border_color())) + .title_style(Style::default().fg(colors.info_color())) .title_position(Position::Bottom) .title(format!( "{}/{}", @@ -248,7 +234,7 @@ impl<'a> Picker<'a> { ); f.render_stateful_widget(table, layout[0], &mut self.selection); - let prompt = Span::styled("> ", Style::default().fg(prompt_color)); + let prompt = Span::styled("> ", Style::default().fg(colors.prompt_color())); let input_text = Span::raw(&self.filter); let input_line = Line::from(vec![prompt, input_text]); let input = Paragraph::new(vec![input_line]); @@ -260,9 +246,8 @@ impl<'a> Picker<'a> { if !matches!(self.preview, Preview::None) { self.render_preview( - snapshot, f, - &border_color, + &colors.border_color(), &preview_direction, preview_split[preview_pane], ); @@ -271,36 +256,31 @@ impl<'a> Picker<'a> { fn render_preview( &self, - snapshot: &Snapshot, f: &mut Frame, border_color: &Color, direction: &Direction, rect: Rect, ) { - let text = if let Some(index) = self.selection.selected() { - if let Some(item) = snapshot.get_matched_item(index as u32) { - let output = match self.preview { - Preview::SessionPane => self.tmux.capture_pane(item.data), - Preview::WindowPane => self.tmux.capture_pane( - item.data - .split_once(' ') - .map(|val| val.0) - .unwrap_or_default(), - ), - Preview::Directory => process::Command::new("ls") - .args(["-1", item.data]) - .output() - .unwrap_or_else(|_| { - panic!("Failed to execute the command for directory: {}", item.data) - }), - Preview::None => panic!("preview rendering should not have occured"), - }; - - if output.status.success() { - String::from_utf8(output.stdout).unwrap() - } else { - "".to_string() - } + let text = if let Some(item_data) = self.get_selected() { + let output = match self.preview { + Preview::SessionPane => self.tmux.capture_pane(item_data), + Preview::WindowPane => self.tmux.capture_pane( + item_data + .split_once(' ') + .map(|val| val.0) + .unwrap_or_default(), + ), + Preview::Directory => process::Command::new("ls") + .args(["-1", item_data]) + .output() + .unwrap_or_else(|_| { + panic!("Failed to execute the command for directory: {}", item_data) + }), + Preview::None => panic!("preview rendering should not have occured"), + }; + + if output.status.success() { + String::from_utf8(output.stdout).unwrap() } else { "".to_string() } @@ -323,13 +303,13 @@ impl<'a> Picker<'a> { f.render_widget(preview, rect); } - fn get_selected(&self) -> Option { + fn get_selected(&self) -> Option<&String> { if let Some(index) = self.selection.selected() { return self .matcher .snapshot() .get_matched_item(index as u32) - .map(|item| item.data.to_owned()); + .map(|item| item.data); } None diff --git a/tests/cli.rs b/tests/cli.rs index 28680e6..fde8dd7 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -1,6 +1,7 @@ use assert_cmd::Command; use pretty_assertions::assert_eq; -use std::fs; +use ratatui::style::Color; +use std::{fs, str::FromStr}; use tempfile::tempdir; use tms::configs::{Config, PickerColorConfig, SearchDirectory, SessionSortOrderConfig}; @@ -32,11 +33,11 @@ fn tms_config() -> anyhow::Result<()> { let depth = 1; let default_session = String::from("my_default_session"); let excluded_dir = String::from("/exclude/this/directory"); - let picker_highlight_color = String::from("#aaaaaa"); - let picker_highlight_text_color = String::from("#bbbbbb"); - let picker_border_color = String::from("#cccccc"); - let picker_info_color = String::from("#dddddd"); - let picker_prompt_color = String::from("#eeeeee"); + let picker_highlight_color = Color::from_str("#aaaaaa")?; + let picker_highlight_text_color = Color::from_str("#bbbbbb")?; + let picker_border_color = Color::from_str("#cccccc")?; + let picker_info_color = Color::from_str("green")?; + let picker_prompt_color = Color::from_str("#eeeeee")?; let expected_config = Config { default_session: Some(default_session.clone()), @@ -53,11 +54,11 @@ fn tms_config() -> anyhow::Result<()> { )]), sessions: None, picker_colors: Some(PickerColorConfig { - highlight_color: Some(picker_highlight_color.clone()), - highlight_text_color: Some(picker_highlight_text_color.clone()), - border_color: Some(picker_border_color.clone()), - info_color: Some(picker_info_color.clone()), - prompt_color: Some(picker_prompt_color.clone()), + highlight_color: Some(picker_highlight_color), + highlight_text_color: Some(picker_highlight_text_color), + border_color: Some(picker_border_color), + info_color: Some(picker_info_color), + prompt_color: Some(picker_prompt_color), }), shortcuts: None, bookmarks: None, @@ -88,15 +89,15 @@ fn tms_config() -> anyhow::Result<()> { "--excluded", &excluded_dir, "--picker-highlight-color", - &picker_highlight_color, + &picker_highlight_color.to_string(), "--picker-highlight-text-color", - &picker_highlight_text_color, + &picker_highlight_text_color.to_string(), "--picker-border-color", - &picker_border_color, + &picker_border_color.to_string(), "--picker-info-color", - &picker_info_color, + &picker_info_color.to_string(), "--picker-prompt-color", - &picker_prompt_color, + &picker_prompt_color.to_string(), ]); tms.assert().success().code(0);