diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/app/actions.rs | 52 | ||||
| -rw-r--r-- | src/app/input.rs | 128 | ||||
| -rw-r--r-- | src/app/mod.rs | 1 | ||||
| -rw-r--r-- | src/app/state.rs | 5 | ||||
| -rw-r--r-- | src/ui.rs | 184 |
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(), @@ -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(); |
