aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-04-04 01:50:43 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-04-04 01:50:43 +0300
commit951fb1d88be721461783a239c152cf143d741dc4 (patch)
tree32f2946a53f30fd50b9523bf9490f13f330a309d /src
parent831894facfc1339f819b42c3339668154121d24f (diff)
feat: add mouse drag reordering for workspaces
Diffstat (limited to 'src')
-rw-r--r--src/app/actions.rs62
-rw-r--r--src/app/input.rs191
-rw-r--r--src/app/mod.rs2
-rw-r--r--src/app/state.rs20
-rw-r--r--src/ui.rs98
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,
diff --git a/src/ui.rs b/src/ui.rs
index 435e3832..452090cd 100644
--- a/src/ui.rs
+++ b/src/ui.rs
@@ -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();