aboutsummaryrefslogtreecommitdiff
path: root/src/ui
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-07-14 20:40:11 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-07-14 20:41:20 +0300
commit5b91dae12ffa09f5842a0c852ef06299c31fe342 (patch)
tree86562e2fb20207f69998cbcc931c570901b99892 /src/ui
parentb0d46fb9bc2a3e7fb58864939e2580b8a9a2f1bc (diff)
feat: configure sidebar entry gaps
Diffstat (limited to 'src/ui')
-rw-r--r--src/ui/sidebar.rs177
-rw-r--r--src/ui/sidebar/tokens.rs9
2 files changed, 157 insertions, 29 deletions
diff --git a/src/ui/sidebar.rs b/src/ui/sidebar.rs
index 68e5549d..ebe7d0a1 100644
--- a/src/ui/sidebar.rs
+++ b/src/ui/sidebar.rs
@@ -218,11 +218,19 @@ fn workspace_row_height_in_body(
workspace_row_height(app, workspace, indented).min(body_height)
}
-fn workspace_entry_gap(entries: &[WorkspaceListEntry], entry_idx: usize, indented: bool) -> u16 {
- u16::from(
- entry_idx + 1 < entries.len()
- && !(indented && next_entry_is_indented_workspace(entries, entry_idx)),
- )
+fn workspace_entry_gap(
+ app: &AppState,
+ entries: &[WorkspaceListEntry],
+ entry_idx: usize,
+ indented: bool,
+) -> u16 {
+ if entry_idx + 1 < entries.len()
+ && !(indented && next_entry_is_indented_workspace(entries, entry_idx))
+ {
+ app.sidebar_spaces.row_gap
+ } else {
+ 0
+ }
}
fn workspace_attention_priority(state: AgentState, seen: bool) -> u8 {
@@ -456,7 +464,7 @@ fn workspace_list_visible_count(app: &AppState, area: Rect, scroll: usize) -> us
};
(
workspace_row_height_in_body(app, ws, *indented, body.height),
- workspace_entry_gap(&entries, entry_idx, *indented),
+ workspace_entry_gap(app, &entries, entry_idx, *indented),
)
}
};
@@ -465,9 +473,7 @@ fn workspace_list_visible_count(app: &AppState, area: Rect, scroll: usize) -> us
}
used_rows = used_rows.saturating_add(row_height);
visible += 1;
- if gap > 0 && used_rows < body.height {
- used_rows = used_rows.saturating_add(1);
- }
+ used_rows = used_rows.saturating_add(gap).min(body.height);
}
visible
}
@@ -482,7 +488,7 @@ fn workspace_list_bottom_start(app: &AppState, area: Rect) -> usize {
let Some(workspace) = app.workspaces.get(*ws_idx) else {
continue;
};
- let gap = workspace_entry_gap(&entries, entry_idx, *indented);
+ let gap = workspace_entry_gap(app, &entries, entry_idx, *indented);
let needed = workspace_row_height_in_body(app, workspace, *indented, body.height)
.saturating_add(gap);
if used_rows.saturating_add(needed) > body.height {
@@ -552,6 +558,14 @@ pub(crate) fn agent_entry_height_in_body(
.min(body_height)
}
+pub(crate) fn agent_entry_gap(app: &AppState, entry_idx: usize, entry_count: usize) -> u16 {
+ if entry_idx + 1 < entry_count {
+ app.sidebar_agents.row_gap
+ } else {
+ 0
+ }
+}
+
fn agent_panel_visible_count_from(app: &AppState, area: Rect, scroll: usize) -> usize {
let body = agent_panel_body_rect(area, false);
if body.width == 0 || body.height == 0 {
@@ -560,16 +574,17 @@ fn agent_panel_visible_count_from(app: &AppState, area: Rect, scroll: usize) ->
let mut used_rows = 0u16;
let mut visible = 0usize;
- for entry in agent_panel_entries(app).iter().skip(scroll) {
+ let entries = agent_panel_entries(app);
+ for (index, entry) in entries.iter().enumerate().skip(scroll) {
let height = agent_entry_height_in_body(app, entry, body.height);
if used_rows.saturating_add(height) > body.height {
break;
}
used_rows = used_rows.saturating_add(height);
visible += 1;
- if used_rows < body.height {
- used_rows = used_rows.saturating_add(1);
- }
+ used_rows = used_rows
+ .saturating_add(agent_entry_gap(app, index, entries.len()))
+ .min(body.height);
}
visible
}
@@ -580,7 +595,7 @@ fn agent_panel_bottom_start(app: &AppState, area: Rect) -> usize {
let mut used_rows = 0u16;
let mut start = entries.len();
for (index, entry) in entries.iter().enumerate().rev() {
- let gap = u16::from(index + 1 < entries.len());
+ let gap = agent_entry_gap(app, index, entries.len());
let needed = agent_entry_height_in_body(app, entry, body.height).saturating_add(gap);
if used_rows.saturating_add(needed) > body.height {
break;
@@ -664,7 +679,7 @@ pub(crate) fn compute_workspace_list_areas(
continue;
};
let row_height = workspace_row_height_in_body(app, ws, *indented, body.height);
- let gap = workspace_entry_gap(&entries, entry_idx, *indented);
+ let gap = workspace_entry_gap(app, &entries, entry_idx, *indented);
if row_y.saturating_add(row_height) > body_bottom {
break;
}
@@ -673,10 +688,10 @@ pub(crate) fn compute_workspace_list_areas(
rect: Rect::new(body.x, row_y, body.width, row_height),
indented: *indented,
});
- row_y = row_y.saturating_add(row_height);
- if gap > 0 && row_y < body_bottom {
- row_y = row_y.saturating_add(1);
- }
+ row_y = row_y
+ .saturating_add(row_height)
+ .saturating_add(gap)
+ .min(body_bottom);
}
}
}
@@ -1298,7 +1313,7 @@ fn render_agent_detail(
let mut row_y = body.y;
let body_bottom = body.y + body.height;
- for detail in details.iter().skip(app.agent_panel_scroll) {
+ for (index, detail) in details.iter().enumerate().skip(app.agent_panel_scroll) {
let label_color = state_label_color(detail.state, detail.seen, p);
let rows = resolved_agent_rows(app, detail);
let height = (rows.len().max(1) as u16).min(body.height);
@@ -1343,10 +1358,10 @@ fn render_agent_detail(
Rect::new(body.x, row_y + row_index as u16, body.width, 1),
);
}
- row_y = row_y.saturating_add(height);
- if row_y < body_bottom {
- row_y += 1;
- }
+ row_y = row_y
+ .saturating_add(height)
+ .saturating_add(agent_entry_gap(app, index, details.len()))
+ .min(body_bottom);
}
if let Some(track) = scrollbar_rect {
@@ -1446,6 +1461,36 @@ mod tests {
}
#[test]
+ fn default_agent_row_gap_packs_rendering_and_scroll_geometry() {
+ let mut app = crate::app::state::AppState::test_new();
+ app.workspaces = vec![Workspace::test_new("one"), Workspace::test_new("two")];
+ app.ensure_test_terminals();
+ for (workspace, agent) in app.workspaces.iter().zip([Agent::Pi, Agent::Claude]) {
+ let pane_id = workspace.tabs[0].root_pane;
+ let terminal_id = workspace.tabs[0].panes[&pane_id]
+ .attached_terminal_id
+ .clone();
+ app.terminals.get_mut(&terminal_id).unwrap().detected_agent = Some(agent);
+ }
+ app.sidebar_agents.rows = vec![vec![crate::config::AgentSidebarToken::Agent]];
+ assert_eq!(app.sidebar_agents.row_gap, 0);
+
+ let area = Rect::new(0, 0, 20, 5);
+ let metrics = agent_panel_scroll_metrics(&app, area);
+ let body = agent_panel_body_rect(area, false);
+ let mut terminal = Terminal::new(TestBackend::new(20, 5)).unwrap();
+ terminal
+ .draw(|frame| render_agent_detail(&app, &TerminalRuntimeRegistry::new(), frame, area))
+ .unwrap();
+ let buffer = terminal.backend().buffer();
+
+ assert_eq!(metrics.viewport_rows, 2);
+ assert_eq!(metrics.max_offset_from_bottom, 0);
+ assert_eq!(row_text(buffer, body.y, body.width), " pi");
+ assert_eq!(row_text(buffer, body.y + 1, body.width), " claude");
+ }
+
+ #[test]
fn narrow_agent_rows_preserve_later_tab_tokens() {
let mut app = crate::app::state::AppState::test_new();
let mut workspace = Workspace::test_new("very-long-workspace-name");
@@ -2020,6 +2065,7 @@ mod tests {
workspace_with_worktree_space("main", Some("repo-key"), "/repo/herdr"),
workspace_with_worktree_space("issue", Some("repo-key"), "/repo/herdr-issue"),
];
+ app.sidebar_spaces.row_gap = 1;
let (cards, headers) = compute_workspace_list_areas(&app, Rect::new(0, 0, 30, 20));
@@ -2032,6 +2078,87 @@ mod tests {
}
#[test]
+ fn space_row_gap_preserves_compact_worktree_children() {
+ let mut app = AppState::test_new();
+ app.workspaces = vec![
+ workspace_with_worktree_space("main", Some("repo-key"), "/repo/herdr"),
+ workspace_with_worktree_space("issue", Some("repo-key"), "/repo/herdr-issue"),
+ workspace_with_worktree_space("review", Some("repo-key"), "/repo/herdr-review"),
+ Workspace::test_new("notes"),
+ ];
+ app.sidebar_spaces.rows = vec![vec![crate::config::SpaceSidebarToken::Workspace]];
+ app.sidebar_spaces.row_gap = 2;
+
+ let (spacious, _) = compute_workspace_list_areas(&app, Rect::new(0, 0, 30, 30));
+ assert_eq!(
+ spacious[1].rect.y,
+ spacious[0].rect.y + spacious[0].rect.height + 2
+ );
+ assert_eq!(
+ spacious[2].rect.y,
+ spacious[1].rect.y + spacious[1].rect.height
+ );
+ assert_eq!(
+ spacious[3].rect.y,
+ spacious[2].rect.y + spacious[2].rect.height + 2
+ );
+ let spacious_metrics = workspace_list_scroll_metrics(&app, Rect::new(0, 0, 30, 7));
+ assert_eq!(spacious_metrics.viewport_rows, 2);
+ assert_eq!(spacious_metrics.max_offset_from_bottom, 2);
+
+ app.sidebar_spaces.row_gap = 0;
+ let (packed, _) = compute_workspace_list_areas(&app, Rect::new(0, 0, 30, 30));
+ assert!(packed
+ .windows(2)
+ .all(|pair| pair[1].rect.y == pair[0].rect.y + pair[0].rect.height));
+ let packed_metrics = workspace_list_scroll_metrics(&app, Rect::new(0, 0, 30, 7));
+ assert_eq!(packed_metrics.viewport_rows, 4);
+ assert_eq!(packed_metrics.max_offset_from_bottom, 0);
+ }
+
+ #[test]
+ fn packed_workspace_drag_indicator_overlays_an_internal_boundary() {
+ let mut app = AppState::test_new();
+ app.workspaces = vec![
+ Workspace::test_new("a"),
+ Workspace::test_new("b"),
+ Workspace::test_new("c"),
+ ];
+ app.sidebar_spaces.rows = vec![vec![crate::config::SpaceSidebarToken::Workspace]];
+ app.sidebar_spaces.row_gap = 0;
+ let area = Rect::new(0, 0, 30, 20);
+ app.view.workspace_card_areas = compute_workspace_card_areas(&app, area);
+ let list_area = workspace_list_rect(area, app.sidebar_section_split);
+ let indicator_row =
+ workspace_drop_indicator_row(&app.view.workspace_card_areas, list_area, 2).unwrap();
+ assert_eq!(indicator_row, app.view.workspace_card_areas[1].rect.y);
+ app.drag = Some(crate::app::state::DragState {
+ target: crate::app::state::DragTarget::WorkspaceReorder {
+ source_ws_idx: 0,
+ insert_idx: Some(2),
+ },
+ });
+
+ let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap();
+ terminal
+ .draw(|frame| {
+ render_workspace_list(
+ &app,
+ &TerminalRuntimeRegistry::new(),
+ frame,
+ list_area,
+ false,
+ )
+ })
+ .unwrap();
+
+ assert_eq!(
+ terminal.backend().buffer()[(list_area.x, indicator_row)].symbol(),
+ "─"
+ );
+ }
+
+ #[test]
fn linked_only_worktree_members_do_not_form_parentless_group() {
let mut app = AppState::test_new();
app.workspaces = vec![
diff --git a/src/ui/sidebar/tokens.rs b/src/ui/sidebar/tokens.rs
index 43c24d05..5c003617 100644
--- a/src/ui/sidebar/tokens.rs
+++ b/src/ui/sidebar/tokens.rs
@@ -153,7 +153,7 @@ mod tests {
vec![AgentSidebarToken::Custom("missing".into())],
vec![AgentSidebarToken::Agent],
],
- rows_by_agent: Default::default(),
+ ..Default::default()
};
let rows = agent_rows(&config, &entry, "working");
@@ -174,7 +174,7 @@ mod tests {
AgentSidebarToken::StateText,
AgentSidebarToken::Custom("summary".into()),
]],
- rows_by_agent: Default::default(),
+ ..Default::default()
};
assert_eq!(
@@ -200,7 +200,7 @@ mod tests {
AgentSidebarToken::TerminalTitleStripped,
AgentSidebarToken::Custom("terminal_title".into()),
]],
- rows_by_agent: Default::default(),
+ ..Default::default()
};
assert_eq!(
@@ -217,7 +217,7 @@ mod tests {
fn known_agent_override_replaces_default_rows() {
let mut config = AgentsSidebarConfig {
rows: vec![vec![AgentSidebarToken::Workspace]],
- rows_by_agent: Default::default(),
+ ..Default::default()
};
config
.rows_by_agent
@@ -265,6 +265,7 @@ mod tests {
let tokens = std::collections::HashMap::from([("jj_status".into(), "2 changes".into())]);
let config = SpacesSidebarConfig {
rows: vec![vec![SpaceSidebarToken::Custom("jj_status".into())]],
+ ..Default::default()
};
assert_eq!(