aboutsummaryrefslogtreecommitdiff
path: root/src/app
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/app
parentfdea0bbbf606fcd8cfec1613a3aaf610bfb9b3cf (diff)
fix(ui): make sidebar spaces list scrollable
Diffstat (limited to 'src/app')
-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
4 files changed, 174 insertions, 12 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(),