diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-16 03:19:52 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-16 03:21:36 +0300 |
| commit | b16465afbbf8e4e959076fceb7e84377f2ecc379 (patch) | |
| tree | 254fb187610adfa41738f6522dd236b3df4f2138 /src/ui | |
| parent | 947328fa79fcefd2fa8087de014168d46a641312 (diff) | |
feat: add configurable sidebar token styles
Diffstat (limited to 'src/ui')
| -rw-r--r-- | src/ui/sidebar.rs | 267 | ||||
| -rw-r--r-- | src/ui/sidebar/tokens.rs | 172 |
2 files changed, 345 insertions, 94 deletions
diff --git a/src/ui/sidebar.rs b/src/ui/sidebar.rs index ebe7d0a1..f89b6e6d 100644 --- a/src/ui/sidebar.rs +++ b/src/ui/sidebar.rs @@ -8,7 +8,7 @@ use ratatui::{ Frame, }; -use self::tokens::{ResolvedToken, SpaceTokenContext}; +use self::tokens::{ResolvedToken, ResolvedTokenKind, SpaceTokenContext}; use super::scrollbar::{render_scrollbar, should_show_scrollbar}; use super::status::{agent_icon, state_dot, state_label, state_label_color}; use super::text::{display_width, display_width_u16, truncate_end}; @@ -905,9 +905,9 @@ fn resolved_token_spans( ) -> Vec<Span<'static>> { let fixed_widths = resolved .iter() - .map(|token| match token { - ResolvedToken::StateIcon => display_width(state_icon.0), - ResolvedToken::GitStatus { ahead, behind } => { + .map(|token| match &token.kind { + ResolvedTokenKind::StateIcon => display_width(state_icon.0), + ResolvedTokenKind::GitStatus { ahead, behind } => { usize::from(*ahead > 0) * display_width(&format!("↑{ahead}")) + usize::from(*behind > 0) * display_width(&format!("↓{behind}")) + usize::from(*ahead > 0 && *behind > 0) @@ -917,15 +917,15 @@ fn resolved_token_spans( .collect::<Vec<_>>(); let flexible_widths = resolved .iter() - .map(|token| match token { - ResolvedToken::StateText(text) - | ResolvedToken::Workspace(text) - | ResolvedToken::Tab(text) - | ResolvedToken::Pane(text) - | ResolvedToken::Agent(text) - | ResolvedToken::TerminalTitle(text) - | ResolvedToken::Branch(text) - | ResolvedToken::Custom(text) => display_width(text), + .map(|token| match &token.kind { + ResolvedTokenKind::StateText(text) + | ResolvedTokenKind::Workspace(text) + | ResolvedTokenKind::Tab(text) + | ResolvedTokenKind::Pane(text) + | ResolvedTokenKind::Agent(text) + | ResolvedTokenKind::TerminalTitle(text) + | ResolvedTokenKind::Branch(text) + | ResolvedTokenKind::Custom(text) => display_width(text), _ => 0, }) .collect::<Vec<_>>(); @@ -1010,55 +1010,58 @@ fn resolved_token_spans( Style::default().fg(p.overlay0).add_modifier(Modifier::DIM), )); } - match token { - ResolvedToken::StateIcon => { - spans.push(Span::styled(state_icon.0.to_string(), state_icon.1)); - } - ResolvedToken::StateText(text) => { + match &token.kind { + ResolvedTokenKind::StateIcon => { spans.push(Span::styled( - truncate_end(text, budgets[index]), - state_text_style, + state_icon.0.to_string(), + apply_token_style(state_icon.1, token.style), )); } - ResolvedToken::Workspace(text) => { + ResolvedTokenKind::StateText(text) => { spans.push(Span::styled( truncate_end(text, budgets[index]), - workspace_style, + apply_token_style(state_text_style, token.style), )); } - ResolvedToken::Tab(text) | ResolvedToken::Pane(text) | ResolvedToken::Agent(text) => { + ResolvedTokenKind::Workspace(text) => { spans.push(Span::styled( truncate_end(text, budgets[index]), - secondary_style, + apply_token_style(workspace_style, token.style), )); } - ResolvedToken::Branch(text) => { + ResolvedTokenKind::Tab(text) + | ResolvedTokenKind::Pane(text) + | ResolvedTokenKind::Agent(text) + | ResolvedTokenKind::Branch(text) => { spans.push(Span::styled( truncate_end(text, budgets[index]), - secondary_style, + apply_token_style(secondary_style, token.style), )); } - ResolvedToken::GitStatus { ahead, behind } => { + ResolvedTokenKind::GitStatus { ahead, behind } => { if *ahead > 0 { spans.push(Span::styled( format!("↑{ahead}"), - Style::default().fg(p.green), + apply_token_style(Style::default().fg(p.green), token.style), )); } if *ahead > 0 && *behind > 0 { - spans.push(Span::raw(" ")); + spans.push(Span::styled( + " ", + apply_token_style(Style::default(), token.style), + )); } if *behind > 0 { spans.push(Span::styled( format!("↓{behind}"), - Style::default().fg(p.red), + apply_token_style(Style::default().fg(p.red), token.style), )); } } - ResolvedToken::TerminalTitle(text) | ResolvedToken::Custom(text) => { + ResolvedTokenKind::TerminalTitle(text) | ResolvedTokenKind::Custom(text) => { spans.push(Span::styled( truncate_end(text, budgets[index]), - custom_style, + apply_token_style(custom_style, token.style), )); } } @@ -1066,6 +1069,27 @@ fn resolved_token_spans( spans } +fn apply_token_style(mut style: Style, patch: crate::config::SidebarTokenStyle) -> Style { + if let Some(fg) = patch.fg { + style = style.fg(fg.ratatui()); + } + if let Some(bold) = patch.bold { + style = if bold { + style.add_modifier(Modifier::BOLD) + } else { + style.remove_modifier(Modifier::BOLD) + }; + } + if let Some(dim) = patch.dim { + style = if dim { + style.add_modifier(Modifier::DIM) + } else { + style.remove_modifier(Modifier::DIM) + }; + } + style +} + fn render_workspace_list( app: &AppState, terminal_runtimes: &TerminalRuntimeRegistry, @@ -1429,6 +1453,17 @@ mod tests { .to_string() } + fn find_symbol_x(buffer: &ratatui::buffer::Buffer, row: u16, width: u16, symbol: &str) -> u16 { + (0..width) + .find(|x| buffer[(*x, row)].symbol() == symbol) + .unwrap_or_else(|| { + panic!( + "missing symbol {symbol:?} in row {}", + row_text(buffer, row, width) + ) + }) + } + #[test] fn default_agent_rows_remove_redundant_state_text() { let mut app = crate::app::state::AppState::test_new(); @@ -1436,6 +1471,7 @@ mod tests { let pane_id = workspace.tabs[0].root_pane; app.workspaces = vec![workspace]; app.ensure_test_terminals(); + app.active = Some(0); let terminal_id = app.workspaces[0].tabs[0].panes[&pane_id] .attached_terminal_id .clone(); @@ -1458,6 +1494,169 @@ mod tests { assert_eq!(second, " pi"); assert!(!first.contains("working")); assert!(!second.contains("working")); + + let workspace_x = find_symbol_x(buffer, body.y, body.width, "o"); + let workspace_style = buffer[(workspace_x, body.y)].style(); + assert_eq!(workspace_style.fg, Some(app.palette.text)); + assert!(workspace_style.add_modifier.contains(Modifier::BOLD)); + assert!(!workspace_style.add_modifier.contains(Modifier::DIM)); + assert_eq!(workspace_style.bg, Some(app.palette.surface_dim)); + + let agent_x = find_symbol_x(buffer, body.y + 1, body.width, "p"); + let agent_style = buffer[(agent_x, body.y + 1)].style(); + assert_eq!(agent_style.fg, Some(app.palette.overlay0)); + assert!(agent_style.add_modifier.contains(Modifier::DIM)); + assert!(!agent_style.add_modifier.contains(Modifier::BOLD)); + assert_eq!(agent_style.bg, Some(app.palette.surface_dim)); + } + + #[test] + fn occurrence_false_removes_default_workspace_bold_and_agent_dim() { + let config: crate::config::Config = toml::from_str( + r##" +[ui.sidebar.agents] +rows = [[{ token = "workspace", bold = false }, { token = "agent", dim = false }]] +"##, + ) + .unwrap(); + let mut app = crate::app::state::AppState::test_new(); + app.sidebar_agents = config.ui.sidebar.agents; + let workspace = Workspace::test_new("one"); + let pane_id = workspace.tabs[0].root_pane; + app.workspaces = vec![workspace]; + app.ensure_test_terminals(); + app.active = Some(0); + let terminal_id = app.workspaces[0].tabs[0].panes[&pane_id] + .attached_terminal_id + .clone(); + app.terminals.get_mut(&terminal_id).unwrap().detected_agent = Some(Agent::Pi); + + let area = Rect::new(0, 0, 26, 20); + let mut terminal = Terminal::new(TestBackend::new(26, 20)).unwrap(); + terminal + .draw(|frame| render_sidebar(&app, &TerminalRuntimeRegistry::new(), frame, area)) + .unwrap(); + let (_, agent_area) = expanded_sidebar_sections(area, app.sidebar_section_split); + let body = agent_panel_body_rect(agent_area, false); + let buffer = terminal.backend().buffer(); + let workspace = buffer[(find_symbol_x(buffer, body.y, body.width, "o"), body.y)].style(); + let agent = buffer[(find_symbol_x(buffer, body.y, body.width, "p"), body.y)].style(); + + assert_eq!(workspace.fg, Some(app.palette.text)); + assert!(!workspace.add_modifier.contains(Modifier::BOLD)); + assert_eq!(agent.fg, Some(app.palette.overlay0)); + assert!(!agent.add_modifier.contains(Modifier::DIM)); + } + + #[test] + fn default_space_workspace_style_tracks_active_state() { + let mut app = crate::app::state::AppState::test_new(); + app.workspaces = vec![Workspace::test_new("one"), Workspace::test_new("two")]; + app.active = Some(0); + app.mode = Mode::Terminal; + let area = Rect::new(0, 0, 26, 20); + app.view.workspace_card_areas = compute_workspace_card_areas(&app, area); + let first_row = app.view.workspace_card_areas[0].rect.y; + let second_row = app.view.workspace_card_areas[1].rect.y; + let mut terminal = Terminal::new(TestBackend::new(26, 20)).unwrap(); + terminal + .draw(|frame| render_sidebar(&app, &TerminalRuntimeRegistry::new(), frame, area)) + .unwrap(); + let buffer = terminal.backend().buffer(); + + let active = buffer[(find_symbol_x(buffer, first_row, 25, "o"), first_row)].style(); + assert_eq!(active.fg, Some(app.palette.text)); + assert!(active.add_modifier.contains(Modifier::BOLD)); + assert!(!active.add_modifier.contains(Modifier::DIM)); + assert_eq!(active.bg, Some(app.palette.surface_dim)); + + let inactive = buffer[(find_symbol_x(buffer, second_row, 25, "t"), second_row)].style(); + assert_eq!(inactive.fg, Some(app.palette.subtext0)); + assert!(!inactive + .add_modifier + .intersects(Modifier::BOLD | Modifier::DIM)); + assert_eq!(inactive.bg, Some(ratatui::style::Color::Reset)); + } + + #[test] + fn space_occurrence_style_applies_without_styling_separator() { + let config: crate::config::Config = toml::from_str( + r##" +[ui.sidebar.spaces] +rows = [[{ token = "$hype", fg = "#abcdef", bold = true, dim = false }, "workspace"]] +"##, + ) + .unwrap(); + let mut app = crate::app::state::AppState::test_new(); + app.sidebar_spaces = config.ui.sidebar.spaces; + app.workspaces = vec![Workspace::test_new("one")]; + app.active = Some(0); + app.mode = Mode::Terminal; + app.workspaces[0].metadata_tokens.patch( + std::collections::HashMap::from([("hype".into(), Some("HI".into()))]), + None, + std::time::Instant::now(), + ); + + let area = Rect::new(0, 0, 26, 20); + app.view.workspace_card_areas = compute_workspace_card_areas(&app, area); + let row = app.view.workspace_card_areas[0].rect.y; + let mut terminal = Terminal::new(TestBackend::new(26, 20)).unwrap(); + terminal + .draw(|frame| render_sidebar(&app, &TerminalRuntimeRegistry::new(), frame, area)) + .unwrap(); + let buffer = terminal.backend().buffer(); + let h = buffer[(find_symbol_x(buffer, row, 25, "H"), row)].style(); + let i = buffer[(find_symbol_x(buffer, row, 25, "I"), row)].style(); + let separator = buffer[(find_symbol_x(buffer, row, 25, "·"), row)].style(); + + for style in [h, i] { + assert_eq!(style.fg, Some(ratatui::style::Color::Rgb(0xab, 0xcd, 0xef))); + assert!(style.add_modifier.contains(Modifier::BOLD)); + assert!(!style.add_modifier.contains(Modifier::DIM)); + assert_eq!(style.bg, Some(app.palette.surface_dim)); + } + assert_eq!(separator.fg, Some(app.palette.overlay0)); + assert!(separator.add_modifier.contains(Modifier::DIM)); + assert!(!separator.add_modifier.contains(Modifier::BOLD)); + assert_eq!(separator.bg, Some(app.palette.surface_dim)); + } + + #[test] + fn occurrence_foreground_flattens_composite_git_status_colors() { + let config: crate::config::Config = toml::from_str( + r##"[ui.sidebar.spaces] +rows = [[{ token = "git_status", fg = "#123456" }]] +"##, + ) + .unwrap(); + let spans = resolved_token_spans( + &[ResolvedToken { + kind: ResolvedTokenKind::GitStatus { + ahead: 2, + behind: 1, + }, + style: config.ui.sidebar.spaces.rows[0][0].parts().1, + }], + ("", Style::default()), + Style::default(), + Style::default(), + Style::default(), + Style::default(), + &crate::app::state::AppState::test_new().palette, + 20, + ); + + assert_eq!( + spans + .iter() + .map(|span| span.content.as_ref()) + .collect::<String>(), + "↑2 ↓1" + ); + assert!(spans + .iter() + .all(|span| { span.style.fg == Some(ratatui::style::Color::Rgb(0x12, 0x34, 0x56)) })); } #[test] @@ -1547,7 +1746,9 @@ mod tests { assert!(rendered.contains('修') && rendered.contains('复')); let spans = resolved_token_spans( - &[ResolvedToken::TerminalTitle("修复🙂标题很长".into())], + &[ResolvedToken::unstyled(ResolvedTokenKind::TerminalTitle( + "修复🙂标题很长".into(), + ))], ("", Style::default()), Style::default(), Style::default(), diff --git a/src/ui/sidebar/tokens.rs b/src/ui/sidebar/tokens.rs index 5c003617..22e13e86 100644 --- a/src/ui/sidebar/tokens.rs +++ b/src/ui/sidebar/tokens.rs @@ -1,11 +1,17 @@ +use super::AgentPanelEntry; use crate::config::{ - AgentSidebarToken, AgentsSidebarConfig, SpaceSidebarToken, SpacesSidebarConfig, + AgentSidebarToken, AgentsSidebarConfig, SidebarTokenStyle, SpaceSidebarToken, + SpacesSidebarConfig, }; -use super::AgentPanelEntry; +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct ResolvedToken { + pub kind: ResolvedTokenKind, + pub style: SidebarTokenStyle, +} #[derive(Debug, Clone, PartialEq, Eq)] -pub(super) enum ResolvedToken { +pub(super) enum ResolvedTokenKind { StateIcon, StateText(String), Workspace(String), @@ -18,6 +24,17 @@ pub(super) enum ResolvedToken { Custom(String), } +impl ResolvedToken { + fn new(kind: ResolvedTokenKind, style: SidebarTokenStyle) -> Self { + Self { kind, style } + } + + #[cfg(test)] + pub(super) fn unstyled(kind: ResolvedTokenKind) -> Self { + Self::new(kind, SidebarTokenStyle::default()) + } +} + pub(super) fn agent_rows( config: &AgentsSidebarConfig, entry: &AgentPanelEntry, @@ -29,30 +46,41 @@ pub(super) fn agent_rows( .filter_map(|row| { let resolved = row .iter() - .filter_map(|token| match token { - AgentSidebarToken::StateIcon => Some(ResolvedToken::StateIcon), - AgentSidebarToken::StateText => { - Some(ResolvedToken::StateText(state_text.to_string())) - } - AgentSidebarToken::Workspace => { - Some(ResolvedToken::Workspace(entry.primary_label.clone())) - } - AgentSidebarToken::Tab => { - entry.primary_tab_label.clone().map(ResolvedToken::Tab) - } - AgentSidebarToken::Pane => entry.pane_label.clone().map(ResolvedToken::Pane), - AgentSidebarToken::Agent => entry.agent_label.clone().map(ResolvedToken::Agent), - AgentSidebarToken::TerminalTitle => entry - .terminal_title - .clone() - .map(ResolvedToken::TerminalTitle), - AgentSidebarToken::TerminalTitleStripped => entry - .terminal_title_stripped - .clone() - .map(ResolvedToken::TerminalTitle), - AgentSidebarToken::Custom(name) => { - entry.tokens.get(name).cloned().map(ResolvedToken::Custom) - } + .filter_map(|configured| { + let (token, style) = configured.parts(); + let kind = match token { + AgentSidebarToken::StateIcon => Some(ResolvedTokenKind::StateIcon), + AgentSidebarToken::StateText => { + Some(ResolvedTokenKind::StateText(state_text.to_string())) + } + AgentSidebarToken::Workspace => { + Some(ResolvedTokenKind::Workspace(entry.primary_label.clone())) + } + AgentSidebarToken::Tab => { + entry.primary_tab_label.clone().map(ResolvedTokenKind::Tab) + } + AgentSidebarToken::Pane => { + entry.pane_label.clone().map(ResolvedTokenKind::Pane) + } + AgentSidebarToken::Agent => { + entry.agent_label.clone().map(ResolvedTokenKind::Agent) + } + AgentSidebarToken::TerminalTitle => entry + .terminal_title + .clone() + .map(ResolvedTokenKind::TerminalTitle), + AgentSidebarToken::TerminalTitleStripped => entry + .terminal_title_stripped + .clone() + .map(ResolvedTokenKind::TerminalTitle), + AgentSidebarToken::Custom(name) => entry + .tokens + .get(name) + .cloned() + .map(ResolvedTokenKind::Custom), + AgentSidebarToken::Styled { .. } => None, + }?; + Some(ResolvedToken::new(kind, style)) }) .collect::<Vec<_>>(); (!resolved.is_empty()).then_some(resolved) @@ -79,26 +107,33 @@ pub(super) fn space_rows( .filter_map(|row| { let resolved = row .iter() - .filter_map(|token| match token { - SpaceSidebarToken::StateIcon => Some(ResolvedToken::StateIcon), - SpaceSidebarToken::StateText => { - Some(ResolvedToken::StateText(context.state_text.to_string())) - } - SpaceSidebarToken::Workspace => { - Some(ResolvedToken::Workspace(context.workspace.to_string())) - } - SpaceSidebarToken::Branch if !context.suppress_git_details => context - .branch - .map(|branch| ResolvedToken::Branch(branch.to_string())), - SpaceSidebarToken::Branch => None, - SpaceSidebarToken::GitStatus if !context.suppress_git_details => context - .ahead_behind - .filter(|(ahead, behind)| *ahead > 0 || *behind > 0) - .map(|(ahead, behind)| ResolvedToken::GitStatus { ahead, behind }), - SpaceSidebarToken::GitStatus => None, - SpaceSidebarToken::Custom(name) => { - context.tokens.get(name).cloned().map(ResolvedToken::Custom) - } + .filter_map(|configured| { + let (token, style) = configured.parts(); + let kind = match token { + SpaceSidebarToken::StateIcon => Some(ResolvedTokenKind::StateIcon), + SpaceSidebarToken::StateText => { + Some(ResolvedTokenKind::StateText(context.state_text.to_string())) + } + SpaceSidebarToken::Workspace => { + Some(ResolvedTokenKind::Workspace(context.workspace.to_string())) + } + SpaceSidebarToken::Branch if !context.suppress_git_details => context + .branch + .map(|branch| ResolvedTokenKind::Branch(branch.to_string())), + SpaceSidebarToken::Branch => None, + SpaceSidebarToken::GitStatus if !context.suppress_git_details => context + .ahead_behind + .filter(|(ahead, behind)| *ahead > 0 || *behind > 0) + .map(|(ahead, behind)| ResolvedTokenKind::GitStatus { ahead, behind }), + SpaceSidebarToken::GitStatus => None, + SpaceSidebarToken::Custom(name) => context + .tokens + .get(name) + .cloned() + .map(ResolvedTokenKind::Custom), + SpaceSidebarToken::Styled { .. } => None, + }?; + Some(ResolvedToken::new(kind, style)) }) .collect::<Vec<_>>(); (!resolved.is_empty()).then_some(resolved) @@ -107,8 +142,8 @@ pub(super) fn space_rows( } pub(super) fn separator(previous: &ResolvedToken, current: &ResolvedToken) -> &'static str { - if matches!(previous, ResolvedToken::StateIcon) - || matches!(current, ResolvedToken::GitStatus { .. }) + if matches!(previous.kind, ResolvedTokenKind::StateIcon) + || matches!(current.kind, ResolvedTokenKind::GitStatus { .. }) { " " } else { @@ -119,6 +154,7 @@ pub(super) fn separator(previous: &ResolvedToken, current: &ResolvedToken) -> &' #[cfg(test)] mod tests { use super::*; + use crate::config::{AgentSidebarToken, SpaceSidebarToken}; use crate::detect::AgentState; fn entry() -> AgentPanelEntry { @@ -159,8 +195,16 @@ mod tests { let rows = agent_rows(&config, &entry, "working"); assert_eq!(rows.len(), 2); - assert_eq!(rows[0], vec![ResolvedToken::StateIcon]); - assert_eq!(rows[1], vec![ResolvedToken::Agent("pi".into())]); + assert_eq!( + rows[0], + vec![ResolvedToken::unstyled(ResolvedTokenKind::StateIcon)] + ); + assert_eq!( + rows[1], + vec![ResolvedToken::unstyled(ResolvedTokenKind::Agent( + "pi".into() + ))] + ); } #[test] @@ -180,8 +224,8 @@ mod tests { assert_eq!( agent_rows(&config, &entry, "deep in the mines"), vec![vec![ - ResolvedToken::StateText("deep in the mines".into()), - ResolvedToken::Custom("reviewing auth".into()), + ResolvedToken::unstyled(ResolvedTokenKind::StateText("deep in the mines".into())), + ResolvedToken::unstyled(ResolvedTokenKind::Custom("reviewing auth".into())), ]] ); } @@ -206,9 +250,9 @@ mod tests { assert_eq!( agent_rows(&config, &entry, "working"), vec![vec![ - ResolvedToken::TerminalTitle("⠋ raw title".into()), - ResolvedToken::TerminalTitle("raw title".into()), - ResolvedToken::Custom("custom title".into()), + ResolvedToken::unstyled(ResolvedTokenKind::TerminalTitle("⠋ raw title".into())), + ResolvedToken::unstyled(ResolvedTokenKind::TerminalTitle("raw title".into())), + ResolvedToken::unstyled(ResolvedTokenKind::Custom("custom title".into())), ]] ); } @@ -227,13 +271,17 @@ mod tests { assert_eq!( agent_rows(&config, &pi, "working"), - vec![vec![ResolvedToken::Agent("renamed pi".into())]] + vec![vec![ResolvedToken::unstyled(ResolvedTokenKind::Agent( + "renamed pi".into() + ))]] ); pi.agent = None; assert_eq!( agent_rows(&config, &pi, "working"), - vec![vec![ResolvedToken::Workspace("repo".into())]] + vec![vec![ResolvedToken::unstyled(ResolvedTokenKind::Workspace( + "repo".into() + ))]] ); } @@ -254,8 +302,8 @@ mod tests { }, ), vec![vec![ - ResolvedToken::StateIcon, - ResolvedToken::Workspace("feature".into()), + ResolvedToken::unstyled(ResolvedTokenKind::StateIcon), + ResolvedToken::unstyled(ResolvedTokenKind::Workspace("feature".into())), ]] ); } @@ -280,7 +328,9 @@ mod tests { suppress_git_details: false, }, ), - vec![vec![ResolvedToken::Custom("2 changes".into())]] + vec![vec![ResolvedToken::unstyled(ResolvedTokenKind::Custom( + "2 changes".into() + ))]] ); } } |
