aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-04-06 16:13:12 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-04-06 16:13:12 +0300
commitc1e100be8fa15bc51f3c2e0b1edb38aa95a7cccd (patch)
treeb6d1a306e52da01a2bd03abb0e0348658674fd74 /src
parentfdea0bbbf606fcd8cfec1613a3aaf610bfb9b3cf (diff)
fix(ui): make sidebar spaces list scrollable
Diffstat (limited to 'src')
-rw-r--r--src/app/actions.rs52
-rw-r--r--src/app/input.rs128
-rw-r--r--src/app/mod.rs1
-rw-r--r--src/app/state.rs5
-rw-r--r--src/ui.rs184
5 files changed, 306 insertions, 64 deletions
diff --git a/src/app/actions.rs b/src/app/actions.rs
index 340d3089..ac9b9243 100644
--- a/src/app/actions.rs
+++ b/src/app/actions.rs
@@ -106,12 +106,43 @@ impl AppState {
if idx < self.workspaces.len() {
self.active = Some(idx);
self.selected = idx;
+ self.ensure_workspace_visible(idx);
if let Some(ws) = self.workspaces.get_mut(idx) {
ws.switch_tab(ws.active_tab);
}
}
}
+ pub(crate) fn ensure_workspace_visible(&mut self, idx: usize) {
+ if self.sidebar_collapsed || idx >= self.workspaces.len() {
+ return;
+ }
+
+ let mut cards = crate::ui::compute_workspace_card_areas(self, self.view.sidebar_rect);
+ if cards.is_empty() {
+ self.workspace_scroll = idx;
+ return;
+ }
+
+ let first_idx = cards.first().map(|card| card.ws_idx).unwrap_or(0);
+ if idx < first_idx {
+ self.workspace_scroll = idx;
+ return;
+ }
+
+ while cards.last().map(|card| card.ws_idx).unwrap_or(idx) < idx {
+ let previous_scroll = self.workspace_scroll;
+ self.workspace_scroll = self.workspace_scroll.saturating_add(1);
+ if self.workspace_scroll == previous_scroll {
+ break;
+ }
+ cards = crate::ui::compute_workspace_card_areas(self, self.view.sidebar_rect);
+ if cards.is_empty() {
+ break;
+ }
+ }
+ }
+
pub fn switch_tab(&mut self, idx: usize) {
if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) {
ws.switch_tab(idx);
@@ -162,6 +193,7 @@ impl AppState {
self.selected = selected_id
.and_then(|id| self.workspaces.iter().position(|ws| ws.id == id))
.unwrap_or(0);
+ self.ensure_workspace_visible(self.selected);
}
pub fn next_tab(&mut self) {
@@ -196,11 +228,16 @@ impl AppState {
if self.workspaces.is_empty() {
self.active = None;
self.selected = 0;
+ self.workspace_scroll = 0;
} else {
if self.selected >= self.workspaces.len() {
self.selected = self.workspaces.len() - 1;
}
self.active = Some(self.selected);
+ self.workspace_scroll = self
+ .workspace_scroll
+ .min(self.workspaces.len().saturating_sub(1));
+ self.ensure_workspace_visible(self.selected);
}
}
}
@@ -543,6 +580,21 @@ mod tests {
}
#[test]
+ fn switch_workspace_keeps_selected_visible_in_scrolled_sidebar() {
+ let mut state = app_with_workspaces(&["a", "b", "c", "d", "e", "f", "g", "h"]);
+ crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 26, 14));
+
+ state.switch_workspace(7);
+ crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 26, 14));
+
+ assert!(state
+ .view
+ .workspace_card_areas
+ .iter()
+ .any(|card| card.ws_idx == 7));
+ }
+
+ #[test]
fn switch_workspace_marks_panes_seen() {
let mut state = app_with_workspaces(&["a", "b"]);
// Mark a pane in workspace 1 as unseen
diff --git a/src/app/input.rs b/src/app/input.rs
index da38f1d7..f13ec684 100644
--- a/src/app/input.rs
+++ b/src/app/input.rs
@@ -719,11 +719,13 @@ fn handle_navigate_key(state: &mut AppState, key: KeyEvent) {
KeyCode::Up => {
if state.selected > 0 {
state.selected -= 1;
+ state.ensure_workspace_visible(state.selected);
}
}
KeyCode::Down => {
if !state.workspaces.is_empty() && state.selected < state.workspaces.len() - 1 {
state.selected += 1;
+ state.ensure_workspace_visible(state.selected);
}
}
KeyCode::Char('h') | KeyCode::Left => state.navigate_pane(NavDirection::Left),
@@ -1404,15 +1406,71 @@ impl AppState {
if self.sidebar_collapsed || sidebar.width <= 1 || sidebar.height == 0 {
return Rect::default();
}
- let content = Rect::new(
- sidebar.x,
- sidebar.y,
- sidebar.width.saturating_sub(1),
- sidebar.height,
- );
- let total_h = content.height as usize;
- let ws_h = (total_h + 1) / 2;
- Rect::new(content.x, content.y, content.width, ws_h as u16)
+ crate::ui::workspace_list_rect(sidebar)
+ }
+
+ fn workspace_list_scrollbar_target_at(
+ &self,
+ col: u16,
+ row: u16,
+ ) -> Option<ScrollbarClickTarget> {
+ let area = self.workspace_list_rect();
+ let metrics = crate::ui::workspace_list_scroll_metrics(self, area);
+ let track = crate::ui::workspace_list_scrollbar_rect(self, area)?;
+ if col < track.x
+ || col >= track.x + track.width
+ || row < track.y
+ || row >= track.y + track.height
+ {
+ return None;
+ }
+ if let Some(grab_row_offset) = crate::ui::scrollbar_thumb_grab_offset(metrics, track, row) {
+ Some(ScrollbarClickTarget::Thumb { grab_row_offset })
+ } else {
+ Some(ScrollbarClickTarget::Track {
+ offset_from_bottom: crate::ui::scrollbar_offset_from_row(metrics, track, row),
+ })
+ }
+ }
+
+ fn workspace_list_offset_for_drag_row(&self, row: u16, grab_row_offset: u16) -> Option<usize> {
+ let area = self.workspace_list_rect();
+ let metrics = crate::ui::workspace_list_scroll_metrics(self, area);
+ let track = crate::ui::workspace_list_scrollbar_rect(self, area)?;
+ Some(crate::ui::scrollbar_offset_from_drag_row(
+ metrics,
+ track,
+ row,
+ grab_row_offset,
+ ))
+ }
+
+ fn set_workspace_list_offset_from_bottom(&mut self, offset_from_bottom: usize) {
+ let area = self.workspace_list_rect();
+ let metrics = crate::ui::workspace_list_scroll_metrics(self, area);
+ self.workspace_scroll = metrics
+ .max_offset_from_bottom
+ .saturating_sub(offset_from_bottom);
+ }
+
+ fn scroll_workspace_list(&mut self, delta: i16) {
+ if delta.is_negative() {
+ self.workspace_scroll = self
+ .workspace_scroll
+ .saturating_sub(delta.unsigned_abs() as usize);
+ return;
+ }
+
+ for _ in 0..delta as usize {
+ let cards = crate::ui::compute_workspace_card_areas(self, self.view.sidebar_rect);
+ let Some(last) = cards.last() else {
+ break;
+ };
+ if last.ws_idx + 1 >= self.workspaces.len() {
+ break;
+ }
+ self.workspace_scroll = self.workspace_scroll.saturating_add(1);
+ }
}
pub(crate) fn sidebar_footer_rect(&self) -> Rect {
@@ -1908,6 +1966,22 @@ impl AppState {
return None;
}
+ if let Some(target) =
+ self.workspace_list_scrollbar_target_at(mouse.column, mouse.row)
+ {
+ match target {
+ ScrollbarClickTarget::Thumb { grab_row_offset } => {
+ self.drag = Some(DragState {
+ target: DragTarget::WorkspaceListScrollbar { grab_row_offset },
+ });
+ }
+ ScrollbarClickTarget::Track { offset_from_bottom } => {
+ self.set_workspace_list_offset_from_bottom(offset_from_bottom);
+ }
+ }
+ return None;
+ }
+
if let Some(idx) = self.workspace_at_row(mouse.row) {
self.workspace_press = Some(WorkspacePressState {
ws_idx: idx,
@@ -1997,6 +2071,13 @@ impl AppState {
} else if let Some(drag) = &self.drag {
match &drag.target {
DragTarget::WorkspaceReorder { .. } => {}
+ DragTarget::WorkspaceListScrollbar { grab_row_offset } => {
+ if let Some(offset_from_bottom) =
+ self.workspace_list_offset_for_drag_row(mouse.row, *grab_row_offset)
+ {
+ self.set_workspace_list_offset_from_bottom(offset_from_bottom);
+ }
+ }
DragTarget::PaneSplit {
path,
direction,
@@ -2097,13 +2178,25 @@ impl AppState {
}
MouseEventKind::ScrollUp if in_sidebar => {
- if self.selected > 0 {
+ if crate::ui::should_show_scrollbar(crate::ui::workspace_list_scroll_metrics(
+ self,
+ self.workspace_list_rect(),
+ )) {
+ self.scroll_workspace_list(-1);
+ } else if self.selected > 0 {
self.selected -= 1;
+ self.ensure_workspace_visible(self.selected);
}
}
MouseEventKind::ScrollDown if in_sidebar => {
- if !self.workspaces.is_empty() && self.selected < self.workspaces.len() - 1 {
+ if crate::ui::should_show_scrollbar(crate::ui::workspace_list_scroll_metrics(
+ self,
+ self.workspace_list_rect(),
+ )) {
+ self.scroll_workspace_list(1);
+ } else if !self.workspaces.is_empty() && self.selected < self.workspaces.len() - 1 {
self.selected += 1;
+ self.ensure_workspace_visible(self.selected);
}
}
@@ -2115,6 +2208,12 @@ impl AppState {
}
MouseEventKind::Down(MouseButton::Right) if in_sidebar && !self.sidebar_collapsed => {
+ if self
+ .workspace_list_scrollbar_target_at(mouse.column, mouse.row)
+ .is_some()
+ {
+ return None;
+ }
if let Some(idx) = self.workspace_at_row(mouse.row) {
self.selected = idx;
self.context_menu = Some(ContextMenuState {
@@ -2240,8 +2339,13 @@ impl AppState {
return Some(0);
}
+ let mut insert_indices = Vec::with_capacity(cards.len() + 1);
+ insert_indices.push(cards[0].ws_idx);
+ insert_indices.extend(cards.iter().skip(1).map(|card| card.ws_idx));
+ insert_indices.push(cards.last().map(|card| card.ws_idx + 1).unwrap_or(0));
+
let mut best: Option<(usize, u16)> = None;
- for insert_idx in 0..=cards.len() {
+ for insert_idx in insert_indices {
let Some(slot_row) = crate::ui::workspace_drop_indicator_row(&cards, area, insert_idx)
else {
continue;
diff --git a/src/app/mod.rs b/src/app/mod.rs
index 65a1cc4a..901d9528 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -213,6 +213,7 @@ impl App {
preview: notes.preview,
}),
keybind_help: state::KeybindHelpState { scroll: 0 },
+ workspace_scroll: 0,
view: state::ViewState {
sidebar_rect: Rect::default(),
workspace_card_areas: Vec::new(),
diff --git a/src/app/state.rs b/src/app/state.rs
index c55b5cd8..9f2e8e5b 100644
--- a/src/app/state.rs
+++ b/src/app/state.rs
@@ -461,6 +461,9 @@ pub(crate) enum DragTarget {
source_ws_idx: usize,
insert_idx: Option<usize>,
},
+ WorkspaceListScrollbar {
+ grab_row_offset: u16,
+ },
PaneSplit {
path: Vec<bool>,
direction: Direction,
@@ -562,6 +565,7 @@ pub struct AppState {
pub onboarding_list: SelectionListState,
pub release_notes: Option<ReleaseNotesState>,
pub keybind_help: KeybindHelpState,
+ pub workspace_scroll: usize,
// View geometry (computed before render, consumed by render + mouse)
pub view: ViewState,
pub(crate) drag: Option<DragState>,
@@ -660,6 +664,7 @@ impl AppState {
onboarding_list: SelectionListState::new(1),
release_notes: None,
keybind_help: KeybindHelpState { scroll: 0 },
+ workspace_scroll: 0,
view: ViewState {
sidebar_rect: Rect::default(),
workspace_card_areas: Vec::new(),
diff --git a/src/ui.rs b/src/ui.rs
index d8322f6f..e82d5b73 100644
--- a/src/ui.rs
+++ b/src/ui.rs
@@ -50,6 +50,10 @@ pub fn compute_view(app: &mut AppState, area: Rect) {
(Rect::default(), main_area)
};
+ app.workspace_scroll = app
+ .workspace_scroll
+ .min(app.workspaces.len().saturating_sub(1));
+
let workspace_card_areas = if app.sidebar_collapsed {
Vec::new()
} else {
@@ -131,31 +135,114 @@ pub fn render(app: &AppState, frame: &mut Frame) {
const MIN_TAB_WIDTH: u16 = 8;
const NEW_TAB_WIDTH: u16 = 3;
+const WORKSPACE_SECTION_HEADER_ROWS: u16 = 2;
+
+fn workspace_row_height(ws: &crate::workspace::Workspace) -> u16 {
+ if ws.branch().is_some() {
+ 2
+ } else {
+ 1
+ }
+}
+
+pub(crate) fn workspace_list_rect(area: Rect) -> Rect {
+ let content = Rect::new(area.x, area.y, area.width.saturating_sub(1), area.height);
+ if content.width == 0 || content.height == 0 {
+ return Rect::default();
+ }
+
+ let total_h = content.height as usize;
+ let ws_h = (total_h + 1) / 2;
+ Rect::new(content.x, content.y, content.width, ws_h as u16)
+}
+
+pub(crate) fn workspace_list_body_rect(area: Rect, has_scrollbar: bool) -> Rect {
+ if area.width == 0 || area.height <= WORKSPACE_SECTION_HEADER_ROWS {
+ return Rect::default();
+ }
+
+ let body_y = area.y.saturating_add(WORKSPACE_SECTION_HEADER_ROWS);
+ let footer_y = area.y + area.height.saturating_sub(1);
+ let body_height = footer_y.saturating_sub(body_y);
+ let body_width = area.width.saturating_sub(u16::from(has_scrollbar));
+ Rect::new(area.x, body_y, body_width, body_height)
+}
+
+fn workspace_list_visible_count(app: &AppState, area: Rect, scroll: usize) -> usize {
+ let body = workspace_list_body_rect(area, false);
+ if body.width == 0 || body.height == 0 {
+ return 0;
+ }
+
+ let mut used_rows = 0u16;
+ let mut visible = 0usize;
+ for ws in app.workspaces.iter().skip(scroll) {
+ let needed = workspace_row_height(ws).saturating_add(1);
+ if used_rows.saturating_add(needed) > body.height {
+ break;
+ }
+ used_rows = used_rows.saturating_add(needed);
+ visible += 1;
+ }
+ visible
+}
+
+pub(crate) fn workspace_list_scroll_metrics(
+ app: &AppState,
+ area: Rect,
+) -> crate::pane::ScrollMetrics {
+ let viewport_rows = workspace_list_visible_count(app, area, app.workspace_scroll);
+ let total_rows = app.workspaces.len();
+ let max_offset_from_bottom = total_rows.saturating_sub(viewport_rows);
+ let offset_from_bottom = total_rows
+ .saturating_sub(app.workspace_scroll)
+ .saturating_sub(viewport_rows);
+
+ crate::pane::ScrollMetrics {
+ offset_from_bottom,
+ max_offset_from_bottom,
+ viewport_rows,
+ }
+}
+
+pub(crate) fn workspace_list_scrollbar_rect(app: &AppState, area: Rect) -> Option<Rect> {
+ let metrics = workspace_list_scroll_metrics(app, area);
+ let body = workspace_list_body_rect(area, true);
+ (should_show_scrollbar(metrics) && body.width > 0 && body.height > 0).then_some(Rect::new(
+ area.x + area.width.saturating_sub(1),
+ body.y,
+ 1,
+ body.height,
+ ))
+}
pub(crate) fn compute_workspace_card_areas(
app: &AppState,
area: Rect,
) -> Vec<crate::app::state::WorkspaceCardArea> {
- let content = Rect::new(area.x, area.y, area.width.saturating_sub(1), area.height);
- if content.width == 0 || content.height == 0 {
+ let ws_area = workspace_list_rect(area);
+ if ws_area == Rect::default() {
return Vec::new();
}
- let total_h = content.height as usize;
- let ws_h = (total_h + 1) / 2;
- let ws_area = Rect::new(content.x, content.y, content.width, ws_h as u16);
- let list_bottom = ws_area.y + ws_area.height.saturating_sub(1);
- let mut row_y = ws_area.y.saturating_add(2);
+ let metrics = workspace_list_scroll_metrics(app, ws_area);
+ let body = workspace_list_body_rect(ws_area, should_show_scrollbar(metrics));
+ if body.width == 0 || body.height == 0 {
+ return Vec::new();
+ }
+
+ let mut row_y = body.y;
+ let body_bottom = body.y + body.height;
let mut cards = Vec::new();
- for (ws_idx, ws) in app.workspaces.iter().enumerate() {
- if row_y + 1 >= list_bottom {
+ for (ws_idx, ws) in app.workspaces.iter().enumerate().skip(app.workspace_scroll) {
+ let row_height = workspace_row_height(ws);
+ if row_y.saturating_add(row_height).saturating_add(1) > body_bottom {
break;
}
- let row_height = if ws.branch().is_some() { 2 } else { 1 };
cards.push(crate::app::state::WorkspaceCardArea {
ws_idx,
- rect: Rect::new(ws_area.x, row_y, ws_area.width, row_height),
+ rect: Rect::new(body.x, row_y, body.width, row_height),
});
row_y = row_y.saturating_add(row_height + 1);
}
@@ -428,17 +515,19 @@ pub(crate) fn workspace_drop_indicator_row(
return None;
}
let list_bottom = area.y + area.height.saturating_sub(1);
- if insert_idx == 0 {
- return cards
- .first()
- .and_then(|card| card.rect.y.checked_sub(1))
- .filter(|y| *y < list_bottom);
+
+ let first = cards.first()?;
+ if insert_idx == first.ws_idx {
+ return first.rect.y.checked_sub(1).filter(|y| *y < list_bottom);
}
- if let Some(card) = cards.get(insert_idx) {
+
+ if let Some(card) = cards.iter().find(|card| card.ws_idx == insert_idx) {
return card.rect.y.checked_sub(1).filter(|y| *y < list_bottom);
}
+
cards
.last()
+ .filter(|card| insert_idx == card.ws_idx.saturating_add(1))
.map(|card| card.rect.y.saturating_add(card.rect.height))
.filter(|y| *y < list_bottom)
}
@@ -523,26 +612,22 @@ fn render_workspace_list(app: &AppState, frame: &mut Frame, area: Rect, is_navig
Rect::new(area.x, area.y, area.width, 1),
);
}
- let mut row_y = area.y.saturating_add(1);
- // Blank line for breathing room
- row_y = row_y.saturating_add(1);
+ let metrics = workspace_list_scroll_metrics(app, area);
+ let scrollbar_rect = workspace_list_scrollbar_rect(app, area);
+ let cards = &app.view.workspace_card_areas;
- for (i, ws) in app.workspaces.iter().enumerate() {
- if row_y + 1 >= list_bottom {
- break;
- }
+ for card in cards {
+ let i = card.ws_idx;
+ let ws = &app.workspaces[i];
+ let row_y = card.rect.y;
+ let row_height = card.rect.height;
let selected = i == app.selected && is_navigating;
let is_active = Some(i) == app.active;
let is_dragged = dragged_ws_idx == Some(i);
- let highlighted = selected || is_active || is_dragged; // active always gets a bg
+ let highlighted = selected || is_active || is_dragged;
let (agg_state, agg_seen) = ws.aggregate_state();
- // Determine row height for background fill
- let has_second_line = ws.branch().is_some();
- let row_height: u16 = if has_second_line { 2 } else { 1 };
-
- // Background fill: selected gets brighter surface, active gets subtle surface
if highlighted {
let bg = if selected {
p.surface0
@@ -556,25 +641,23 @@ fn render_workspace_list(app: &AppState, frame: &mut Frame, area: Rect, is_navig
if y >= list_bottom {
break;
}
- for x in area.x..area.x + area.width {
+ for x in card.rect.x..card.rect.x + card.rect.width {
buf[(x, y)].set_style(Style::default().bg(bg));
}
}
}
- // Accent bar on the left edge for active workspace
if is_active {
let buf = frame.buffer_mut();
for y in row_y..row_y + row_height {
if y >= list_bottom {
break;
}
- buf[(area.x, y)].set_symbol("▌");
- buf[(area.x, y)].set_style(Style::default().fg(p.accent));
+ buf[(card.rect.x, y)].set_symbol("▌");
+ buf[(card.rect.x, y)].set_style(Style::default().fg(p.accent));
}
}
- // Styles — active workspace is always visible, selected (nav mode) is brightest
let name_style = if selected || is_active || is_dragged {
Style::default().fg(p.text).add_modifier(Modifier::BOLD)
} else {
@@ -586,26 +669,21 @@ fn render_workspace_list(app: &AppState, frame: &mut Frame, area: Rect, is_navig
Style::default().fg(p.overlay0)
};
- // Line 1: marker-space + number + name + aggregate state dot
- let marker = if is_active { " " } else { " " }; // accent bar handles active indicator
let mut line1 = vec![
- Span::styled(marker, Style::default()),
+ Span::styled(" ", Style::default()),
Span::styled(format!("{} ", i + 1), num_style),
Span::styled(ws.display_name(), name_style),
Span::styled(" ", Style::default()),
];
-
let (icon, icon_style) = state_dot(agg_state, agg_seen, p);
line1.push(Span::styled(icon, icon_style));
frame.render_widget(
Paragraph::new(Line::from(line1)),
- Rect::new(area.x, row_y, area.width, 1),
+ Rect::new(card.rect.x, row_y, card.rect.width, 1),
);
- row_y += 1;
- // Line 2: branch
- if row_y < list_bottom {
+ if row_height > 1 && row_y + 1 < list_bottom {
if let Some(branch) = ws.branch() {
let upstream_label = ws.git_ahead_behind().and_then(|(ahead, behind)| {
let mut parts = Vec::new();
@@ -623,7 +701,7 @@ fn render_workspace_list(app: &AppState, frame: &mut Frame, area: Rect, is_navig
parts.iter().map(|(label, _)| label.len()).sum::<usize>() + parts.len()
})
.unwrap_or(0);
- let max_branch_len = (area.width as usize).saturating_sub(5 + reserved);
+ let max_branch_len = (card.rect.width as usize).saturating_sub(5 + reserved);
let branch_display = if branch.len() > max_branch_len {
format!("{}…", &branch[..max_branch_len.saturating_sub(1)])
} else {
@@ -647,27 +725,29 @@ fn render_workspace_list(app: &AppState, frame: &mut Frame, area: Rect, is_navig
spans.push(Span::styled(label, Style::default().fg(color)));
}
}
- let line2 = Line::from(spans);
frame.render_widget(
- Paragraph::new(line2),
- Rect::new(area.x, row_y, area.width, 1),
+ Paragraph::new(Line::from(spans)),
+ Rect::new(card.rect.x, row_y + 1, card.rect.width, 1),
);
- row_y += 1;
}
}
-
- // Spacing between workspace cards
- row_y += 1;
}
if let Some(y) = insertion_row.filter(|y| *y < list_bottom) {
+ let indicator_right = scrollbar_rect
+ .map(|rect| rect.x)
+ .unwrap_or(area.x + area.width);
let buf = frame.buffer_mut();
- for x in area.x..area.x + area.width {
+ for x in area.x..indicator_right {
buf[(x, y)].set_symbol("─");
buf[(x, y)].set_style(Style::default().fg(p.accent));
}
}
+ if let Some(track) = scrollbar_rect {
+ render_scrollbar(frame, metrics, track, p.surface_dim, p.overlay0, "▕");
+ }
+
// Footer actions for workspace section
if list_bottom > area.y {
let new_rect = app.sidebar_new_button_rect();