diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-04-04 01:50:43 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-04-04 01:50:43 +0300 |
| commit | 951fb1d88be721461783a239c152cf143d741dc4 (patch) | |
| tree | 32f2946a53f30fd50b9523bf9490f13f330a309d /src | |
| parent | 831894facfc1339f819b42c3339668154121d24f (diff) | |
feat: add mouse drag reordering for workspaces
Diffstat (limited to 'src')
| -rw-r--r-- | src/app/actions.rs | 62 | ||||
| -rw-r--r-- | src/app/input.rs | 191 | ||||
| -rw-r--r-- | src/app/mod.rs | 2 | ||||
| -rw-r--r-- | src/app/state.rs | 20 | ||||
| -rw-r--r-- | src/ui.rs | 98 |
5 files changed, 343 insertions, 30 deletions
diff --git a/src/app/actions.rs b/src/app/actions.rs index 88366ce8..41db6f1f 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -138,6 +138,32 @@ impl AppState { } } + pub fn move_workspace(&mut self, source_idx: usize, insert_idx: usize) { + if source_idx >= self.workspaces.len() || insert_idx > self.workspaces.len() { + return; + } + + let active_id = self.active.map(|idx| self.workspaces[idx].id.clone()); + let selected_id = self + .workspaces + .get(self.selected) + .map(|workspace| workspace.id.clone()); + + let workspace = self.workspaces.remove(source_idx); + let target_idx = if source_idx < insert_idx { + insert_idx.saturating_sub(1) + } else { + insert_idx + } + .min(self.workspaces.len()); + self.workspaces.insert(target_idx, workspace); + + self.active = active_id.and_then(|id| self.workspaces.iter().position(|ws| ws.id == id)); + self.selected = selected_id + .and_then(|id| self.workspaces.iter().position(|ws| ws.id == id)) + .unwrap_or(0); + } + pub fn next_tab(&mut self) { if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) { if !ws.tabs.is_empty() { @@ -536,6 +562,42 @@ mod tests { } #[test] + fn move_workspace_reorders_without_changing_logical_selection() { + let mut state = app_with_workspaces(&["a", "b", "c"]); + let active_id = state.workspaces[1].id.clone(); + let selected_id = state.workspaces[2].id.clone(); + state.active = Some(1); + state.selected = 2; + + state.move_workspace(1, 0); + + let names: Vec<_> = state + .workspaces + .iter() + .map(|ws| ws.display_name()) + .collect(); + assert_eq!(names, vec!["b", "a", "c"]); + assert_eq!(state.active, Some(0)); + assert_eq!(state.selected, 2); + assert_eq!(state.workspaces[state.active.unwrap()].id, active_id); + assert_eq!(state.workspaces[state.selected].id, selected_id); + } + + #[test] + fn move_workspace_accepts_insert_at_end() { + let mut state = app_with_workspaces(&["a", "b", "c"]); + + state.move_workspace(0, state.workspaces.len()); + + let names: Vec<_> = state + .workspaces + .iter() + .map(|ws| ws.display_name()) + .collect(); + assert_eq!(names, vec!["b", "c", "a"]); + } + + #[test] fn close_workspace_adjusts_indices() { let mut state = app_with_workspaces(&["a", "b", "c"]); state.selected = 1; diff --git a/src/app/input.rs b/src/app/input.rs index e04c9245..513a1d48 100644 --- a/src/app/input.rs +++ b/src/app/input.rs @@ -23,9 +23,11 @@ enum WheelRouting { AlternateScroll, } +const WORKSPACE_DRAG_THRESHOLD: u16 = 1; + use super::state::{ key_matches, AppState, ContextMenuKind, ContextMenuState, DragState, DragTarget, MenuListState, - Mode, + Mode, WorkspacePressState, }; use super::App; @@ -1410,7 +1412,7 @@ impl AppState { } } - pub(crate) fn sidebar_footer_rect(&self) -> Rect { + fn workspace_list_rect(&self) -> Rect { let sidebar = self.view.sidebar_rect; if self.sidebar_collapsed || sidebar.width <= 1 || sidebar.height == 0 { return Rect::default(); @@ -1423,7 +1425,14 @@ impl AppState { ); 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); + Rect::new(content.x, content.y, content.width, ws_h as u16) + } + + pub(crate) fn sidebar_footer_rect(&self) -> Rect { + let ws_area = self.workspace_list_rect(); + if ws_area == Rect::default() { + return Rect::default(); + } let y = ws_area.y + ws_area.height.saturating_sub(1); Rect::new(ws_area.x, y, ws_area.width, 1) } @@ -1778,6 +1787,7 @@ impl AppState { match mouse.kind { MouseEventKind::Down(MouseButton::Left) => { self.selection = None; + self.workspace_press = None; if self.mode == Mode::ConfirmClose { let popup = self.confirm_close_rect(); @@ -1912,8 +1922,11 @@ impl AppState { } if let Some(idx) = self.workspace_at_row(mouse.row) { - self.switch_workspace(idx); - self.mode = Mode::Terminal; + self.workspace_press = Some(WorkspacePressState { + ws_idx: idx, + start_col: mouse.column, + start_row: mouse.row, + }); return None; } @@ -1961,8 +1974,30 @@ impl AppState { } MouseEventKind::Drag(MouseButton::Left) => { - if let Some(drag) = &self.drag { + let workspace_drop_index = self.workspace_drop_index_at_row(mouse.row); + if self.drag.is_none() { + if let Some(press) = &self.workspace_press { + let delta_col = mouse.column.abs_diff(press.start_col); + let delta_row = mouse.row.abs_diff(press.start_row); + if delta_col.max(delta_row) >= WORKSPACE_DRAG_THRESHOLD { + self.drag = Some(DragState { + target: DragTarget::WorkspaceReorder { + source_ws_idx: press.ws_idx, + insert_idx: workspace_drop_index, + }, + }); + } + } + } + + if let Some(DragState { + target: DragTarget::WorkspaceReorder { insert_idx, .. }, + }) = &mut self.drag + { + *insert_idx = workspace_drop_index; + } else if let Some(drag) = &self.drag { match &drag.target { + DragTarget::WorkspaceReorder { .. } => {} DragTarget::PaneSplit { path, direction, @@ -2009,13 +2044,30 @@ impl AppState { } MouseEventKind::Up(MouseButton::Left) => { - if self.drag.take().is_some() { - } else { - let was_click = self.selection.as_ref().is_some_and(|s| s.was_just_click()); - if was_click { - self.selection = None; - } else { - self.copy_selection(); + let workspace_press = self.workspace_press.take(); + match self.drag.take() { + Some(DragState { + target: + DragTarget::WorkspaceReorder { + source_ws_idx, + insert_idx: Some(insert_idx), + }, + }) => { + self.move_workspace(source_ws_idx, insert_idx); + } + Some(_) => {} + None => { + if let Some(press) = workspace_press { + self.switch_workspace(press.ws_idx); + self.mode = Mode::Terminal; + return None; + } + let was_click = self.selection.as_ref().is_some_and(|s| s.was_just_click()); + if was_click { + self.selection = None; + } else { + self.copy_selection(); + } } } } @@ -2139,23 +2191,49 @@ impl AppState { return None; } - if row < self.view.sidebar_rect.y || row >= footer.y { + let cards = if self.view.workspace_card_areas.is_empty() { + crate::ui::compute_workspace_card_areas(self, self.view.sidebar_rect) + } else { + self.view.workspace_card_areas.clone() + }; + + cards.iter().find_map(|card| { + (row >= card.rect.y && row < card.rect.y + card.rect.height).then_some(card.ws_idx) + }) + } + + fn workspace_drop_index_at_row(&self, row: u16) -> Option<usize> { + let area = self.workspace_list_rect(); + let footer = self.sidebar_footer_rect(); + if area == Rect::default() || row < area.y || row >= footer.y { return None; } - let mut row_y = self.view.sidebar_rect.y; - for (i, ws) in self.workspaces.iter().enumerate() { - let has_branch = ws.branch().is_some(); - let card_h: u16 = if has_branch { 2 } else { 1 }; - if row >= row_y && row < row_y + card_h { - return Some(i); - } - row_y += card_h + 1; // +1 for gap - if row_y >= footer.y { - break; + let cards = if self.view.workspace_card_areas.is_empty() { + crate::ui::compute_workspace_card_areas(self, self.view.sidebar_rect) + } else { + self.view.workspace_card_areas.clone() + }; + if cards.is_empty() { + return Some(0); + } + + let mut best: Option<(usize, u16)> = None; + for insert_idx in 0..=cards.len() { + let Some(slot_row) = crate::ui::workspace_drop_indicator_row(&cards, area, insert_idx) + else { + continue; + }; + let distance = row.abs_diff(slot_row); + match best { + Some((best_idx, best_distance)) + if distance > best_distance + || (distance == best_distance && insert_idx < best_idx) => {} + _ => best = Some((insert_idx, distance)), } } - None + + best.map(|(insert_idx, _)| insert_idx) } fn agent_detail_target_at(&self, row: u16) -> Option<(usize, usize, crate::layout::PaneId)> { @@ -3164,6 +3242,69 @@ mod tests { } #[test] + fn clicking_workspace_switches_on_mouse_up() { + let mut app = app_for_mouse_test(); + app.state.workspaces = vec![Workspace::test_new("a"), Workspace::test_new("b")]; + app.state.active = Some(0); + app.state.selected = 0; + + app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 2, 3)); + assert_eq!(app.state.active, Some(0)); + assert!(app.state.workspace_press.is_some()); + + app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), 2, 3)); + assert_eq!(app.state.active, Some(1)); + assert_eq!(app.state.selected, 1); + assert!(app.state.workspace_press.is_none()); + } + + #[test] + fn dragging_workspace_reorders_without_changing_identity() { + let mut app = app_for_mouse_test(); + app.state.workspaces = vec![ + Workspace::test_new("a"), + Workspace::test_new("b"), + Workspace::test_new("c"), + ]; + let active_id = app.state.workspaces[1].id.clone(); + let selected_id = app.state.workspaces[2].id.clone(); + app.state.active = Some(1); + app.state.selected = 2; + + app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 2, 3)); + app.handle_mouse(mouse(MouseEventKind::Drag(MouseButton::Left), 2, 0)); + assert!(matches!( + app.state.drag.as_ref().map(|drag| &drag.target), + Some(DragTarget::WorkspaceReorder { + source_ws_idx: 1, + insert_idx: Some(0), + }) + )); + app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), 2, 0)); + + let names: Vec<_> = app + .state + .workspaces + .iter() + .map(|ws| ws.display_name()) + .collect(); + assert_eq!(names, vec!["b", "a", "c"]); + assert_eq!(app.state.active, Some(0)); + assert_eq!(app.state.selected, 2); + assert_eq!(app.state.workspaces[0].id, active_id); + assert_eq!(app.state.workspaces[2].id, selected_id); + } + + #[test] + fn top_drop_slot_is_distinct_from_gap_below_first_workspace() { + let mut app = app_for_mouse_test(); + app.state.workspaces = vec![Workspace::test_new("a"), Workspace::test_new("b")]; + + assert_eq!(app.state.workspace_drop_index_at_row(0), Some(0)); + assert_eq!(app.state.workspace_drop_index_at_row(1), Some(1)); + } + + #[test] fn dragging_sidebar_divider_sets_manual_width() { let mut app = app_for_mouse_test(); diff --git a/src/app/mod.rs b/src/app/mod.rs index c78d1c74..460e5780 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -215,6 +215,7 @@ impl App { keybind_help: state::KeybindHelpState { scroll: 0 }, view: state::ViewState { sidebar_rect: Rect::default(), + workspace_card_areas: Vec::new(), tab_bar_rect: Rect::default(), tab_hit_areas: Vec::new(), new_tab_hit_area: Rect::default(), @@ -223,6 +224,7 @@ impl App { split_borders: Vec::new(), }, drag: None, + workspace_press: None, selection: None, context_menu: None, update_available: None, diff --git a/src/app/state.rs b/src/app/state.rs index bb1f5bfe..6708ef65 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -320,10 +320,17 @@ impl Palette { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct WorkspaceCardArea { + pub ws_idx: usize, + pub rect: Rect, +} + /// Computed view geometry — derived from AppState + terminal size. /// Updated before each render, consumed by render and mouse handling. pub struct ViewState { pub sidebar_rect: Rect, + pub workspace_card_areas: Vec<WorkspaceCardArea>, pub tab_bar_rect: Rect, pub tab_hit_areas: Vec<Rect>, pub new_tab_hit_area: Rect, @@ -449,6 +456,10 @@ pub struct SettingsState { } pub(crate) enum DragTarget { + WorkspaceReorder { + source_ws_idx: usize, + insert_idx: Option<usize>, + }, PaneSplit { path: Vec<bool>, direction: Direction, @@ -472,6 +483,12 @@ pub(crate) struct DragState { pub target: DragTarget, } +pub(crate) struct WorkspacePressState { + pub ws_idx: usize, + pub start_col: u16, + pub start_row: u16, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ContextMenuKind { Workspace { ws_idx: usize }, @@ -547,6 +564,7 @@ pub struct AppState { // View geometry (computed before render, consumed by render + mouse) pub view: ViewState, pub(crate) drag: Option<DragState>, + pub(crate) workspace_press: Option<WorkspacePressState>, pub selection: Option<Selection>, pub context_menu: Option<ContextMenuState>, // Notifications @@ -641,6 +659,7 @@ impl AppState { keybind_help: KeybindHelpState { scroll: 0 }, view: ViewState { sidebar_rect: Rect::default(), + workspace_card_areas: Vec::new(), tab_bar_rect: Rect::default(), tab_hit_areas: Vec::new(), new_tab_hit_area: Rect::default(), @@ -649,6 +668,7 @@ impl AppState { split_borders: Vec::new(), }, drag: None, + workspace_press: None, selection: None, context_menu: None, update_available: None, @@ -51,6 +51,12 @@ pub fn compute_view(app: &mut AppState, area: Rect) { (Rect::default(), main_area) }; + let workspace_card_areas = if app.sidebar_collapsed { + Vec::new() + } else { + compute_workspace_card_areas(app, sidebar_area) + }; + let tab_hit_areas = app .active .and_then(|i| app.workspaces.get(i)) @@ -68,6 +74,7 @@ pub fn compute_view(app: &mut AppState, area: Rect) { app.view = crate::app::ViewState { sidebar_rect: sidebar_area, + workspace_card_areas, tab_bar_rect, tab_hit_areas, new_tab_hit_area, @@ -126,6 +133,37 @@ pub fn render(app: &AppState, frame: &mut Frame) { const MIN_TAB_WIDTH: u16 = 8; const NEW_TAB_WIDTH: u16 = 3; +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 { + 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; + let mut cards = Vec::new(); + + for (ws_idx, ws) in app.workspaces.iter().enumerate() { + if row_y + 1 >= list_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), + }); + row_y = row_y.saturating_add(row_height + 1); + } + + cards +} + fn compute_tab_hit_areas(ws: &crate::workspace::Workspace, area: Rect) -> Vec<Rect> { if area.width == 0 || area.height == 0 { return Vec::new(); @@ -382,6 +420,30 @@ fn render_sidebar_collapsed(app: &AppState, frame: &mut Frame, area: Rect) { render_sidebar_toggle(app, frame, area, true, p); } +pub(crate) fn workspace_drop_indicator_row( + cards: &[crate::app::state::WorkspaceCardArea], + area: Rect, + insert_idx: usize, +) -> Option<u16> { + if area.height == 0 { + return None; + } + let list_bottom = area.y + area.height.saturating_sub(1); + if insert_idx == 0 { + return cards + .first() + .map(|card| card.rect.y) + .filter(|y| *y < list_bottom); + } + if let Some(card) = cards.get(insert_idx) { + return card.rect.y.checked_sub(1).filter(|y| *y < list_bottom); + } + cards + .last() + .map(|_| list_bottom.saturating_sub(1)) + .filter(|y| *y < list_bottom) +} + fn render_sidebar(app: &AppState, frame: &mut Frame, area: Rect) { let p = &app.palette; let is_navigating = matches!(app.mode, Mode::Navigate); @@ -437,6 +499,19 @@ fn render_sidebar(app: &AppState, frame: &mut Frame, area: Rect) { /// Render the workspace list in the top section of the sidebar. fn render_workspace_list(app: &AppState, frame: &mut Frame, area: Rect, is_navigating: bool) { let p = &app.palette; + let dragged_ws_idx = match app.drag.as_ref().map(|drag| &drag.target) { + Some(crate::app::state::DragTarget::WorkspaceReorder { source_ws_idx, .. }) => { + Some(*source_ws_idx) + } + _ => None, + }; + let insertion_row = match app.drag.as_ref().map(|drag| &drag.target) { + Some(crate::app::state::DragTarget::WorkspaceReorder { + insert_idx: Some(insert_idx), + .. + }) => workspace_drop_indicator_row(&app.view.workspace_card_areas, area, *insert_idx), + _ => None, + }; // Reserve last row for "new" button let list_bottom = area.y + area.height.saturating_sub(1); @@ -448,7 +523,8 @@ fn render_workspace_list(app: &AppState, frame: &mut Frame, area: Rect, is_navig } let selected = i == app.selected && is_navigating; let is_active = Some(i) == app.active; - let highlighted = selected || is_active; // active always gets a bg + let is_dragged = dragged_ws_idx == Some(i); + let highlighted = selected || is_active || is_dragged; // active always gets a bg let (agg_state, agg_seen) = ws.aggregate_state(); // Determine row height for background fill @@ -457,7 +533,13 @@ fn render_workspace_list(app: &AppState, frame: &mut Frame, area: Rect, is_navig // Background fill: selected gets brighter surface, active gets subtle surface if highlighted { - let bg = if selected { p.surface0 } else { p.surface_dim }; + let bg = if selected { + p.surface0 + } else if is_dragged { + p.surface1 + } else { + p.surface_dim + }; let buf = frame.buffer_mut(); for y in row_y..row_y + row_height { if y >= list_bottom { @@ -482,9 +564,7 @@ fn render_workspace_list(app: &AppState, frame: &mut Frame, area: Rect, is_navig } // Styles — active workspace is always visible, selected (nav mode) is brightest - let name_style = if selected { - Style::default().fg(p.text).add_modifier(Modifier::BOLD) - } else if is_active { + let name_style = if selected || is_active || is_dragged { Style::default().fg(p.text).add_modifier(Modifier::BOLD) } else { Style::default().fg(p.subtext0) @@ -569,6 +649,14 @@ fn render_workspace_list(app: &AppState, frame: &mut Frame, area: Rect, is_navig row_y += 1; } + if let Some(y) = insertion_row.filter(|y| *y < list_bottom) { + let buf = frame.buffer_mut(); + for x in area.x..area.x + area.width { + buf[(x, y)].set_symbol("─"); + buf[(x, y)].set_style(Style::default().fg(p.accent)); + } + } + // Footer actions for workspace section if list_bottom > area.y { let new_rect = app.sidebar_new_button_rect(); |
