From 256e6b7109e3796f5f11f9f9aeba467a3128c1da Mon Sep 17 00:00:00 2001 From: daewon Date: Fri, 6 Mar 2026 17:28:22 +0000 Subject: [PATCH 1/6] Add configurable UI theme selection styling --- docs/src/configurations/config-file-format.md | 51 ++++++++++ src/color.rs | 96 ++++++++++++++++++- src/config.rs | 17 ++++ src/main.rs | 2 +- src/pages/bucket_list.rs | 14 ++- src/pages/object_detail.rs | 17 ++-- src/pages/object_list.rs | 59 +++++++++--- 7 files changed, 228 insertions(+), 28 deletions(-) diff --git a/docs/src/configurations/config-file-format.md b/docs/src/configurations/config-file-format.md index 2352d0d..8629b38 100644 --- a/docs/src/configurations/config-file-format.md +++ b/docs/src/configurations/config-file-format.md @@ -23,6 +23,13 @@ date_format = "%Y-%m-%d %H:%M:%S" [ui.help] max_help_width = 100 +[ui.theme] +list_selected_bg = "#ffd166" +list_selected_fg = "black" +list_selected_inactive_bg = "dark_gray" +list_selected_inactive_fg = "black" +object_dir_bold = true + [preview] highlight = false highlight_theme = "base16-ocean.dark" @@ -119,6 +126,50 @@ The maximum width of the keybindings display area in the help. - type: `usize` - default: `100` +### `ui.theme.list_selected_bg` + +The background color of the selected row in bucket and object lists. + +- type: `string` +- default: `cyan` + +Supports named terminal colors such as `cyan`, `yellow`, `dark_gray`, `bright_white`, and hex +colors such as `#ffd166` or `#abc`. + +### `ui.theme.list_selected_fg` + +The foreground color of the selected row in bucket and object lists. + +- type: `string` +- default: `black` + +Supports the same color formats as `ui.theme.list_selected_bg`. + +### `ui.theme.list_selected_inactive_bg` + +The background color of the selected row when the list is inactive. + +- type: `string` +- default: `dark_gray` + +Supports the same color formats as `ui.theme.list_selected_bg`. + +### `ui.theme.list_selected_inactive_fg` + +The foreground color of the selected row when the list is inactive. + +- type: `string` +- default: `black` + +Supports the same color formats as `ui.theme.list_selected_bg`. + +### `ui.theme.object_dir_bold` + +Whether directory names in the object list should be rendered in bold. + +- type: `bool` +- default: `true` + ### `preview.highlight` Whether syntax highlighting is enabled in the object preview. diff --git a/src/color.rs b/src/color.rs index 1845c6a..d8f5fc7 100644 --- a/src/color.rs +++ b/src/color.rs @@ -1,4 +1,7 @@ -use ratatui::style::Color; +use anyhow::{anyhow, Context}; +use ratatui::style::{Color, Style}; + +use crate::config::Config; #[derive(Debug, Clone)] pub struct ColorTheme { @@ -60,3 +63,94 @@ impl Default for ColorTheme { } } } + +impl ColorTheme { + pub fn from_config(config: &Config) -> anyhow::Result { + let mut theme = Self::default(); + theme.list_selected_bg = parse_color(&config.ui.theme.list_selected_bg) + .with_context(|| "Failed to parse ui.theme.list_selected_bg")?; + theme.list_selected_fg = parse_color(&config.ui.theme.list_selected_fg) + .with_context(|| "Failed to parse ui.theme.list_selected_fg")?; + theme.list_selected_inactive_bg = parse_color(&config.ui.theme.list_selected_inactive_bg) + .with_context(|| "Failed to parse ui.theme.list_selected_inactive_bg")?; + theme.list_selected_inactive_fg = parse_color(&config.ui.theme.list_selected_inactive_fg) + .with_context(|| "Failed to parse ui.theme.list_selected_inactive_fg")?; + Ok(theme) + } + + pub fn list_selected_style(&self) -> Style { + Style::default() + .bg(self.list_selected_bg) + .fg(self.list_selected_fg) + } + + pub fn list_selected_inactive_style(&self) -> Style { + Style::default() + .bg(self.list_selected_inactive_bg) + .fg(self.list_selected_inactive_fg) + } +} + +fn parse_color(value: &str) -> anyhow::Result { + let value = value.trim(); + let normalized = value.to_ascii_lowercase().replace(['-', ' '], "_"); + + match normalized.as_str() { + "reset" | "default" => Ok(Color::Reset), + "black" => Ok(Color::Black), + "red" => Ok(Color::Red), + "green" => Ok(Color::Green), + "yellow" => Ok(Color::Yellow), + "blue" => Ok(Color::Blue), + "magenta" => Ok(Color::Magenta), + "cyan" => Ok(Color::Cyan), + "gray" | "grey" => Ok(Color::Gray), + "dark_gray" | "dark_grey" | "bright_black" => Ok(Color::DarkGray), + "light_red" | "bright_red" => Ok(Color::LightRed), + "light_green" | "bright_green" => Ok(Color::LightGreen), + "light_yellow" | "bright_yellow" => Ok(Color::LightYellow), + "light_blue" | "bright_blue" => Ok(Color::LightBlue), + "light_magenta" | "bright_magenta" => Ok(Color::LightMagenta), + "light_cyan" | "bright_cyan" => Ok(Color::LightCyan), + "white" | "light_white" | "bright_white" => Ok(Color::White), + _ => parse_hex_color(value).ok_or_else(|| anyhow!("unknown color: {value}")), + } +} + +fn parse_hex_color(value: &str) -> Option { + let hex = value.strip_prefix('#')?; + + match hex.len() { + 3 => { + let r = u8::from_str_radix(&hex[0..1].repeat(2), 16).ok()?; + let g = u8::from_str_radix(&hex[1..2].repeat(2), 16).ok()?; + let b = u8::from_str_radix(&hex[2..3].repeat(2), 16).ok()?; + Some(Color::Rgb(r, g, b)) + } + 6 => { + let r = u8::from_str_radix(&hex[0..2], 16).ok()?; + let g = u8::from_str_radix(&hex[2..4], 16).ok()?; + let b = u8::from_str_radix(&hex[4..6], 16).ok()?; + Some(Color::Rgb(r, g, b)) + } + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_named_colors() { + assert_eq!(parse_color("cyan").unwrap(), Color::Cyan); + assert_eq!(parse_color("dark-gray").unwrap(), Color::DarkGray); + assert_eq!(parse_color("bright_white").unwrap(), Color::White); + } + + #[test] + fn parse_hex_colors() { + assert_eq!(parse_color("#123456").unwrap(), Color::Rgb(0x12, 0x34, 0x56)); + assert_eq!(parse_color("#abc").unwrap(), Color::Rgb(0xaa, 0xbb, 0xcc)); + } +} diff --git a/src/config.rs b/src/config.rs index 5156161..c28ded4 100644 --- a/src/config.rs +++ b/src/config.rs @@ -45,6 +45,8 @@ pub struct UiConfig { pub object_detail: UiObjectDetailConfig, #[nested] pub help: UiHelpConfig, + #[nested] + pub theme: UiThemeConfig, } #[optional(derives = [Deserialize])] @@ -99,6 +101,21 @@ pub struct UiHelpConfig { pub max_help_width: usize, } +#[optional(derives = [Deserialize])] +#[derive(Debug, Clone, SmartDefault)] +pub struct UiThemeConfig { + #[default = "cyan"] + pub list_selected_bg: String, + #[default = "black"] + pub list_selected_fg: String, + #[default = "dark_gray"] + pub list_selected_inactive_bg: String, + #[default = "black"] + pub list_selected_inactive_fg: String, + #[default = true] + pub object_dir_bold: bool, +} + #[optional(derives = [Deserialize])] #[derive(Debug, Clone, SmartDefault)] pub struct PreviewConfig { diff --git a/src/main.rs b/src/main.rs index 41f9e12..baa6fd2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -97,7 +97,7 @@ async fn main() -> anyhow::Result<()> { let config = Config::load()?; let mapper = UserEventMapper::load()?; let env = Environment::new(config.preview.image, args.fix_dynamic_values_for_test); - let theme = ColorTheme::default(); + let theme = ColorTheme::from_config(&config)?; let ctx = AppContext::new(config, env, theme); initialize_debug_log(&args)?; diff --git a/src/pages/bucket_list.rs b/src/pages/bucket_list.rs index 2c781db..d88f455 100644 --- a/src/pages/bucket_list.rs +++ b/src/pages/bucket_list.rs @@ -225,6 +225,7 @@ impl BucketListPage { pub fn render(&mut self, f: &mut Frame, area: Rect) { let offset = self.list_state.offset; let selected = self.list_state.selected; + let list_active = matches!(self.view_state, ViewState::Default); let list_items = build_list_items( &self.bucket_items, @@ -233,6 +234,7 @@ impl BucketListPage { &self.ctx.theme, offset, selected, + list_active, area, ); @@ -678,6 +680,7 @@ fn build_list_items<'a>( theme: &'a ColorTheme, offset: usize, selected: usize, + list_active: bool, area: Rect, ) -> Vec> { let show_item_count = (area.height as usize) - 2 /* border */; @@ -689,7 +692,7 @@ fn build_list_items<'a>( .enumerate() .map(|(idx, item)| { let selected = idx + offset == selected; - build_list_item(&item.name, selected, filter, area.width, theme) + build_list_item(&item.name, selected, list_active, filter, area.width, theme) }) .collect() } @@ -697,6 +700,7 @@ fn build_list_items<'a>( fn build_list_item<'a>( name: &'a str, selected: bool, + list_active: bool, filter: &'a str, width: u16, theme: &'a ColorTheme, @@ -728,9 +732,11 @@ fn build_list_item<'a>( }; let style = if selected { - Style::default() - .bg(theme.list_selected_bg) - .fg(theme.list_selected_fg) + if list_active { + theme.list_selected_style() + } else { + theme.list_selected_inactive_style() + } } else { Style::default() }; diff --git a/src/pages/object_detail.rs b/src/pages/object_detail.rs index 6ffc987..ad0588d 100644 --- a/src/pages/object_detail.rs +++ b/src/pages/object_detail.rs @@ -202,6 +202,7 @@ impl ObjectDetailPage { offset, selected, chunks[0], + &self.ctx.config.ui, &self.ctx.theme, ); @@ -483,6 +484,7 @@ fn build_list_items_from_object_items<'a>( offset: usize, selected: usize, area: Rect, + ui_config: &UiConfig, theme: &ColorTheme, ) -> Vec> { let show_item_count = (area.height as usize) - 2 /* border */; @@ -492,7 +494,7 @@ fn build_list_items_from_object_items<'a>( .take(show_item_count) .enumerate() .map(|(idx, item)| { - build_list_item_from_object_item(idx, item, offset, selected, area, theme) + build_list_item_from_object_item(idx, item, offset, selected, area, ui_config, theme) }) .collect() } @@ -503,12 +505,17 @@ fn build_list_item_from_object_item<'a>( offset: usize, selected: usize, area: Rect, + ui_config: &UiConfig, theme: &ColorTheme, ) -> ListItem<'a> { let content = match item { ObjectItem::Dir { name, .. } => { let content = format_dir_item(name, area.width); - let style = Style::default().add_modifier(Modifier::BOLD); + let style = if ui_config.theme.object_dir_bold { + Style::default().add_modifier(Modifier::BOLD) + } else { + Style::default() + }; Span::styled(content, style) } ObjectItem::File { name, .. } => { @@ -518,11 +525,7 @@ fn build_list_item_from_object_item<'a>( } }; if idx + offset == selected { - ListItem::new(content).style( - Style::default() - .bg(theme.list_selected_inactive_bg) - .fg(theme.list_selected_inactive_fg), - ) + ListItem::new(content).style(theme.list_selected_style()) } else { ListItem::new(content) } diff --git a/src/pages/object_list.rs b/src/pages/object_list.rs index e85cea2..2779bd5 100644 --- a/src/pages/object_list.rs +++ b/src/pages/object_list.rs @@ -241,6 +241,7 @@ impl ObjectListPage { pub fn render(&mut self, f: &mut Frame, area: Rect) { let offset = self.list_state.offset; let selected = self.list_state.selected; + let list_active = matches!(self.view_state, ViewState::Default); let list_items = build_list_items( &self.object_items, @@ -248,6 +249,7 @@ impl ObjectListPage { self.filter_input_state.input(), offset, selected, + list_active, area, &self.ctx.config.ui, &self.ctx.env, @@ -771,6 +773,7 @@ fn build_list_items<'a>( filter: &'a str, offset: usize, selected: usize, + list_active: bool, area: Rect, ui_config: &UiConfig, env: &Environment, @@ -783,14 +786,15 @@ fn build_list_items<'a>( .skip(offset) .take(show_item_count) .enumerate() - .map(|(idx, item)| { - build_list_item( - item, - idx + offset == selected, - filter, - area, - ui_config, - env, + .map(|(idx, item)| { + build_list_item( + item, + idx + offset == selected, + list_active, + filter, + area, + ui_config, + env, theme, ) }) @@ -800,6 +804,7 @@ fn build_list_items<'a>( fn build_list_item<'a>( item: &'a ObjectItem, selected: bool, + list_active: bool, filter: &'a str, area: Rect, ui_config: &UiConfig, @@ -807,7 +812,13 @@ fn build_list_item<'a>( theme: &ColorTheme, ) -> ListItem<'a> { let line = match item { - ObjectItem::Dir { name, .. } => build_object_dir_line(name, filter, area.width, theme), + ObjectItem::Dir { name, .. } => build_object_dir_line( + name, + filter, + area.width, + ui_config.theme.object_dir_bold, + theme, + ), ObjectItem::File { name, size_byte, @@ -826,9 +837,11 @@ fn build_list_item<'a>( }; let style = if selected { - Style::default() - .bg(theme.list_selected_bg) - .fg(theme.list_selected_fg) + if list_active { + theme.list_selected_style() + } else { + theme.list_selected_inactive_style() + } } else { Style::default() }; @@ -839,6 +852,7 @@ fn build_object_dir_line<'a>( name: &'a str, filter: &'a str, width: u16, + dir_bold: bool, theme: &ColorTheme, ) -> Line<'a> { let name = format!("{name}/"); @@ -851,17 +865,32 @@ fn build_object_dir_line<'a>( }; if filter.is_empty() { - Line::from(vec![" ".into(), pad_name.bold(), " ".into()]) + let name_span = if dir_bold { + pad_name.bold() + } else { + pad_name.into() + }; + Line::from(vec![" ".into(), name_span, " ".into()]) } else { let i = name.find(filter).unwrap(); let mut hm = highlight_matched_text(vec![pad_name.into()]); if w > name_w { hm = hm.ellipsis(ELLIPSIS); } + let not_matched_style = if dir_bold { + Style::default().bold() + } else { + Style::default() + }; + let matched_style = if dir_bold { + Style::default().fg(theme.list_filter_match).bold() + } else { + Style::default().fg(theme.list_filter_match) + }; let mut spans = hm .matched_range(i, i + filter.len()) - .not_matched_style(Style::default().bold()) - .matched_style(Style::default().fg(theme.list_filter_match).bold()) + .not_matched_style(not_matched_style) + .matched_style(matched_style) .into_spans(); spans.insert(0, " ".into()); spans.push(" ".into()); From e8b34cfd9f65fb118cc39dc30621f5cd48f1cbf8 Mon Sep 17 00:00:00 2001 From: daewon Date: Fri, 6 Mar 2026 17:45:55 +0000 Subject: [PATCH 2/6] Refactor UI theme selection helpers - centralize list selection style resolution in ColorTheme - reuse UiThemeConfig for directory item styling - align list rendering tests with active/inactive selection behavior Validated with cargo fmt --all -- --check, cargo test -q, and cargo clippy --all-targets --all-features -- -D warnings on stable. --- src/color.rs | 70 +++++++++++++++++++++++++++++--------- src/config.rs | 11 ++++++ src/pages/bucket_list.rs | 15 ++------ src/pages/object_detail.rs | 35 +++++++++++-------- src/pages/object_list.rs | 63 +++++++++++----------------------- 5 files changed, 107 insertions(+), 87 deletions(-) diff --git a/src/color.rs b/src/color.rs index d8f5fc7..fba7c40 100644 --- a/src/color.rs +++ b/src/color.rs @@ -1,7 +1,7 @@ use anyhow::{anyhow, Context}; use ratatui::style::{Color, Style}; -use crate::config::Config; +use crate::config::{Config, UiThemeConfig}; #[derive(Debug, Clone)] pub struct ColorTheme { @@ -67,30 +67,58 @@ impl Default for ColorTheme { impl ColorTheme { pub fn from_config(config: &Config) -> anyhow::Result { let mut theme = Self::default(); - theme.list_selected_bg = parse_color(&config.ui.theme.list_selected_bg) - .with_context(|| "Failed to parse ui.theme.list_selected_bg")?; - theme.list_selected_fg = parse_color(&config.ui.theme.list_selected_fg) - .with_context(|| "Failed to parse ui.theme.list_selected_fg")?; - theme.list_selected_inactive_bg = parse_color(&config.ui.theme.list_selected_inactive_bg) - .with_context(|| "Failed to parse ui.theme.list_selected_inactive_bg")?; - theme.list_selected_inactive_fg = parse_color(&config.ui.theme.list_selected_inactive_fg) - .with_context(|| "Failed to parse ui.theme.list_selected_inactive_fg")?; + theme.apply_ui_theme(&config.ui.theme)?; Ok(theme) } + fn apply_ui_theme(&mut self, ui_theme: &UiThemeConfig) -> anyhow::Result<()> { + self.list_selected_bg = + parse_config_color(&ui_theme.list_selected_bg, "ui.theme.list_selected_bg")?; + self.list_selected_fg = + parse_config_color(&ui_theme.list_selected_fg, "ui.theme.list_selected_fg")?; + self.list_selected_inactive_bg = parse_config_color( + &ui_theme.list_selected_inactive_bg, + "ui.theme.list_selected_inactive_bg", + )?; + self.list_selected_inactive_fg = parse_config_color( + &ui_theme.list_selected_inactive_fg, + "ui.theme.list_selected_inactive_fg", + )?; + Ok(()) + } + + pub fn list_item_style(&self, selected: bool, active: bool) -> Style { + if !selected { + return Style::default(); + } + + if active { + self.list_selected_style() + } else { + self.list_selected_inactive_style() + } + } + pub fn list_selected_style(&self) -> Style { - Style::default() - .bg(self.list_selected_bg) - .fg(self.list_selected_fg) + list_style(self.list_selected_bg, self.list_selected_fg) } pub fn list_selected_inactive_style(&self) -> Style { - Style::default() - .bg(self.list_selected_inactive_bg) - .fg(self.list_selected_inactive_fg) + list_style( + self.list_selected_inactive_bg, + self.list_selected_inactive_fg, + ) } } +fn list_style(bg: Color, fg: Color) -> Style { + Style::default().bg(bg).fg(fg) +} + +fn parse_config_color(value: &str, key: &str) -> anyhow::Result { + parse_color(value).with_context(|| format!("Failed to parse {key}")) +} + fn parse_color(value: &str) -> anyhow::Result { let value = value.trim(); let normalized = value.to_ascii_lowercase().replace(['-', ' '], "_"); @@ -150,7 +178,17 @@ mod tests { #[test] fn parse_hex_colors() { - assert_eq!(parse_color("#123456").unwrap(), Color::Rgb(0x12, 0x34, 0x56)); + assert_eq!( + parse_color("#123456").unwrap(), + Color::Rgb(0x12, 0x34, 0x56) + ); assert_eq!(parse_color("#abc").unwrap(), Color::Rgb(0xaa, 0xbb, 0xcc)); } + + #[test] + fn list_item_style_falls_back_to_default_for_unselected_items() { + let theme = ColorTheme::default(); + assert_eq!(theme.list_item_style(false, true), Style::default()); + assert_eq!(theme.list_item_style(false, false), Style::default()); + } } diff --git a/src/config.rs b/src/config.rs index c28ded4..f18c691 100644 --- a/src/config.rs +++ b/src/config.rs @@ -4,6 +4,7 @@ use std::{ }; use anyhow::Context; +use ratatui::style::{Modifier, Style}; use serde::Deserialize; use smart_default::SmartDefault; use umbra::optional; @@ -116,6 +117,16 @@ pub struct UiThemeConfig { pub object_dir_bold: bool, } +impl UiThemeConfig { + pub fn object_dir_style(&self) -> Style { + let mut style = Style::default(); + if self.object_dir_bold { + style = style.add_modifier(Modifier::BOLD); + } + style + } +} + #[optional(derives = [Deserialize])] #[derive(Debug, Clone, SmartDefault)] pub struct PreviewConfig { diff --git a/src/pages/bucket_list.rs b/src/pages/bucket_list.rs index d88f455..af8e977 100644 --- a/src/pages/bucket_list.rs +++ b/src/pages/bucket_list.rs @@ -731,16 +731,7 @@ fn build_list_item<'a>( Line::from(spans) }; - let style = if selected { - if list_active { - theme.list_selected_style() - } else { - theme.list_selected_inactive_style() - } - } else { - Style::default() - }; - ListItem::new(line).style(style) + ListItem::new(line).style(theme.list_item_style(selected, list_active)) } fn build_download_confirm_message_lines<'a>( @@ -883,7 +874,7 @@ mod tests { ]); set_cells! { expected => // selected item - (2..28, [1]) => bg: Color::Cyan, fg: Color::Black, + (2..28, [1]) => bg: Color::DarkGray, fg: Color::Black, // match ([3], [1]) => fg: Color::Red, ([3], [2]) => fg: Color::Red, @@ -973,7 +964,7 @@ mod tests { ]); set_cells! { expected => // selected item - (2..28, [1]) => bg: Color::Cyan, fg: Color::Black, + (2..28, [1]) => bg: Color::DarkGray, fg: Color::Black, // selected sort item (4..26, [5]) => fg: Color::Cyan, } diff --git a/src/pages/object_detail.rs b/src/pages/object_detail.rs index ad0588d..e635f49 100644 --- a/src/pages/object_detail.rs +++ b/src/pages/object_detail.rs @@ -196,11 +196,13 @@ impl ObjectDetailPage { let offset = self.list_state.offset; let selected = self.list_state.selected; + let list_active = matches!(self.view_state, ViewState::Default); let list_items = build_list_items_from_object_items( &self.object_items, offset, selected, + list_active, chunks[0], &self.ctx.config.ui, &self.ctx.theme, @@ -483,6 +485,7 @@ fn build_list_items_from_object_items<'a>( current_items: &'a [ObjectItem], offset: usize, selected: usize, + list_active: bool, area: Rect, ui_config: &UiConfig, theme: &ColorTheme, @@ -494,7 +497,16 @@ fn build_list_items_from_object_items<'a>( .take(show_item_count) .enumerate() .map(|(idx, item)| { - build_list_item_from_object_item(idx, item, offset, selected, area, ui_config, theme) + build_list_item_from_object_item( + idx, + item, + offset, + selected, + list_active, + area, + ui_config, + theme, + ) }) .collect() } @@ -504,6 +516,7 @@ fn build_list_item_from_object_item<'a>( item: &'a ObjectItem, offset: usize, selected: usize, + list_active: bool, area: Rect, ui_config: &UiConfig, theme: &ColorTheme, @@ -511,11 +524,7 @@ fn build_list_item_from_object_item<'a>( let content = match item { ObjectItem::Dir { name, .. } => { let content = format_dir_item(name, area.width); - let style = if ui_config.theme.object_dir_bold { - Style::default().add_modifier(Modifier::BOLD) - } else { - Style::default() - }; + let style = ui_config.theme.object_dir_style(); Span::styled(content, style) } ObjectItem::File { name, .. } => { @@ -524,11 +533,7 @@ fn build_list_item_from_object_item<'a>( Span::styled(content, style) } }; - if idx + offset == selected { - ListItem::new(content).style(theme.list_selected_style()) - } else { - ListItem::new(content) - } + ListItem::new(content).style(theme.list_item_style(idx + offset == selected, list_active)) } fn format_dir_item(name: &str, width: u16) -> String { @@ -877,7 +882,7 @@ mod tests { ]); set_cells! { expected => // selected item - (2..28, [1]) => bg: Color::DarkGray, fg: Color::Black, + (2..28, [1]) => bg: Color::Cyan, fg: Color::Black, // "Detail" is selected (32..38, [1]) => fg: Color::Cyan, modifier: Modifier::BOLD, // "Name" label @@ -944,7 +949,7 @@ mod tests { ]); set_cells! { expected => // selected item - (2..28, [1]) => bg: Color::DarkGray, fg: Color::Black, + (2..28, [1]) => bg: Color::Cyan, fg: Color::Black, // "Detail" is selected (32..38, [1]) => fg: Color::Cyan, modifier: Modifier::BOLD, // "Name" label @@ -1012,7 +1017,7 @@ mod tests { ]); set_cells! { expected => // selected item - (2..28, [1]) => bg: Color::DarkGray, fg: Color::Black, + (2..28, [1]) => bg: Color::Cyan, fg: Color::Black, // "Version" is selected (41..48, [1]) => fg: Color::Cyan, modifier: Modifier::BOLD, // "Version ID" label @@ -1081,7 +1086,7 @@ mod tests { ]); set_cells! { expected => // selected item - (2..28, [1]) => bg: Color::DarkGray, fg: Color::Black, + (2..28, [1]) => bg: Color::Cyan, fg: Color::Black, // "Version" is selected (41..48, [1]) => fg: Color::Cyan, modifier: Modifier::BOLD, // "Version ID" label diff --git a/src/pages/object_list.rs b/src/pages/object_list.rs index 2779bd5..298f1d6 100644 --- a/src/pages/object_list.rs +++ b/src/pages/object_list.rs @@ -6,7 +6,7 @@ use ratatui::{ crossterm::event::KeyEvent, layout::Rect, style::{Style, Stylize}, - text::Line, + text::{Line, Span}, widgets::ListItem, Frame, }; @@ -14,7 +14,7 @@ use ratatui::{ use crate::{ app::AppContext, color::ColorTheme, - config::UiConfig, + config::{UiConfig, UiThemeConfig}, environment::Environment, event::{AppEventType, Sender}, format::{format_datetime, format_size_byte}, @@ -786,15 +786,15 @@ fn build_list_items<'a>( .skip(offset) .take(show_item_count) .enumerate() - .map(|(idx, item)| { - build_list_item( - item, - idx + offset == selected, - list_active, - filter, - area, - ui_config, - env, + .map(|(idx, item)| { + build_list_item( + item, + idx + offset == selected, + list_active, + filter, + area, + ui_config, + env, theme, ) }) @@ -812,13 +812,9 @@ fn build_list_item<'a>( theme: &ColorTheme, ) -> ListItem<'a> { let line = match item { - ObjectItem::Dir { name, .. } => build_object_dir_line( - name, - filter, - area.width, - ui_config.theme.object_dir_bold, - theme, - ), + ObjectItem::Dir { name, .. } => { + build_object_dir_line(name, filter, area.width, &ui_config.theme, theme) + } ObjectItem::File { name, size_byte, @@ -836,23 +832,14 @@ fn build_list_item<'a>( ), }; - let style = if selected { - if list_active { - theme.list_selected_style() - } else { - theme.list_selected_inactive_style() - } - } else { - Style::default() - }; - ListItem::new(line).style(style) + ListItem::new(line).style(theme.list_item_style(selected, list_active)) } fn build_object_dir_line<'a>( name: &'a str, filter: &'a str, width: u16, - dir_bold: bool, + ui_theme: &UiThemeConfig, theme: &ColorTheme, ) -> Line<'a> { let name = format!("{name}/"); @@ -865,11 +852,7 @@ fn build_object_dir_line<'a>( }; if filter.is_empty() { - let name_span = if dir_bold { - pad_name.bold() - } else { - pad_name.into() - }; + let name_span = Span::styled(pad_name, ui_theme.object_dir_style()); Line::from(vec![" ".into(), name_span, " ".into()]) } else { let i = name.find(filter).unwrap(); @@ -877,16 +860,8 @@ fn build_object_dir_line<'a>( if w > name_w { hm = hm.ellipsis(ELLIPSIS); } - let not_matched_style = if dir_bold { - Style::default().bold() - } else { - Style::default() - }; - let matched_style = if dir_bold { - Style::default().fg(theme.list_filter_match).bold() - } else { - Style::default().fg(theme.list_filter_match) - }; + let not_matched_style = ui_theme.object_dir_style(); + let matched_style = not_matched_style.fg(theme.list_filter_match); let mut spans = hm .matched_range(i, i + filter.len()) .not_matched_style(not_matched_style) From 60b8bea0f369df333e598edfd22c446a81d6c551 Mon Sep 17 00:00:00 2001 From: daewon Date: Fri, 6 Mar 2026 17:56:57 +0000 Subject: [PATCH 3/6] Document default UI theme colors --- docs/src/configurations/config-file-format.md | 2 +- src/color.rs | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/src/configurations/config-file-format.md b/docs/src/configurations/config-file-format.md index 8629b38..a987e87 100644 --- a/docs/src/configurations/config-file-format.md +++ b/docs/src/configurations/config-file-format.md @@ -134,7 +134,7 @@ The background color of the selected row in bucket and object lists. - default: `cyan` Supports named terminal colors such as `cyan`, `yellow`, `dark_gray`, `bright_white`, and hex -colors such as `#ffd166` or `#abc`. +colors such as `#ffd166` or `#abc`. `default` and `reset` are also supported. ### `ui.theme.list_selected_fg` diff --git a/src/color.rs b/src/color.rs index fba7c40..6716a31 100644 --- a/src/color.rs +++ b/src/color.rs @@ -174,6 +174,8 @@ mod tests { assert_eq!(parse_color("cyan").unwrap(), Color::Cyan); assert_eq!(parse_color("dark-gray").unwrap(), Color::DarkGray); assert_eq!(parse_color("bright_white").unwrap(), Color::White); + assert_eq!(parse_color("default").unwrap(), Color::Reset); + assert_eq!(parse_color("reset").unwrap(), Color::Reset); } #[test] From 54239d7dc42e962d7fb5c70a18a6c5d4db58f07c Mon Sep 17 00:00:00 2001 From: daewon Date: Fri, 6 Mar 2026 18:02:25 +0000 Subject: [PATCH 4/6] Preserve default list styling behavior --- src/pages/bucket_list.rs | 12 ++++-------- src/pages/object_detail.rs | 25 ++++++------------------- src/pages/object_list.rs | 7 +------ 3 files changed, 11 insertions(+), 33 deletions(-) diff --git a/src/pages/bucket_list.rs b/src/pages/bucket_list.rs index af8e977..3435013 100644 --- a/src/pages/bucket_list.rs +++ b/src/pages/bucket_list.rs @@ -225,7 +225,6 @@ impl BucketListPage { pub fn render(&mut self, f: &mut Frame, area: Rect) { let offset = self.list_state.offset; let selected = self.list_state.selected; - let list_active = matches!(self.view_state, ViewState::Default); let list_items = build_list_items( &self.bucket_items, @@ -234,7 +233,6 @@ impl BucketListPage { &self.ctx.theme, offset, selected, - list_active, area, ); @@ -680,7 +678,6 @@ fn build_list_items<'a>( theme: &'a ColorTheme, offset: usize, selected: usize, - list_active: bool, area: Rect, ) -> Vec> { let show_item_count = (area.height as usize) - 2 /* border */; @@ -692,7 +689,7 @@ fn build_list_items<'a>( .enumerate() .map(|(idx, item)| { let selected = idx + offset == selected; - build_list_item(&item.name, selected, list_active, filter, area.width, theme) + build_list_item(&item.name, selected, filter, area.width, theme) }) .collect() } @@ -700,7 +697,6 @@ fn build_list_items<'a>( fn build_list_item<'a>( name: &'a str, selected: bool, - list_active: bool, filter: &'a str, width: u16, theme: &'a ColorTheme, @@ -731,7 +727,7 @@ fn build_list_item<'a>( Line::from(spans) }; - ListItem::new(line).style(theme.list_item_style(selected, list_active)) + ListItem::new(line).style(theme.list_item_style(selected, true)) } fn build_download_confirm_message_lines<'a>( @@ -874,7 +870,7 @@ mod tests { ]); set_cells! { expected => // selected item - (2..28, [1]) => bg: Color::DarkGray, fg: Color::Black, + (2..28, [1]) => bg: Color::Cyan, fg: Color::Black, // match ([3], [1]) => fg: Color::Red, ([3], [2]) => fg: Color::Red, @@ -964,7 +960,7 @@ mod tests { ]); set_cells! { expected => // selected item - (2..28, [1]) => bg: Color::DarkGray, fg: Color::Black, + (2..28, [1]) => bg: Color::Cyan, fg: Color::Black, // selected sort item (4..26, [5]) => fg: Color::Cyan, } diff --git a/src/pages/object_detail.rs b/src/pages/object_detail.rs index e635f49..d07408f 100644 --- a/src/pages/object_detail.rs +++ b/src/pages/object_detail.rs @@ -196,13 +196,11 @@ impl ObjectDetailPage { let offset = self.list_state.offset; let selected = self.list_state.selected; - let list_active = matches!(self.view_state, ViewState::Default); let list_items = build_list_items_from_object_items( &self.object_items, offset, selected, - list_active, chunks[0], &self.ctx.config.ui, &self.ctx.theme, @@ -485,7 +483,6 @@ fn build_list_items_from_object_items<'a>( current_items: &'a [ObjectItem], offset: usize, selected: usize, - list_active: bool, area: Rect, ui_config: &UiConfig, theme: &ColorTheme, @@ -497,16 +494,7 @@ fn build_list_items_from_object_items<'a>( .take(show_item_count) .enumerate() .map(|(idx, item)| { - build_list_item_from_object_item( - idx, - item, - offset, - selected, - list_active, - area, - ui_config, - theme, - ) + build_list_item_from_object_item(idx, item, offset, selected, area, ui_config, theme) }) .collect() } @@ -516,7 +504,6 @@ fn build_list_item_from_object_item<'a>( item: &'a ObjectItem, offset: usize, selected: usize, - list_active: bool, area: Rect, ui_config: &UiConfig, theme: &ColorTheme, @@ -533,7 +520,7 @@ fn build_list_item_from_object_item<'a>( Span::styled(content, style) } }; - ListItem::new(content).style(theme.list_item_style(idx + offset == selected, list_active)) + ListItem::new(content).style(theme.list_item_style(idx + offset == selected, false)) } fn format_dir_item(name: &str, width: u16) -> String { @@ -882,7 +869,7 @@ mod tests { ]); set_cells! { expected => // selected item - (2..28, [1]) => bg: Color::Cyan, fg: Color::Black, + (2..28, [1]) => bg: Color::DarkGray, fg: Color::Black, // "Detail" is selected (32..38, [1]) => fg: Color::Cyan, modifier: Modifier::BOLD, // "Name" label @@ -949,7 +936,7 @@ mod tests { ]); set_cells! { expected => // selected item - (2..28, [1]) => bg: Color::Cyan, fg: Color::Black, + (2..28, [1]) => bg: Color::DarkGray, fg: Color::Black, // "Detail" is selected (32..38, [1]) => fg: Color::Cyan, modifier: Modifier::BOLD, // "Name" label @@ -1017,7 +1004,7 @@ mod tests { ]); set_cells! { expected => // selected item - (2..28, [1]) => bg: Color::Cyan, fg: Color::Black, + (2..28, [1]) => bg: Color::DarkGray, fg: Color::Black, // "Version" is selected (41..48, [1]) => fg: Color::Cyan, modifier: Modifier::BOLD, // "Version ID" label @@ -1086,7 +1073,7 @@ mod tests { ]); set_cells! { expected => // selected item - (2..28, [1]) => bg: Color::Cyan, fg: Color::Black, + (2..28, [1]) => bg: Color::DarkGray, fg: Color::Black, // "Version" is selected (41..48, [1]) => fg: Color::Cyan, modifier: Modifier::BOLD, // "Version ID" label diff --git a/src/pages/object_list.rs b/src/pages/object_list.rs index 298f1d6..6a24e78 100644 --- a/src/pages/object_list.rs +++ b/src/pages/object_list.rs @@ -241,7 +241,6 @@ impl ObjectListPage { pub fn render(&mut self, f: &mut Frame, area: Rect) { let offset = self.list_state.offset; let selected = self.list_state.selected; - let list_active = matches!(self.view_state, ViewState::Default); let list_items = build_list_items( &self.object_items, @@ -249,7 +248,6 @@ impl ObjectListPage { self.filter_input_state.input(), offset, selected, - list_active, area, &self.ctx.config.ui, &self.ctx.env, @@ -773,7 +771,6 @@ fn build_list_items<'a>( filter: &'a str, offset: usize, selected: usize, - list_active: bool, area: Rect, ui_config: &UiConfig, env: &Environment, @@ -790,7 +787,6 @@ fn build_list_items<'a>( build_list_item( item, idx + offset == selected, - list_active, filter, area, ui_config, @@ -804,7 +800,6 @@ fn build_list_items<'a>( fn build_list_item<'a>( item: &'a ObjectItem, selected: bool, - list_active: bool, filter: &'a str, area: Rect, ui_config: &UiConfig, @@ -832,7 +827,7 @@ fn build_list_item<'a>( ), }; - ListItem::new(line).style(theme.list_item_style(selected, list_active)) + ListItem::new(line).style(theme.list_item_style(selected, true)) } fn build_object_dir_line<'a>( From 95d981e917279efbbd602b57e985d93a7525d95d Mon Sep 17 00:00:00 2001 From: daewon Date: Sat, 7 Mar 2026 11:47:19 +0000 Subject: [PATCH 5/6] Refactor theme config to use Ratatui serde --- Cargo.lock | 63 ++++++- Cargo.toml | 2 +- docs/src/configurations/config-file-format.md | 141 +++++++++++++- src/app.rs | 19 +- src/color.rs | 174 +++++------------- src/config.rs | 88 ++++++--- src/main.rs | 4 +- src/pages/bucket_list.rs | 26 +-- src/pages/help.rs | 6 +- src/pages/initializing.rs | 2 +- src/pages/object_detail.rs | 43 ++--- src/pages/object_list.rs | 89 ++++++--- src/pages/object_preview.rs | 8 +- src/widget/confirm_dialog.rs | 6 +- src/widget/copy_detail_dialog.rs | 8 +- src/widget/header.rs | 12 +- src/widget/input_dialog.rs | 12 +- src/widget/loading_dialog.rs | 6 +- src/widget/scroll_lines.rs | 10 +- src/widget/scroll_list.rs | 10 +- src/widget/sort_list_dialog.rs | 8 +- src/widget/status.rs | 6 +- src/widget/text_preview.rs | 10 +- 23 files changed, 468 insertions(+), 285 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bf791c5..a8d57fb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -786,6 +786,9 @@ name = "bitflags" version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +dependencies = [ + "serde_core", +] [[package]] name = "bitstream-io" @@ -1051,6 +1054,7 @@ dependencies = [ "itoa", "rustversion", "ryu", + "serde", "static_assertions", ] @@ -1177,6 +1181,23 @@ version = "0.8.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22ec99545bb0ed0ea7bb9b8e1e9122ea386ff8a48c0922e43f36d45ab09e0e80" +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags 2.10.0", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix 0.38.34", + "serde", + "signal-hook", + "signal-hook-mio", + "winapi", +] + [[package]] name = "crossterm" version = "0.29.0" @@ -1191,6 +1212,7 @@ dependencies = [ "mio", "parking_lot", "rustix 1.1.2", + "serde", "signal-hook", "signal-hook-mio", "winapi", @@ -2759,6 +2781,12 @@ dependencies = [ "libc", ] +[[package]] +name = "numtoa" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aa2c4e539b869820a2b82e1aef6ff40aa85e65decdd5185e83fb4b1249cd00f" + [[package]] name = "objc2" version = "0.6.0" @@ -3358,8 +3386,10 @@ dependencies = [ "ratatui-core", "ratatui-crossterm", "ratatui-macros", + "ratatui-termion", "ratatui-termwiz", "ratatui-widgets", + "serde", ] [[package]] @@ -3375,6 +3405,7 @@ dependencies = [ "itertools 0.14.0", "kasuari", "lru", + "serde", "strum", "thiserror 2.0.17", "unicode-segmentation", @@ -3389,7 +3420,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "577c9b9f652b4c121fb25c6a391dd06406d3b092ba68827e6d2f09550edc54b3" dependencies = [ "cfg-if", - "crossterm", + "crossterm 0.28.1", + "crossterm 0.29.0", "instability", "ratatui-core", ] @@ -3420,6 +3452,17 @@ dependencies = [ "ratatui-widgets", ] +[[package]] +name = "ratatui-termion" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cade85a8591fbc911e147951422f0d6fd40f4948b271b6216c7dc01838996f8" +dependencies = [ + "instability", + "ratatui-core", + "termion", +] + [[package]] name = "ratatui-termwiz" version = "0.1.0" @@ -3443,6 +3486,7 @@ dependencies = [ "itertools 0.14.0", "line-clipping", "ratatui-core", + "serde", "strum", "time", "unicode-segmentation", @@ -4145,7 +4189,7 @@ dependencies = [ "chrono", "clap", "console", - "crossterm", + "crossterm 0.29.0", "encoding_rs", "futures", "humansize", @@ -4263,6 +4307,17 @@ dependencies = [ "phf_codegen", ] +[[package]] +name = "termion" +version = "4.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f44138a9ae08f0f502f24104d82517ef4da7330c35acd638f1f29d3cd5475ecb" +dependencies = [ + "libc", + "numtoa", + "serde", +] + [[package]] name = "termios" version = "0.3.3" @@ -4297,6 +4352,7 @@ dependencies = [ "pest", "pest_derive", "phf", + "serde", "sha2", "signal-hook", "siphasher", @@ -4773,6 +4829,7 @@ dependencies = [ "atomic", "getrandom 0.3.3", "js-sys", + "serde_core", "wasm-bindgen", ] @@ -4988,6 +5045,7 @@ checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" dependencies = [ "getrandom 0.3.3", "mac_address", + "serde", "sha2", "thiserror 1.0.61", "uuid", @@ -5002,6 +5060,7 @@ dependencies = [ "csscolorparser", "deltae", "lazy_static", + "serde", "wezterm-dynamic", ] diff --git a/Cargo.toml b/Cargo.toml index 5b73a14..965c316 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,7 +39,7 @@ itsuki = "0.2.1" laurier = "0.3.0" once_cell = "1.21.3" open = "5.3.3" -ratatui = "0.30.0" +ratatui = { version = "0.30.0", features = ["serde"] } ratatui-image = { version = "10.0.4", default-features = false, features = [ "crossterm", "image-defaults", diff --git a/docs/src/configurations/config-file-format.md b/docs/src/configurations/config-file-format.md index a987e87..871ef83 100644 --- a/docs/src/configurations/config-file-format.md +++ b/docs/src/configurations/config-file-format.md @@ -24,10 +24,24 @@ date_format = "%Y-%m-%d %H:%M:%S" max_help_width = 100 [ui.theme] -list_selected_bg = "#ffd166" +bg = "reset" +fg = "reset" +divider = "dark_gray" +link = "blue" +list_selected_bg = "#FFD166" list_selected_fg = "black" list_selected_inactive_bg = "dark_gray" list_selected_inactive_fg = "black" +list_filter_match = "red" +detail_selected = "cyan" +dialog_selected = "cyan" +preview_line_number = "dark_gray" +help_key_fg = "yellow" +status_help = "dark_gray" +status_info = "blue" +status_success = "green" +status_warn = "yellow" +status_error = "red" object_dir_bold = true [preview] @@ -126,6 +140,34 @@ The maximum width of the keybindings display area in the help. - type: `usize` - default: `100` +### `ui.theme.bg` + +The default background color used across the UI. + +- type: `string` +- default: `reset` + +### `ui.theme.fg` + +The default foreground color used across the UI. + +- type: `string` +- default: `reset` + +### `ui.theme.divider` + +The color of dividers and separators. + +- type: `string` +- default: `dark_gray` + +### `ui.theme.link` + +The color used for links in the help view. + +- type: `string` +- default: `blue` + ### `ui.theme.list_selected_bg` The background color of the selected row in bucket and object lists. @@ -133,8 +175,9 @@ The background color of the selected row in bucket and object lists. - type: `string` - default: `cyan` -Supports named terminal colors such as `cyan`, `yellow`, `dark_gray`, `bright_white`, and hex -colors such as `#ffd166` or `#abc`. `default` and `reset` are also supported. +Theme colors are deserialized by Ratatui's `Color` serde support. +Supported examples include named colors such as `cyan`, `dark_gray`, `bright_white`, +hex colors such as `#FFD166`, and indexed colors such as `42`. ### `ui.theme.list_selected_fg` @@ -163,9 +206,99 @@ The foreground color of the selected row when the list is inactive. Supports the same color formats as `ui.theme.list_selected_bg`. +### `ui.theme.list_filter_match` + +The color used to highlight matched filter text in lists. + +- type: `string` +- default: `red` + +Supports the same color formats as `ui.theme.list_selected_bg`. + +### `ui.theme.detail_selected` + +The color used for the selected tab and selection accents in the object detail page. + +- type: `string` +- default: `cyan` + +Supports the same color formats as `ui.theme.list_selected_bg`. + +### `ui.theme.dialog_selected` + +The color used to highlight the selected choice in dialogs. + +- type: `string` +- default: `cyan` + +Supports the same color formats as `ui.theme.list_selected_bg`. + +### `ui.theme.preview_line_number` + +The color of line numbers in the text preview. + +- type: `string` +- default: `dark_gray` + +Supports the same color formats as `ui.theme.list_selected_bg`. + +### `ui.theme.help_key_fg` + +The foreground color of key labels in the help view. + +- type: `string` +- default: `yellow` + +Supports the same color formats as `ui.theme.list_selected_bg`. + +### `ui.theme.status_help` + +The color used for help/status hint messages. + +- type: `string` +- default: `dark_gray` + +Supports the same color formats as `ui.theme.list_selected_bg`. + +### `ui.theme.status_info` + +The color used for informational status messages. + +- type: `string` +- default: `blue` + +Supports the same color formats as `ui.theme.list_selected_bg`. + +### `ui.theme.status_success` + +The color used for success status messages. + +- type: `string` +- default: `green` + +Supports the same color formats as `ui.theme.list_selected_bg`. + +### `ui.theme.status_warn` + +The color used for warning status messages. + +- type: `string` +- default: `yellow` + +Supports the same color formats as `ui.theme.list_selected_bg`. + +### `ui.theme.status_error` + +The color used for error status messages. + +- type: `string` +- default: `red` + +Supports the same color formats as `ui.theme.list_selected_bg`. + ### `ui.theme.object_dir_bold` -Whether directory names in the object list should be rendered in bold. +Whether directory names should be rendered in bold. - type: `bool` - default: `true` diff --git a/src/app.rs b/src/app.rs index 04d4b83..9695c32 100644 --- a/src/app.rs +++ b/src/app.rs @@ -15,7 +15,7 @@ use tokio::spawn; use crate::{ client::Client, - color::ColorTheme, + color::Theme, config::Config, environment::Environment, error::{AppError, Result}, @@ -46,12 +46,15 @@ pub enum Notification { pub struct AppContext { pub config: Config, pub env: Environment, - pub theme: ColorTheme, } impl AppContext { - pub fn new(config: Config, env: Environment, theme: ColorTheme) -> AppContext { - AppContext { config, env, theme } + pub fn new(config: Config, env: Environment) -> AppContext { + AppContext { config, env } + } + + pub fn theme(&self) -> &Theme { + &self.config.ui.theme } } @@ -898,13 +901,13 @@ impl App { } fn render_background(&self, f: &mut Frame, area: Rect) { - let block = Block::default().bg(self.ctx.theme.bg); + let block = Block::default().bg(self.ctx.theme().bg); f.render_widget(block, area); } fn render_header(&self, f: &mut Frame, area: Rect) { if !area.is_empty() { - let header = Header::new(self.page_stack.breadcrumb()).theme(&self.ctx.theme); + let header = Header::new(self.page_stack.breadcrumb()).theme(self.ctx.theme()); f.render_widget(header, area); } } @@ -923,13 +926,13 @@ impl App { StatusType::Help(self.page_stack.current_page().short_helps(&self.mapper)) } }; - let status = Status::new(status_type).theme(&self.ctx.theme); + let status = Status::new(status_type).theme(self.ctx.theme()); f.render_widget(status, area); } fn render_loading_dialog(&self, f: &mut Frame) { if self.loading() { - let dialog = LoadingDialog::default().theme(&self.ctx.theme); + let dialog = LoadingDialog::default().theme(self.ctx.theme()); f.render_widget(dialog, f.area()); } } diff --git a/src/color.rs b/src/color.rs index 6716a31..1bf9ecc 100644 --- a/src/color.rs +++ b/src/color.rs @@ -1,92 +1,59 @@ -use anyhow::{anyhow, Context}; -use ratatui::style::{Color, Style}; - -use crate::config::{Config, UiThemeConfig}; - -#[derive(Debug, Clone)] -pub struct ColorTheme { +use ratatui::style::{Color, Modifier, Style}; +use serde::Deserialize; +use smart_default::SmartDefault; +use umbra::optional; + +#[optional(derives = [Deserialize], visibility = pub)] +#[derive(Debug, Clone, SmartDefault)] +pub struct Theme { + #[default(Color::Reset)] pub bg: Color, + #[default(Color::Reset)] pub fg: Color, + #[default(Color::DarkGray)] pub divider: Color, + #[default(Color::Blue)] pub link: Color, + #[default(Color::Cyan)] pub list_selected_bg: Color, + #[default(Color::Black)] pub list_selected_fg: Color, + #[default(Color::DarkGray)] pub list_selected_inactive_bg: Color, + #[default(Color::Black)] pub list_selected_inactive_fg: Color, + #[default(Color::Red)] pub list_filter_match: Color, + #[default(Color::Cyan)] pub detail_selected: Color, + #[default(Color::Cyan)] pub dialog_selected: Color, + #[default(Color::DarkGray)] pub preview_line_number: Color, + #[default(Color::Yellow)] pub help_key_fg: Color, + #[default(Color::DarkGray)] pub status_help: Color, + #[default(Color::Blue)] pub status_info: Color, + #[default(Color::Green)] pub status_success: Color, + #[default(Color::Yellow)] pub status_warn: Color, + #[default(Color::Red)] pub status_error: Color, + #[default = true] + pub object_dir_bold: bool, } -impl Default for ColorTheme { - fn default() -> Self { - Self { - bg: Color::Reset, - fg: Color::Reset, - - divider: Color::DarkGray, - link: Color::Blue, - - list_selected_bg: Color::Cyan, - list_selected_fg: Color::Black, - list_selected_inactive_bg: Color::DarkGray, - list_selected_inactive_fg: Color::Black, - list_filter_match: Color::Red, - - detail_selected: Color::Cyan, - - dialog_selected: Color::Cyan, - - preview_line_number: Color::DarkGray, - - help_key_fg: Color::Yellow, - - status_help: Color::DarkGray, - status_info: Color::Blue, - status_success: Color::Green, - status_warn: Color::Yellow, - status_error: Color::Red, - } - } -} - -impl ColorTheme { - pub fn from_config(config: &Config) -> anyhow::Result { - let mut theme = Self::default(); - theme.apply_ui_theme(&config.ui.theme)?; - Ok(theme) - } - - fn apply_ui_theme(&mut self, ui_theme: &UiThemeConfig) -> anyhow::Result<()> { - self.list_selected_bg = - parse_config_color(&ui_theme.list_selected_bg, "ui.theme.list_selected_bg")?; - self.list_selected_fg = - parse_config_color(&ui_theme.list_selected_fg, "ui.theme.list_selected_fg")?; - self.list_selected_inactive_bg = parse_config_color( - &ui_theme.list_selected_inactive_bg, - "ui.theme.list_selected_inactive_bg", - )?; - self.list_selected_inactive_fg = parse_config_color( - &ui_theme.list_selected_inactive_fg, - "ui.theme.list_selected_inactive_fg", - )?; - Ok(()) - } - +impl Theme { pub fn list_item_style(&self, selected: bool, active: bool) -> Style { if !selected { return Style::default(); @@ -109,60 +76,18 @@ impl ColorTheme { self.list_selected_inactive_fg, ) } -} - -fn list_style(bg: Color, fg: Color) -> Style { - Style::default().bg(bg).fg(fg) -} -fn parse_config_color(value: &str, key: &str) -> anyhow::Result { - parse_color(value).with_context(|| format!("Failed to parse {key}")) -} - -fn parse_color(value: &str) -> anyhow::Result { - let value = value.trim(); - let normalized = value.to_ascii_lowercase().replace(['-', ' '], "_"); - - match normalized.as_str() { - "reset" | "default" => Ok(Color::Reset), - "black" => Ok(Color::Black), - "red" => Ok(Color::Red), - "green" => Ok(Color::Green), - "yellow" => Ok(Color::Yellow), - "blue" => Ok(Color::Blue), - "magenta" => Ok(Color::Magenta), - "cyan" => Ok(Color::Cyan), - "gray" | "grey" => Ok(Color::Gray), - "dark_gray" | "dark_grey" | "bright_black" => Ok(Color::DarkGray), - "light_red" | "bright_red" => Ok(Color::LightRed), - "light_green" | "bright_green" => Ok(Color::LightGreen), - "light_yellow" | "bright_yellow" => Ok(Color::LightYellow), - "light_blue" | "bright_blue" => Ok(Color::LightBlue), - "light_magenta" | "bright_magenta" => Ok(Color::LightMagenta), - "light_cyan" | "bright_cyan" => Ok(Color::LightCyan), - "white" | "light_white" | "bright_white" => Ok(Color::White), - _ => parse_hex_color(value).ok_or_else(|| anyhow!("unknown color: {value}")), + pub fn object_dir_style(&self) -> Style { + let mut style = Style::default(); + if self.object_dir_bold { + style = style.add_modifier(Modifier::BOLD); + } + style } } -fn parse_hex_color(value: &str) -> Option { - let hex = value.strip_prefix('#')?; - - match hex.len() { - 3 => { - let r = u8::from_str_radix(&hex[0..1].repeat(2), 16).ok()?; - let g = u8::from_str_radix(&hex[1..2].repeat(2), 16).ok()?; - let b = u8::from_str_radix(&hex[2..3].repeat(2), 16).ok()?; - Some(Color::Rgb(r, g, b)) - } - 6 => { - let r = u8::from_str_radix(&hex[0..2], 16).ok()?; - let g = u8::from_str_radix(&hex[2..4], 16).ok()?; - let b = u8::from_str_radix(&hex[4..6], 16).ok()?; - Some(Color::Rgb(r, g, b)) - } - _ => None, - } +fn list_style(bg: Color, fg: Color) -> Style { + Style::default().bg(bg).fg(fg) } #[cfg(test)] @@ -170,27 +95,18 @@ mod tests { use super::*; #[test] - fn parse_named_colors() { - assert_eq!(parse_color("cyan").unwrap(), Color::Cyan); - assert_eq!(parse_color("dark-gray").unwrap(), Color::DarkGray); - assert_eq!(parse_color("bright_white").unwrap(), Color::White); - assert_eq!(parse_color("default").unwrap(), Color::Reset); - assert_eq!(parse_color("reset").unwrap(), Color::Reset); + fn list_item_style_falls_back_to_default_for_unselected_items() { + let theme = Theme::default(); + assert_eq!(theme.list_item_style(false, true), Style::default()); + assert_eq!(theme.list_item_style(false, false), Style::default()); } #[test] - fn parse_hex_colors() { + fn object_dir_style_adds_bold_when_enabled() { + let theme = Theme::default(); assert_eq!( - parse_color("#123456").unwrap(), - Color::Rgb(0x12, 0x34, 0x56) + theme.object_dir_style(), + Style::default().add_modifier(Modifier::BOLD) ); - assert_eq!(parse_color("#abc").unwrap(), Color::Rgb(0xaa, 0xbb, 0xcc)); - } - - #[test] - fn list_item_style_falls_back_to_default_for_unselected_items() { - let theme = ColorTheme::default(); - assert_eq!(theme.list_item_style(false, true), Style::default()); - assert_eq!(theme.list_item_style(false, false), Style::default()); } } diff --git a/src/config.rs b/src/config.rs index f18c691..37d6d7f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -4,11 +4,12 @@ use std::{ }; use anyhow::Context; -use ratatui::style::{Modifier, Style}; use serde::Deserialize; use smart_default::SmartDefault; use umbra::optional; +use crate::color::{OptionalTheme, Theme}; + const STU_ROOT_DIR_ENV_VAR: &str = "STU_ROOT_DIR"; const APP_BASE_DIR: &str = ".stu"; @@ -47,7 +48,7 @@ pub struct UiConfig { #[nested] pub help: UiHelpConfig, #[nested] - pub theme: UiThemeConfig, + pub theme: Theme, } #[optional(derives = [Deserialize])] @@ -102,31 +103,6 @@ pub struct UiHelpConfig { pub max_help_width: usize, } -#[optional(derives = [Deserialize])] -#[derive(Debug, Clone, SmartDefault)] -pub struct UiThemeConfig { - #[default = "cyan"] - pub list_selected_bg: String, - #[default = "black"] - pub list_selected_fg: String, - #[default = "dark_gray"] - pub list_selected_inactive_bg: String, - #[default = "black"] - pub list_selected_inactive_fg: String, - #[default = true] - pub object_dir_bold: bool, -} - -impl UiThemeConfig { - pub fn object_dir_style(&self) -> Style { - let mut style = Style::default(); - if self.object_dir_bold { - style = style.add_modifier(Modifier::BOLD); - } - style - } -} - #[optional(derives = [Deserialize])] #[derive(Debug, Clone, SmartDefault)] pub struct PreviewConfig { @@ -236,3 +212,61 @@ impl Config { } } } + +#[cfg(test)] +mod tests { + use ratatui::style::Color; + + use super::*; + + fn parse_config(input: &str) -> Config { + let config: OptionalConfig = toml::from_str(input).unwrap(); + config.into() + } + + #[test] + fn theme_defaults_when_omitted() { + let config = parse_config(""); + + assert_eq!(config.ui.theme.bg, Color::Reset); + assert_eq!(config.ui.theme.link, Color::Blue); + assert_eq!(config.ui.theme.list_selected_bg, Color::Cyan); + assert!(config.ui.theme.object_dir_bold); + } + + #[test] + fn theme_accepts_ratatui_color_formats() { + let config = parse_config( + r##" +[ui.theme] +bg = "reset" +list_selected_bg = "#FFD166" +list_selected_fg = "42" +status_error = "bright-white" +object_dir_bold = false +"##, + ); + + assert_eq!(config.ui.theme.bg, Color::Reset); + assert_eq!( + config.ui.theme.list_selected_bg, + Color::Rgb(0xFF, 0xD1, 0x66) + ); + assert_eq!(config.ui.theme.list_selected_fg, Color::Indexed(42)); + assert_eq!(config.ui.theme.status_error, Color::White); + assert!(!config.ui.theme.object_dir_bold); + assert_eq!(config.ui.theme.dialog_selected, Color::Cyan); + } + + #[test] + fn invalid_theme_color_is_rejected() { + let result = toml::from_str::( + r#" +[ui.theme] +list_selected_bg = "not-a-color" +"#, + ); + + assert!(result.is_err()); + } +} diff --git a/src/main.rs b/src/main.rs index baa6fd2..8ba43a9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -26,7 +26,6 @@ use tracing_subscriber::fmt::time::ChronoLocal; use crate::{ app::{App, AppContext}, client::Client, - color::ColorTheme, config::Config, environment::Environment, keys::UserEventMapper, @@ -97,8 +96,7 @@ async fn main() -> anyhow::Result<()> { let config = Config::load()?; let mapper = UserEventMapper::load()?; let env = Environment::new(config.preview.image, args.fix_dynamic_values_for_test); - let theme = ColorTheme::from_config(&config)?; - let ctx = AppContext::new(config, env, theme); + let ctx = AppContext::new(config, env); initialize_debug_log(&args)?; diff --git a/src/pages/bucket_list.rs b/src/pages/bucket_list.rs index 3435013..db90063 100644 --- a/src/pages/bucket_list.rs +++ b/src/pages/bucket_list.rs @@ -12,7 +12,7 @@ use ratatui::{ use crate::{ app::AppContext, - color::ColorTheme, + color::Theme, event::{AppEventType, Sender}, format::format_size_byte, handle_user_events, handle_user_events_with_default, @@ -230,20 +230,20 @@ impl BucketListPage { &self.bucket_items, &self.view_indices, self.filter_input_state.input(), - &self.ctx.theme, + self.ctx.theme(), offset, selected, area, ); - let list = ScrollList::new(list_items).theme(&self.ctx.theme); + let list = ScrollList::new(list_items).theme(self.ctx.theme()); f.render_stateful_widget(list, area, &mut self.list_state); if let ViewState::FilterDialog = self.view_state { let filter_dialog = InputDialog::default() .title("Filter") .max_width(30) - .theme(&self.ctx.theme); + .theme(self.ctx.theme()); f.render_stateful_widget(filter_dialog, area, &mut self.filter_input_state); let (cursor_x, cursor_y) = self.filter_input_state.cursor(); @@ -252,18 +252,18 @@ impl BucketListPage { if let ViewState::SortDialog = self.view_state { let sort_dialog = - BucketListSortDialog::new(self.sort_dialog_state).theme(&self.ctx.theme); + BucketListSortDialog::new(self.sort_dialog_state).theme(self.ctx.theme()); f.render_widget(sort_dialog, area); } if let ViewState::CopyDetailDialog(state) = &mut self.view_state { - let copy_detail_dialog = CopyDetailDialog::default().theme(&self.ctx.theme); + let copy_detail_dialog = CopyDetailDialog::default().theme(self.ctx.theme()); f.render_stateful_widget(copy_detail_dialog, area, state); } if let ViewState::DownloadConfirmDialog(objs, state, _) = &mut self.view_state { - let message_lines = build_download_confirm_message_lines(objs, &self.ctx.theme); - let download_confirm_dialog = ConfirmDialog::new(message_lines).theme(&self.ctx.theme); + let message_lines = build_download_confirm_message_lines(objs, self.ctx.theme()); + let download_confirm_dialog = ConfirmDialog::new(message_lines).theme(self.ctx.theme()); f.render_stateful_widget(download_confirm_dialog, area, state); } @@ -271,7 +271,7 @@ impl BucketListPage { let save_dialog = InputDialog::default() .title("Save As") .max_width(40) - .theme(&self.ctx.theme); + .theme(self.ctx.theme()); f.render_stateful_widget(save_dialog, area, state); let (cursor_x, cursor_y) = state.cursor(); @@ -364,7 +364,7 @@ impl BucketListPage { ] } }; - build_help_spans(helps, mapper, self.ctx.theme.help_key_fg) + build_help_spans(helps, mapper, self.ctx.theme().help_key_fg) } pub fn short_helps(&self, mapper: &UserEventMapper) -> Vec { @@ -675,7 +675,7 @@ fn build_list_items<'a>( current_items: &'a [BucketItem], view_indices: &'a [usize], filter: &'a str, - theme: &'a ColorTheme, + theme: &'a Theme, offset: usize, selected: usize, area: Rect, @@ -699,7 +699,7 @@ fn build_list_item<'a>( selected: bool, filter: &'a str, width: u16, - theme: &'a ColorTheme, + theme: &'a Theme, ) -> ListItem<'a> { let name_w = (width as usize) - 5 /* border + pad + scroll */; let w = console::measure_text_width(name); @@ -732,7 +732,7 @@ fn build_list_item<'a>( fn build_download_confirm_message_lines<'a>( objs: &[DownloadObjectInfo], - theme: &ColorTheme, + theme: &Theme, ) -> Vec> { let total_size = format_size_byte(objs.iter().map(|obj| obj.size_byte).sum()); let total_count = objs.len(); diff --git a/src/pages/help.rs b/src/pages/help.rs index 6ba9f5f..b015a92 100644 --- a/src/pages/help.rs +++ b/src/pages/help.rs @@ -47,7 +47,7 @@ impl HelpPage { pub fn render(&mut self, f: &mut Frame, area: Rect) { let block = Block::bordered() .padding(Padding::horizontal(1)) - .fg(self.ctx.theme.fg); + .fg(self.ctx.theme().fg); let content_area = block.inner(area); @@ -63,9 +63,9 @@ impl HelpPage { APP_DESCRIPTION, APP_VERSION, APP_REPOSITORY_URL, - self.ctx.theme.link, + self.ctx.theme().link, ); - let divider = Divider::default().color(self.ctx.theme.divider); + let divider = Divider::default().color(self.ctx.theme().divider); let help = Help::new(&self.helps, self.ctx.config.ui.help.max_help_width); f.render_widget(block, area); diff --git a/src/pages/initializing.rs b/src/pages/initializing.rs index 9d99965..7bf523a 100644 --- a/src/pages/initializing.rs +++ b/src/pages/initializing.rs @@ -23,7 +23,7 @@ impl InitializingPage { pub fn handle_key(&mut self, _user_events: Vec, _key_event: KeyEvent) {} pub fn render(&mut self, f: &mut Frame, area: Rect) { - let content = Block::bordered().fg(self.ctx.theme.fg); + let content = Block::bordered().fg(self.ctx.theme().fg); f.render_widget(content, area); } diff --git a/src/pages/object_detail.rs b/src/pages/object_detail.rs index d07408f..d7d3407 100644 --- a/src/pages/object_detail.rs +++ b/src/pages/object_detail.rs @@ -12,7 +12,7 @@ use ratatui::{ use crate::{ app::AppContext, - color::ColorTheme, + color::Theme, config::UiConfig, environment::Environment, event::{AppEventType, Sender}, @@ -202,30 +202,29 @@ impl ObjectDetailPage { offset, selected, chunks[0], - &self.ctx.config.ui, - &self.ctx.theme, + self.ctx.theme(), ); - let list = ScrollList::new(list_items).theme(&self.ctx.theme); + let list = ScrollList::new(list_items).theme(self.ctx.theme()); f.render_stateful_widget(list, chunks[0], &mut self.list_state); - let block = Block::bordered().fg(self.ctx.theme.fg); + let block = Block::bordered().fg(self.ctx.theme().fg); f.render_widget(block, chunks[1]); let chunks = Layout::vertical([Constraint::Length(2), Constraint::Min(0)]) .margin(1) .split(chunks[1]); - let tabs = build_tabs(&self.tab, &self.ctx.theme); + let tabs = build_tabs(&self.tab, self.ctx.theme()); f.render_widget(tabs, chunks[0]); match self.tab { Tab::Detail(ref mut state) => { - let detail = DetailTab::new(&self.ctx.theme); + let detail = DetailTab::new(self.ctx.theme()); f.render_stateful_widget(detail, chunks[1], state); } Tab::Version(ref mut state) => { - let version = VersionTab::new(&self.ctx.theme); + let version = VersionTab::new(self.ctx.theme()); f.render_stateful_widget(version, chunks[1], state); } } @@ -234,7 +233,7 @@ impl ObjectDetailPage { let save_dialog = InputDialog::default() .title("Save As") .max_width(40) - .theme(&self.ctx.theme); + .theme(self.ctx.theme()); f.render_stateful_widget(save_dialog, area, state); let (cursor_x, cursor_y) = state.cursor(); @@ -242,7 +241,7 @@ impl ObjectDetailPage { } if let ViewState::CopyDetailDialog(state) = &mut self.view_state { - let copy_detail_dialog = CopyDetailDialog::default().theme(&self.ctx.theme); + let copy_detail_dialog = CopyDetailDialog::default().theme(self.ctx.theme()); f.render_stateful_widget(copy_detail_dialog, area, state); } } @@ -301,7 +300,7 @@ impl ObjectDetailPage { ] }, }; - build_help_spans(helps, mapper, self.ctx.theme.help_key_fg) + build_help_spans(helps, mapper, self.ctx.theme().help_key_fg) } pub fn short_helps(&self, mapper: &UserEventMapper) -> Vec { @@ -484,8 +483,7 @@ fn build_list_items_from_object_items<'a>( offset: usize, selected: usize, area: Rect, - ui_config: &UiConfig, - theme: &ColorTheme, + theme: &Theme, ) -> Vec> { let show_item_count = (area.height as usize) - 2 /* border */; current_items @@ -493,9 +491,7 @@ fn build_list_items_from_object_items<'a>( .skip(offset) .take(show_item_count) .enumerate() - .map(|(idx, item)| { - build_list_item_from_object_item(idx, item, offset, selected, area, ui_config, theme) - }) + .map(|(idx, item)| build_list_item_from_object_item(idx, item, offset, selected, area, theme)) .collect() } @@ -505,13 +501,12 @@ fn build_list_item_from_object_item<'a>( offset: usize, selected: usize, area: Rect, - ui_config: &UiConfig, - theme: &ColorTheme, + theme: &Theme, ) -> ListItem<'a> { let content = match item { ObjectItem::Dir { name, .. } => { let content = format_dir_item(name, area.width); - let style = ui_config.theme.object_dir_style(); + let style = theme.object_dir_style(); Span::styled(content, style) } ObjectItem::File { name, .. } => { @@ -534,7 +529,7 @@ fn format_file_item(name: &str, width: u16) -> String { format!(" {name: Tabs<'static> { +fn build_tabs(tab: &Tab, theme: &Theme) -> Tabs<'static> { let tabs = vec!["Detail", "Version"]; Tabs::new(tabs) .select(tab.val()) @@ -599,11 +594,11 @@ impl DetailTabState { #[derive(Debug)] struct DetailTab<'a> { - theme: &'a ColorTheme, + theme: &'a Theme, } impl<'a> DetailTab<'a> { - fn new(theme: &'a ColorTheme) -> Self { + fn new(theme: &'a Theme) -> Self { Self { theme } } } @@ -732,7 +727,7 @@ struct VersionTabColor { } impl VersionTabColor { - fn new(theme: &ColorTheme) -> Self { + fn new(theme: &Theme) -> Self { Self { selected: theme.detail_selected, divider: theme.divider, @@ -746,7 +741,7 @@ struct VersionTab { } impl VersionTab { - fn new(theme: &ColorTheme) -> Self { + fn new(theme: &Theme) -> Self { Self { color: VersionTabColor::new(theme), } diff --git a/src/pages/object_list.rs b/src/pages/object_list.rs index 6a24e78..dbcd494 100644 --- a/src/pages/object_list.rs +++ b/src/pages/object_list.rs @@ -13,8 +13,8 @@ use ratatui::{ use crate::{ app::AppContext, - color::ColorTheme, - config::{UiConfig, UiThemeConfig}, + color::Theme, + config::UiConfig, environment::Environment, event::{AppEventType, Sender}, format::{format_datetime, format_size_byte}, @@ -251,17 +251,17 @@ impl ObjectListPage { area, &self.ctx.config.ui, &self.ctx.env, - &self.ctx.theme, + self.ctx.theme(), ); - let list = ScrollList::new(list_items).theme(&self.ctx.theme); + let list = ScrollList::new(list_items).theme(self.ctx.theme()); f.render_stateful_widget(list, area, &mut self.list_state); if let ViewState::FilterDialog = self.view_state { let filter_dialog = InputDialog::default() .title("Filter") .max_width(30) - .theme(&self.ctx.theme); + .theme(self.ctx.theme()); f.render_stateful_widget(filter_dialog, area, &mut self.filter_input_state); let (cursor_x, cursor_y) = self.filter_input_state.cursor(); @@ -270,18 +270,18 @@ impl ObjectListPage { if let ViewState::SortDialog = self.view_state { let sort_dialog = - ObjectListSortDialog::new(self.sort_dialog_state).theme(&self.ctx.theme); + ObjectListSortDialog::new(self.sort_dialog_state).theme(self.ctx.theme()); f.render_widget(sort_dialog, area); } if let ViewState::CopyDetailDialog(state) = &mut self.view_state { - let copy_detail_dialog = CopyDetailDialog::default().theme(&self.ctx.theme); + let copy_detail_dialog = CopyDetailDialog::default().theme(self.ctx.theme()); f.render_stateful_widget(copy_detail_dialog, area, state); } if let ViewState::DownloadConfirmDialog(objs, state, _) = &mut self.view_state { - let message_lines = build_download_confirm_message_lines(objs, &self.ctx.theme); - let download_confirm_dialog = ConfirmDialog::new(message_lines).theme(&self.ctx.theme); + let message_lines = build_download_confirm_message_lines(objs, self.ctx.theme()); + let download_confirm_dialog = ConfirmDialog::new(message_lines).theme(self.ctx.theme()); f.render_stateful_widget(download_confirm_dialog, area, state); } @@ -289,7 +289,7 @@ impl ObjectListPage { let save_dialog = InputDialog::default() .title("Save As") .max_width(40) - .theme(&self.ctx.theme); + .theme(self.ctx.theme()); f.render_stateful_widget(save_dialog, area, state); let (cursor_x, cursor_y) = state.cursor(); @@ -386,7 +386,7 @@ impl ObjectListPage { ] } }; - build_help_spans(helps, mapper, self.ctx.theme.help_key_fg) + build_help_spans(helps, mapper, self.ctx.theme().help_key_fg) } pub fn short_helps(&self, mapper: &UserEventMapper) -> Vec { @@ -774,7 +774,7 @@ fn build_list_items<'a>( area: Rect, ui_config: &UiConfig, env: &Environment, - theme: &ColorTheme, + theme: &Theme, ) -> Vec> { let show_item_count = (area.height as usize) - 2 /* border */; view_indices @@ -804,12 +804,10 @@ fn build_list_item<'a>( area: Rect, ui_config: &UiConfig, env: &Environment, - theme: &ColorTheme, + theme: &Theme, ) -> ListItem<'a> { let line = match item { - ObjectItem::Dir { name, .. } => { - build_object_dir_line(name, filter, area.width, &ui_config.theme, theme) - } + ObjectItem::Dir { name, .. } => build_object_dir_line(name, filter, area.width, theme), ObjectItem::File { name, size_byte, @@ -834,8 +832,7 @@ fn build_object_dir_line<'a>( name: &'a str, filter: &'a str, width: u16, - ui_theme: &UiThemeConfig, - theme: &ColorTheme, + theme: &Theme, ) -> Line<'a> { let name = format!("{name}/"); let name_w = (width as usize) - 2 /* spaces */ - 4 /* border + pad */ - 1 /* slash */; @@ -847,7 +844,7 @@ fn build_object_dir_line<'a>( }; if filter.is_empty() { - let name_span = Span::styled(pad_name, ui_theme.object_dir_style()); + let name_span = Span::styled(pad_name, theme.object_dir_style()); Line::from(vec![" ".into(), name_span, " ".into()]) } else { let i = name.find(filter).unwrap(); @@ -855,7 +852,7 @@ fn build_object_dir_line<'a>( if w > name_w { hm = hm.ellipsis(ELLIPSIS); } - let not_matched_style = ui_theme.object_dir_style(); + let not_matched_style = theme.object_dir_style(); let matched_style = not_matched_style.fg(theme.list_filter_match); let mut spans = hm .matched_range(i, i + filter.len()) @@ -876,7 +873,7 @@ fn build_object_file_line<'a>( width: u16, ui_config: &UiConfig, env: &Environment, - theme: &ColorTheme, + theme: &Theme, ) -> Line<'a> { let size = format_size_byte(size_byte); let date = format_datetime( @@ -930,7 +927,7 @@ fn build_object_file_line<'a>( fn build_download_confirm_message_lines<'a>( objs: &[DownloadObjectInfo], - theme: &ColorTheme, + theme: &Theme, ) -> Vec> { let total_size = format_size_byte(objs.iter().map(|obj| obj.size_byte).sum()); let total_count = objs.len(); @@ -1097,6 +1094,54 @@ mod tests { Ok(()) } + #[tokio::test] + async fn test_render_with_theme_config() -> Result<(), core::convert::Infallible> { + let tx = sender(); + let mut terminal = setup_terminal()?; + + terminal.draw(|f| { + let items = vec![ + object_dir_item("dir1"), + object_dir_item("dir2"), + object_file_item("file1", 1024 + 10, "2024-01-02 13:01:02"), + object_file_item("file2", 1024 * 999, "2023-12-31 09:00:00"), + ]; + let object_key = ObjectKey { + bucket_name: "test-bucket".to_string(), + object_path: vec!["path".to_string(), "to".to_string()], + }; + let mut ctx = AppContext::default(); + ctx.config.ui.theme.list_selected_bg = Color::LightMagenta; + ctx.config.ui.theme.list_selected_fg = Color::Yellow; + ctx.config.ui.theme.object_dir_bold = false; + let mut page = ObjectListPage::new(items, object_key, Rc::new(ctx), tx); + let area = Rect::new(0, 0, 60, 10); + page.render(f, area); + })?; + + #[rustfmt::skip] + let mut expected = Buffer::with_lines([ + "┌─────────────────────────────────────────────────── 1 / 4 ┐", + "│ dir1/ │", + "│ dir2/ │", + "│ file1 2024-01-02 13:01:02 1.01 KiB │", + "│ file2 2023-12-31 09:00:00 999 KiB │", + "│ │", + "│ │", + "│ │", + "│ │", + "└──────────────────────────────────────────────────────────┘", + ]); + set_cells! { expected => + // selected item + (2..58, [1]) => bg: Color::LightMagenta, fg: Color::Yellow, + } + + terminal.backend().assert_buffer(&expected); + + Ok(()) + } + #[tokio::test] async fn test_sort_items() { let ctx = Rc::default(); diff --git a/src/pages/object_preview.rs b/src/pages/object_preview.rs index 9f15103..67a0721 100644 --- a/src/pages/object_preview.rs +++ b/src/pages/object_preview.rs @@ -218,7 +218,7 @@ impl ObjectPreviewPage { self.file_detail.name.as_str(), self.file_version_id.as_deref(), &self.ctx.env, - &self.ctx.theme, + self.ctx.theme(), ); f.render_stateful_widget(preview, area, state); } @@ -236,7 +236,7 @@ impl ObjectPreviewPage { let save_dialog = InputDialog::default() .title("Save As") .max_width(40) - .theme(&self.ctx.theme); + .theme(self.ctx.theme()); f.render_stateful_widget(save_dialog, area, state); let (cursor_x, cursor_y) = state.cursor(); @@ -245,7 +245,7 @@ impl ObjectPreviewPage { if let ViewState::EncodingDialog = &mut self.view_state { let encoding_dialog = - EncodingDialog::new(&self.encoding_dialog_state).theme(&self.ctx.theme); + EncodingDialog::new(&self.encoding_dialog_state).theme(self.ctx.theme()); f.render_widget(encoding_dialog, area); } } @@ -299,7 +299,7 @@ impl ObjectPreviewPage { ] }, }; - build_help_spans(helps, mapper, self.ctx.theme.help_key_fg) + build_help_spans(helps, mapper, self.ctx.theme().help_key_fg) } pub fn short_helps(&self, mapper: &UserEventMapper) -> Vec { diff --git a/src/widget/confirm_dialog.rs b/src/widget/confirm_dialog.rs index 7770e37..ca784c6 100644 --- a/src/widget/confirm_dialog.rs +++ b/src/widget/confirm_dialog.rs @@ -9,7 +9,7 @@ use ratatui::{ }; use crate::{ - color::ColorTheme, + color::Theme, widget::{Dialog, Divider}, }; @@ -46,7 +46,7 @@ struct ConfirmDialogColor { } impl ConfirmDialogColor { - fn new(theme: &ColorTheme) -> ConfirmDialogColor { + fn new(theme: &Theme) -> ConfirmDialogColor { ConfirmDialogColor { bg: theme.bg, block: theme.fg, @@ -72,7 +72,7 @@ impl<'a> ConfirmDialog<'a> { } } - pub fn theme(mut self, theme: &ColorTheme) -> Self { + pub fn theme(mut self, theme: &Theme) -> Self { self.color = ConfirmDialogColor::new(theme); self } diff --git a/src/widget/copy_detail_dialog.rs b/src/widget/copy_detail_dialog.rs index b41b413..8f0a192 100644 --- a/src/widget/copy_detail_dialog.rs +++ b/src/widget/copy_detail_dialog.rs @@ -9,7 +9,7 @@ use ratatui::{ }; use crate::{ - color::ColorTheme, + color::Theme, object::{BucketItem, FileDetail, FileVersion, ObjectItem}, widget::Dialog, }; @@ -281,7 +281,7 @@ struct CopyDetailDialogColor { } impl CopyDetailDialogColor { - fn new(theme: &ColorTheme) -> Self { + fn new(theme: &Theme) -> Self { Self { bg: theme.bg, block: theme.fg, @@ -297,7 +297,7 @@ pub struct CopyDetailDialog { } impl CopyDetailDialog { - pub fn theme(mut self, theme: &ColorTheme) -> Self { + pub fn theme(mut self, theme: &Theme) -> Self { self.color = CopyDetailDialogColor::new(theme); self } @@ -364,7 +364,7 @@ mod tests { #[test] fn test_render_copy_detail_dialog() { let file_detail = file_detail(); - let theme = ColorTheme::default(); + let theme = Theme::default(); let mut state = CopyDetailDialogState::object_detail(file_detail); let copy_detail_dialog = CopyDetailDialog::default().theme(&theme); diff --git a/src/widget/header.rs b/src/widget/header.rs index f547d07..32c1976 100644 --- a/src/widget/header.rs +++ b/src/widget/header.rs @@ -5,7 +5,7 @@ use ratatui::{ widgets::{Block, Padding, Paragraph, Widget}, }; -use crate::{color::ColorTheme, util::prune_strings_to_fit_width}; +use crate::{color::Theme, util::prune_strings_to_fit_width}; #[derive(Debug, Default)] struct HeaderColor { @@ -14,7 +14,7 @@ struct HeaderColor { } impl HeaderColor { - fn new(theme: &ColorTheme) -> HeaderColor { + fn new(theme: &Theme) -> HeaderColor { HeaderColor { block: theme.fg, text: theme.fg, @@ -36,7 +36,7 @@ impl Header { } } - pub fn theme(mut self, theme: &ColorTheme) -> Self { + pub fn theme(mut self, theme: &Theme) -> Self { self.color = HeaderColor::new(theme); self } @@ -101,7 +101,7 @@ mod tests { #[test] fn test_render_header() { - let theme = ColorTheme::default(); + let theme = Theme::default(); let breadcrumb = ["bucket", "key01", "key02", "key03"] .into_iter() .map(|s| s.to_string()) @@ -121,7 +121,7 @@ mod tests { #[test] fn test_render_header_with_ellipsis() { - let theme = ColorTheme::default(); + let theme = Theme::default(); let breadcrumb = ["bucket", "key01", "key02a", "key03"] .into_iter() .map(|s| s.to_string()) @@ -141,7 +141,7 @@ mod tests { #[test] fn test_render_header_empty() { - let theme = ColorTheme::default(); + let theme = Theme::default(); let header = Header::new(vec![]).theme(&theme); let mut buf = Buffer::empty(Rect::new(0, 0, 30 + 4, 3)); header.render(buf.area, &mut buf); diff --git a/src/widget/input_dialog.rs b/src/widget/input_dialog.rs index aa75f1d..e3cc182 100644 --- a/src/widget/input_dialog.rs +++ b/src/widget/input_dialog.rs @@ -8,7 +8,7 @@ use ratatui::{ }; use tui_input::{backend::crossterm::EventHandler, Input}; -use crate::{color::ColorTheme, widget::Dialog}; +use crate::{color::Theme, widget::Dialog}; #[derive(Debug, Default)] pub struct InputDialogState { @@ -59,7 +59,7 @@ struct InputDialogColor { } impl InputDialogColor { - fn new(theme: &ColorTheme) -> InputDialogColor { + fn new(theme: &Theme) -> InputDialogColor { InputDialogColor { bg: theme.bg, block: theme.fg, @@ -86,7 +86,7 @@ impl InputDialog { self } - pub fn theme(mut self, theme: &ColorTheme) -> Self { + pub fn theme(mut self, theme: &Theme) -> Self { self.color = InputDialogColor::new(theme); self } @@ -136,7 +136,7 @@ mod tests { #[test] fn test_render_input_dialog() { - let theme = ColorTheme::default(); + let theme = Theme::default(); let mut state = InputDialogState::default(); let save_dialog = InputDialog::default().theme(&theme); @@ -167,7 +167,7 @@ mod tests { #[test] fn test_render_input_dialog_with_params() { - let theme = ColorTheme::default(); + let theme = Theme::default(); let mut state = InputDialogState::default(); let save_dialog = InputDialog::default() .title("xyz") @@ -200,7 +200,7 @@ mod tests { #[test] fn test_render_input_dialog_with_default_input() { - let theme = ColorTheme::default(); + let theme = Theme::default(); let mut state = InputDialogState::new("xyz".into()); let save_dialog = InputDialog::default().theme(&theme); diff --git a/src/widget/loading_dialog.rs b/src/widget/loading_dialog.rs index ae727c9..c1915f6 100644 --- a/src/widget/loading_dialog.rs +++ b/src/widget/loading_dialog.rs @@ -7,7 +7,7 @@ use ratatui::{ widgets::{Block, BorderType, Padding, Paragraph, Widget}, }; -use crate::{color::ColorTheme, widget::Dialog}; +use crate::{color::Theme, widget::Dialog}; #[derive(Debug, Default)] struct LoadingDialogColor { @@ -17,7 +17,7 @@ struct LoadingDialogColor { } impl LoadingDialogColor { - fn new(theme: &ColorTheme) -> Self { + fn new(theme: &Theme) -> Self { LoadingDialogColor { bg: theme.bg, block: theme.fg, @@ -32,7 +32,7 @@ pub struct LoadingDialog { } impl LoadingDialog { - pub fn theme(mut self, theme: &ColorTheme) -> Self { + pub fn theme(mut self, theme: &Theme) -> Self { self.color = LoadingDialogColor::new(theme); self } diff --git a/src/widget/scroll_lines.rs b/src/widget/scroll_lines.rs index 53fbcac..39fc222 100644 --- a/src/widget/scroll_lines.rs +++ b/src/widget/scroll_lines.rs @@ -6,7 +6,7 @@ use ratatui::{ widgets::{Block, BlockExt, Borders, Padding, Paragraph, StatefulWidget, Widget, Wrap}, }; -use crate::{color::ColorTheme, util::digits}; +use crate::{color::Theme, util::digits}; #[derive(Debug, Default)] enum ScrollEvent { @@ -118,7 +118,7 @@ struct ScrollLinesColor { } impl ScrollLinesColor { - fn new(theme: &ColorTheme) -> Self { + fn new(theme: &Theme) -> Self { Self { block: theme.fg, line_number: theme.preview_line_number, @@ -139,7 +139,7 @@ impl ScrollLines { self } - pub fn theme(mut self, theme: &ColorTheme) -> Self { + pub fn theme(mut self, theme: &Theme) -> Self { self.color = ScrollLinesColor::new(theme); self } @@ -739,7 +739,7 @@ mod tests { } fn render_scroll_lines(state: &mut ScrollLinesState) -> Buffer { - let theme = ColorTheme::default(); + let theme = Theme::default(); let scroll_lines = ScrollLines::default() .block(Block::bordered().title("TITLE")) .theme(&theme); @@ -749,7 +749,7 @@ mod tests { } fn render_scroll_lines_no_block(state: &mut ScrollLinesState) -> Buffer { - let theme = ColorTheme::default(); + let theme = Theme::default(); let scroll_lines = ScrollLines::default().theme(&theme); let mut buf = Buffer::empty(Rect::new(0, 0, 20, 5 + 2)); scroll_lines.render(buf.area, &mut buf, state); diff --git a/src/widget/scroll_list.rs b/src/widget/scroll_list.rs index 363160c..65f8d7a 100644 --- a/src/widget/scroll_list.rs +++ b/src/widget/scroll_list.rs @@ -5,7 +5,7 @@ use ratatui::{ widgets::{Block, List, ListItem, Padding, StatefulWidget, Widget}, }; -use crate::{color::ColorTheme, util::digits}; +use crate::{color::Theme, util::digits}; use crate::widget::ScrollBar; @@ -119,7 +119,7 @@ struct ScrollListColor { } impl ScrollListColor { - fn new(theme: &ColorTheme) -> ScrollListColor { + fn new(theme: &Theme) -> ScrollListColor { ScrollListColor { block: theme.fg, bar: theme.fg, @@ -141,7 +141,7 @@ impl ScrollList<'_> { } } - pub fn theme(mut self, theme: &ColorTheme) -> Self { + pub fn theme(mut self, theme: &Theme) -> Self { self.color = ScrollListColor::new(theme); self } @@ -190,7 +190,7 @@ mod tests { #[test] fn test_render_scroll_list_without_scroll() { - let theme = ColorTheme::default(); + let theme = Theme::default(); let mut state = ScrollListState::new(5); let items: Vec = (1..=5) .map(|i| ListItem::new(vec![Line::from(format!("Item {i}"))])) @@ -297,7 +297,7 @@ mod tests { .skip(state.offset) .take(show_item_count as usize) .collect(); - let theme = ColorTheme::default(); + let theme = Theme::default(); let scroll_list = ScrollList::new(items).theme(&theme); let mut buf = Buffer::empty(Rect::new(0, 0, 20, show_item_count + 2)); scroll_list.render(buf.area, &mut buf, state); diff --git a/src/widget/sort_list_dialog.rs b/src/widget/sort_list_dialog.rs index c3c2cf1..3fb2707 100644 --- a/src/widget/sort_list_dialog.rs +++ b/src/widget/sort_list_dialog.rs @@ -8,7 +8,7 @@ use ratatui::{ widgets::{Block, BorderType, List, ListItem, Padding, Widget}, }; -use crate::{color::ColorTheme, config, widget::Dialog}; +use crate::{color::Theme, config, widget::Dialog}; #[zero_indexed_enum] pub enum BucketListSortType { @@ -87,7 +87,7 @@ impl BucketListSortDialog { } } - pub fn theme(mut self, theme: &ColorTheme) -> Self { + pub fn theme(mut self, theme: &Theme) -> Self { self.color = ListSortDialogColor::new(theme); self } @@ -189,7 +189,7 @@ impl ObjectListSortDialog { } } - pub fn theme(mut self, theme: &ColorTheme) -> Self { + pub fn theme(mut self, theme: &Theme) -> Self { self.color = ListSortDialogColor::new(theme); self } @@ -211,7 +211,7 @@ struct ListSortDialogColor { } impl ListSortDialogColor { - fn new(theme: &ColorTheme) -> ListSortDialogColor { + fn new(theme: &Theme) -> ListSortDialogColor { ListSortDialogColor { bg: theme.bg, block: theme.fg, diff --git a/src/widget/status.rs b/src/widget/status.rs index 3ac4c7b..c6a0501 100644 --- a/src/widget/status.rs +++ b/src/widget/status.rs @@ -7,7 +7,7 @@ use ratatui::{ }; use crate::{ - color::ColorTheme, + color::Theme, help::{prune_spans_to_fit_width, SpansWithPriority}, }; @@ -30,7 +30,7 @@ struct StatusColor { } impl StatusColor { - fn new(theme: &ColorTheme) -> Self { + fn new(theme: &Theme) -> Self { StatusColor { help: theme.status_help, info: theme.status_info, @@ -55,7 +55,7 @@ impl Status { } } - pub fn theme(mut self, theme: &ColorTheme) -> Self { + pub fn theme(mut self, theme: &Theme) -> Self { self.color = StatusColor::new(theme); self } diff --git a/src/widget/text_preview.rs b/src/widget/text_preview.rs index 2c3e05f..5ea7048 100644 --- a/src/widget/text_preview.rs +++ b/src/widget/text_preview.rs @@ -20,7 +20,7 @@ use syntect::{ }; use crate::{ - color::ColorTheme, + color::Theme, config::Config, environment::Environment, format::format_version, @@ -306,7 +306,7 @@ struct EncodingDialogColor { } impl EncodingDialogColor { - fn new(theme: &ColorTheme) -> Self { + fn new(theme: &Theme) -> Self { Self { bg: theme.bg, block: theme.fg, @@ -332,7 +332,7 @@ impl<'a> EncodingDialog<'a> { } } - pub fn theme(mut self, theme: &ColorTheme) -> Self { + pub fn theme(mut self, theme: &Theme) -> Self { self.color = EncodingDialogColor::new(theme); self } @@ -501,7 +501,7 @@ pub struct TextPreview<'a> { file_version_id: Option<&'a str>, env: &'a Environment, - theme: &'a ColorTheme, + theme: &'a Theme, } impl<'a> TextPreview<'a> { @@ -509,7 +509,7 @@ impl<'a> TextPreview<'a> { file_name: &'a str, file_version_id: Option<&'a str>, env: &'a Environment, - theme: &'a ColorTheme, + theme: &'a Theme, ) -> Self { Self { file_name, From 2a668a535677372f48aa8d71bb1f81cf6ffcd55b Mon Sep 17 00:00:00 2001 From: daewon Date: Sun, 8 Mar 2026 04:47:41 +0000 Subject: [PATCH 6/6] Format object detail list builder --- src/pages/object_detail.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/pages/object_detail.rs b/src/pages/object_detail.rs index d7d3407..0f12e5e 100644 --- a/src/pages/object_detail.rs +++ b/src/pages/object_detail.rs @@ -491,7 +491,9 @@ fn build_list_items_from_object_items<'a>( .skip(offset) .take(show_item_count) .enumerate() - .map(|(idx, item)| build_list_item_from_object_item(idx, item, offset, selected, area, theme)) + .map(|(idx, item)| { + build_list_item_from_object_item(idx, item, offset, selected, area, theme) + }) .collect() }