From c1e100be8fa15bc51f3c2e0b1edb38aa95a7cccd Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Mon, 6 Apr 2026 16:13:12 +0300 Subject: fix(ui): make sidebar spaces list scrollable --- src/app/actions.rs | 52 ++++++++++++++++++++++ src/app/input.rs | 128 ++++++++++++++++++++++++++++++++++++++++++++++++----- src/app/mod.rs | 1 + src/app/state.rs | 5 +++ 4 files changed, 174 insertions(+), 12 deletions(-) (limited to 'src/app') 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); } } } @@ -542,6 +579,21 @@ mod tests { assert_eq!(state.selected, 2); } + #[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"]); 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 { + 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 { + 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, }, + WorkspaceListScrollbar { + grab_row_offset: u16, + }, PaneSplit { path: Vec, direction: Direction, @@ -562,6 +565,7 @@ pub struct AppState { pub onboarding_list: SelectionListState, pub release_notes: Option, pub keybind_help: KeybindHelpState, + pub workspace_scroll: usize, // View geometry (computed before render, consumed by render + mouse) pub view: ViewState, pub(crate) drag: Option, @@ -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(), -- cgit v1.2.3