aboutsummaryrefslogtreecommitdiff
path: root/src/ui/sidebar.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/ui/sidebar.rs')
-rw-r--r--src/ui/sidebar.rs267
1 files changed, 234 insertions, 33 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(),