Merge pull request #39 from bnema/feature/consume-or-expel-pane
Consume/expel pane: cycling fixes + Alt defaults
15 files · +1198 -0
M CHANGELOG.md +1 -0
| @@ -6,6 +6,7 @@ All notable changes to this project will be documented in this file. | |||
| 6 | 6 | ||
| 7 | 7 | ### Added | |
| 8 | 8 | - **Configurable new-pane defaults**: New `workspace.new_pane_url` (default: `about:blank`, supports `dumb://`) and `omnibox.auto_open_on_new_pane` (default: false). | |
| 9 | + | - **Consume-or-expel panes (very alpha)**: Niri-style pane consume/expel operation to merge a leaf into a sibling stack or expel a stacked pane into a split. Default global shortcuts: `Alt+[` / `Alt+]` (and `Alt+Shift+[` / `Alt+Shift+]`). | |
| 9 | 10 | - **Zellij-style pane resize mode**: New modal resize mode (`Ctrl+N`) to adjust split sizes with keyboard (`h/j/k/l` or arrow keys, `+/-` smart resize). Shows a per-pane border indicator and keeps the timeout alive while you resize. | |
| 10 | 11 | - **Mouse-driven pane resizing**: Drag split dividers with the mouse; split ratios are persisted in session snapshots (rounded to 2 decimals) and restored on resurrection. | |
| 11 | 12 | - **Dynamic window title**: Window title now displays the active pane's page title in format `<Page Title> - Dumber`. Updates when switching tabs/panes or navigating. Truncated at 255 characters. | |
| @@ -6,6 +6,7 @@ All notable changes to this project will be documented in this file. | |||||
| 6 | 6 | ||||
| 7 | ### Added | 7 | ### Added | ||
| 8 | - **Configurable new-pane defaults**: New `workspace.new_pane_url` (default: `about:blank`, supports `dumb://`) and `omnibox.auto_open_on_new_pane` (default: false). | 8 | - **Configurable new-pane defaults**: New `workspace.new_pane_url` (default: `about:blank`, supports `dumb://`) and `omnibox.auto_open_on_new_pane` (default: false). | ||
| 9 | + | - **Consume-or-expel panes (very alpha)**: Niri-style pane consume/expel operation to merge a leaf into a sibling stack or expel a stacked pane into a split. Default global shortcuts: `Alt+[` / `Alt+]` (and `Alt+Shift+[` / `Alt+Shift+]`). | |||
| 9 | - **Zellij-style pane resize mode**: New modal resize mode (`Ctrl+N`) to adjust split sizes with keyboard (`h/j/k/l` or arrow keys, `+/-` smart resize). Shows a per-pane border indicator and keeps the timeout alive while you resize. | 10 | - **Zellij-style pane resize mode**: New modal resize mode (`Ctrl+N`) to adjust split sizes with keyboard (`h/j/k/l` or arrow keys, `+/-` smart resize). Shows a per-pane border indicator and keeps the timeout alive while you resize. | ||
| 10 | - **Mouse-driven pane resizing**: Drag split dividers with the mouse; split ratios are persisted in session snapshots (rounded to 2 decimals) and restored on resurrection. | 11 | - **Mouse-driven pane resizing**: Drag split dividers with the mouse; split ratios are persisted in session snapshots (rounded to 2 decimals) and restored on resurrection. | ||
| 11 | - **Dynamic window title**: Window title now displays the active pane's page title in format `<Page Title> - Dumber`. Updates when switching tabs/panes or navigating. Truncated at 255 characters. | 12 | - **Dynamic window title**: Window title now displays the active pane's page title in format `<Page Title> - Dumber`. Updates when switching tabs/panes or navigating. Truncated at 255 characters. | ||
M README.md +4 -0
| @@ -32,6 +32,8 @@ The project is now structured around clean architecture, which makes it much eas | |||
| 32 | 32 | ||
| 33 | 33 | Early development with regular releases. Core features work well for daily use but expect some rough edges. | |
| 34 | 34 | ||
| 35 | + | Some features are very alpha/experimental (notably consume-or-expel panes) and may change behavior between releases. | |
| 36 | + | ||
| 35 | 37 | ## Controls & Shortcuts | |
| 36 | 38 | ||
| 37 | 39 | ### Keyboard Shortcuts | |
| @@ -79,6 +81,8 @@ Early development with regular releases. Core features work well for daily use b | |||
| 79 | 81 | | **Escape** (in pane mode) | Exit Pane Mode | Return to normal navigation | | |
| 80 | 82 | | **Alt+Arrow Keys** | Navigate Panes | Move focus between panes | | |
| 81 | 83 | | **Alt+Up/Down** | Navigate Stack | Navigate between stacked panes | | |
| 84 | + | | **Alt+[** / **Alt+]** | Consume / Expel Pane | Experimental (alpha): merge into sibling stack, or expel out of stack | | |
| 85 | + | | **Alt+Shift+[** / **Alt+Shift+]** | Consume / Expel Pane (Vertical) | Experimental (alpha): up/down variants | | |
| 82 | 86 | ||
| 83 | 87 | #### Resize Mode | |
| 84 | 88 | | Shortcut | Action | Notes | | |
| @@ -32,6 +32,8 @@ The project is now structured around clean architecture, which makes it much eas | |||||
| 32 | 32 | ||||
| 33 | Early development with regular releases. Core features work well for daily use but expect some rough edges. | 33 | Early development with regular releases. Core features work well for daily use but expect some rough edges. | ||
| 34 | 34 | ||||
| 35 | + | Some features are very alpha/experimental (notably consume-or-expel panes) and may change behavior between releases. | |||
| 36 | + | ||||
| 35 | ## Controls & Shortcuts | 37 | ## Controls & Shortcuts | ||
| 36 | 38 | ||||
| 37 | ### Keyboard Shortcuts | 39 | ### Keyboard Shortcuts | ||
| @@ -79,6 +81,8 @@ Early development with regular releases. Core features work well for daily use b | |||||
| 79 | | **Escape** (in pane mode) | Exit Pane Mode | Return to normal navigation | | 81 | | **Escape** (in pane mode) | Exit Pane Mode | Return to normal navigation | | ||
| 80 | | **Alt+Arrow Keys** | Navigate Panes | Move focus between panes | | 82 | | **Alt+Arrow Keys** | Navigate Panes | Move focus between panes | | ||
| 81 | | **Alt+Up/Down** | Navigate Stack | Navigate between stacked panes | | 83 | | **Alt+Up/Down** | Navigate Stack | Navigate between stacked panes | | ||
| 84 | + | | **Alt+[** / **Alt+]** | Consume / Expel Pane | Experimental (alpha): merge into sibling stack, or expel out of stack | | |||
| 85 | + | | **Alt+Shift+[** / **Alt+Shift+]** | Consume / Expel Pane (Vertical) | Experimental (alpha): up/down variants | | |||
| 82 | 86 | ||||
| 83 | #### Resize Mode | 87 | #### Resize Mode | ||
| 84 | | Shortcut | Action | Notes | | 88 | | Shortcut | Action | Notes | | ||
M docs/CONFIG.md +13 -0
| @@ -225,6 +225,13 @@ split-up = ["arrowup", "u"] | |||
| 225 | 225 | split-down = ["arrowdown", "d"] | |
| 226 | 226 | stack-pane = ["s"] | |
| 227 | 227 | close-pane = ["x"] | |
| 228 | + | ||
| 229 | + | # Consume-or-expel (niri-style) - very alpha | |
| 230 | + | consume-or-expel-left = ["bracketleft", "["] | |
| 231 | + | consume-or-expel-right = ["bracketright", "]"] | |
| 232 | + | consume-or-expel-up = ["shift+bracketleft", "braceleft", "{"] | |
| 233 | + | consume-or-expel-down = ["shift+bracketright", "braceright", "}"] | |
| 234 | + | ||
| 228 | 235 | focus-right = ["shift+arrowright", "shift+l"] | |
| 229 | 236 | focus-left = ["shift+arrowleft", "shift+h"] | |
| 230 | 237 | focus-up = ["shift+arrowup", "shift+k"] | |
| @@ -296,8 +303,14 @@ Notes: | |||
| 296 | 303 | | `workspace.shortcuts.close_pane` | string | `"ctrl+w"` | Close active pane (closes tab if last pane) | | |
| 297 | 304 | | `workspace.shortcuts.next_tab` | string | `"ctrl+tab"` | Next tab shortcut | | |
| 298 | 305 | | `workspace.shortcuts.previous_tab` | string | `"ctrl+shift+tab"` | Previous tab shortcut | | |
| 306 | + | | `workspace.shortcuts.consume_or_expel_left` | string | `"alt+bracketleft"` | Consume into left sibling stack, or expel left if stacked (very alpha) | | |
| 307 | + | | `workspace.shortcuts.consume_or_expel_right` | string | `"alt+bracketright"` | Consume into right sibling stack, or expel right if stacked (very alpha) | | |
| 308 | + | | `workspace.shortcuts.consume_or_expel_up` | string | `"alt+shift+bracketleft"` | Consume into upper sibling stack, or expel up if stacked (very alpha) | | |
| 309 | + | | `workspace.shortcuts.consume_or_expel_down` | string | `"alt+shift+bracketright"` | Consume into lower sibling stack, or expel down if stacked (very alpha) | | |
| 299 | 310 | ||
| 300 | 311 | > **Note:** New tab creation uses modal tab mode (Ctrl+T then n/c). This follows the Zellij-style modal keyboard interface. | |
| 312 | + | > | |
| 313 | + | > **Note:** Consume-or-expel is experimental and may change behavior between releases. | |
| 301 | 314 | ||
| 302 | 315 | ### Popup Behavior | |
| 303 | 316 | ||
| @@ -225,6 +225,13 @@ split-up = ["arrowup", "u"] | |||||
| 225 | split-down = ["arrowdown", "d"] | 225 | split-down = ["arrowdown", "d"] | ||
| 226 | stack-pane = ["s"] | 226 | stack-pane = ["s"] | ||
| 227 | close-pane = ["x"] | 227 | close-pane = ["x"] | ||
| 228 | + | ||||
| 229 | + | # Consume-or-expel (niri-style) - very alpha | |||
| 230 | + | consume-or-expel-left = ["bracketleft", "["] | |||
| 231 | + | consume-or-expel-right = ["bracketright", "]"] | |||
| 232 | + | consume-or-expel-up = ["shift+bracketleft", "braceleft", "{"] | |||
| 233 | + | consume-or-expel-down = ["shift+bracketright", "braceright", "}"] | |||
| 234 | + | ||||
| 228 | focus-right = ["shift+arrowright", "shift+l"] | 235 | focus-right = ["shift+arrowright", "shift+l"] | ||
| 229 | focus-left = ["shift+arrowleft", "shift+h"] | 236 | focus-left = ["shift+arrowleft", "shift+h"] | ||
| 230 | focus-up = ["shift+arrowup", "shift+k"] | 237 | focus-up = ["shift+arrowup", "shift+k"] | ||
| @@ -296,8 +303,14 @@ Notes: | |||||
| 296 | | `workspace.shortcuts.close_pane` | string | `"ctrl+w"` | Close active pane (closes tab if last pane) | | 303 | | `workspace.shortcuts.close_pane` | string | `"ctrl+w"` | Close active pane (closes tab if last pane) | | ||
| 297 | | `workspace.shortcuts.next_tab` | string | `"ctrl+tab"` | Next tab shortcut | | 304 | | `workspace.shortcuts.next_tab` | string | `"ctrl+tab"` | Next tab shortcut | | ||
| 298 | | `workspace.shortcuts.previous_tab` | string | `"ctrl+shift+tab"` | Previous tab shortcut | | 305 | | `workspace.shortcuts.previous_tab` | string | `"ctrl+shift+tab"` | Previous tab shortcut | | ||
| 306 | + | | `workspace.shortcuts.consume_or_expel_left` | string | `"alt+bracketleft"` | Consume into left sibling stack, or expel left if stacked (very alpha) | | |||
| 307 | + | | `workspace.shortcuts.consume_or_expel_right` | string | `"alt+bracketright"` | Consume into right sibling stack, or expel right if stacked (very alpha) | | |||
| 308 | + | | `workspace.shortcuts.consume_or_expel_up` | string | `"alt+shift+bracketleft"` | Consume into upper sibling stack, or expel up if stacked (very alpha) | | |||
| 309 | + | | `workspace.shortcuts.consume_or_expel_down` | string | `"alt+shift+bracketright"` | Consume into lower sibling stack, or expel down if stacked (very alpha) | | |||
| 299 | 310 | ||||
| 300 | > **Note:** New tab creation uses modal tab mode (Ctrl+T then n/c). This follows the Zellij-style modal keyboard interface. | 311 | > **Note:** New tab creation uses modal tab mode (Ctrl+T then n/c). This follows the Zellij-style modal keyboard interface. | ||
| 312 | + | > | |||
| 313 | + | > **Note:** Consume-or-expel is experimental and may change behavior between releases. | |||
| 301 | 314 | ||||
| 302 | ### Popup Behavior | 315 | ### Popup Behavior | ||
| 303 | 316 | ||||
M internal/application/usecase/manage_panes.go +579 -0
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | "fmt" | |
| 7 | 7 | "math" | |
| 8 | 8 | "sort" | |
| 9 | + | "strings" | |
| 9 | 10 | ||
| 10 | 11 | "github.com/bnema/dumber/internal/domain/entity" | |
| 11 | 12 | domainurl "github.com/bnema/dumber/internal/domain/url" | |
| @@ -52,6 +53,22 @@ const ( | |||
| 52 | 53 | ||
| 53 | 54 | var ErrNothingToResize = errors.New("nothing to resize") | |
| 54 | 55 | ||
| 56 | + | type ConsumeOrExpelDirection string | |
| 57 | + | ||
| 58 | + | const ( | |
| 59 | + | ConsumeOrExpelLeft ConsumeOrExpelDirection = "left" | |
| 60 | + | ConsumeOrExpelRight ConsumeOrExpelDirection = "right" | |
| 61 | + | ConsumeOrExpelUp ConsumeOrExpelDirection = "up" | |
| 62 | + | ConsumeOrExpelDown ConsumeOrExpelDirection = "down" | |
| 63 | + | ) | |
| 64 | + | ||
| 65 | + | type ConsumeOrExpelResult struct { | |
| 66 | + | Action string // "consumed", "expelled", or "none" | |
| 67 | + | ErrorMessage string | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | const consumeOrExpelExpelCycleMarker = "_expel_cycle" | |
| 71 | + | ||
| 55 | 72 | // ManagePanesUseCase handles pane tree operations. | |
| 56 | 73 | type ManagePanesUseCase struct { | |
| 57 | 74 | idGenerator IDGenerator | |
| @@ -1074,6 +1091,568 @@ func (uc *ManagePanesUseCase) RemoveFromStack(ctx context.Context, stackNode *en | |||
| 1074 | 1091 | return nil | |
| 1075 | 1092 | } | |
| 1076 | 1093 | ||
| 1094 | + | func (uc *ManagePanesUseCase) ConsumeOrExpel( | |
| 1095 | + | ctx context.Context, | |
| 1096 | + | ws *entity.Workspace, | |
| 1097 | + | activeNode *entity.PaneNode, | |
| 1098 | + | direction ConsumeOrExpelDirection, | |
| 1099 | + | ) (*ConsumeOrExpelResult, error) { | |
| 1100 | + | if uc == nil { | |
| 1101 | + | return nil, fmt.Errorf("manage panes use case is nil") | |
| 1102 | + | } | |
| 1103 | + | if ws == nil { | |
| 1104 | + | return nil, fmt.Errorf("workspace is required") | |
| 1105 | + | } | |
| 1106 | + | if ws.Root == nil { | |
| 1107 | + | return &ConsumeOrExpelResult{Action: "none", ErrorMessage: "Only one pane"}, nil | |
| 1108 | + | } | |
| 1109 | + | if activeNode == nil { | |
| 1110 | + | return nil, fmt.Errorf("active pane node is required") | |
| 1111 | + | } | |
| 1112 | + | if activeNode.Pane == nil { | |
| 1113 | + | return nil, fmt.Errorf("active pane node has no pane") | |
| 1114 | + | } | |
| 1115 | + | ||
| 1116 | + | if ws.PaneCount() <= 1 { | |
| 1117 | + | return &ConsumeOrExpelResult{Action: "none", ErrorMessage: "Only one pane"}, nil | |
| 1118 | + | } | |
| 1119 | + | ||
| 1120 | + | // Expel if active pane is inside a stack. | |
| 1121 | + | if activeNode.Parent != nil && activeNode.Parent.IsStacked { | |
| 1122 | + | return uc.expelFromStack(ctx, ws, activeNode, direction) | |
| 1123 | + | } | |
| 1124 | + | ||
| 1125 | + | // Consume if active pane is not inside a stack. | |
| 1126 | + | return uc.consumeIntoSiblingStack(ctx, ws, activeNode, direction) | |
| 1127 | + | } | |
| 1128 | + | ||
| 1129 | + | func (*ManagePanesUseCase) consumeIntoSiblingStack( | |
| 1130 | + | ctx context.Context, | |
| 1131 | + | ws *entity.Workspace, | |
| 1132 | + | activeNode *entity.PaneNode, | |
| 1133 | + | direction ConsumeOrExpelDirection, | |
| 1134 | + | ) (*ConsumeOrExpelResult, error) { | |
| 1135 | + | log := logging.FromContext(ctx) | |
| 1136 | + | ||
| 1137 | + | sibling, _ := findAdjacentSiblingForConsumeOrExpel(activeNode, direction) | |
| 1138 | + | if sibling == nil { | |
| 1139 | + | sibling = fallbackSiblingForConsumeOrExpel(activeNode, direction) | |
| 1140 | + | } | |
| 1141 | + | if sibling == nil { | |
| 1142 | + | return &ConsumeOrExpelResult{Action: "none", ErrorMessage: directionNoSiblingMessage(direction)}, nil | |
| 1143 | + | } | |
| 1144 | + | ||
| 1145 | + | // Left/right consume cycles layout for immediate split siblings: | |
| 1146 | + | // 1) horizontal split -> vertical split (target on top) | |
| 1147 | + | // 2) vertical split -> stack (top then bottom) | |
| 1148 | + | if direction == ConsumeOrExpelLeft || direction == ConsumeOrExpelRight { | |
| 1149 | + | cycled, cycleAction, err := cycleHorizontalToVerticalThenStack(ws, activeNode, sibling, direction) | |
| 1150 | + | if err != nil { | |
| 1151 | + | return nil, err | |
| 1152 | + | } | |
| 1153 | + | if cycled { | |
| 1154 | + | switch cycleAction { | |
| 1155 | + | case "vertical_split": | |
| 1156 | + | log.Info(). | |
| 1157 | + | Str("pane_id", activeNode.ID). | |
| 1158 | + | Str("sibling_id", sibling.ID). | |
| 1159 | + | Msg("pane moved below sibling") | |
| 1160 | + | case "stack": | |
| 1161 | + | log.Info(). | |
| 1162 | + | Str("pane_id", activeNode.ID). | |
| 1163 | + | Str("sibling_id", sibling.ID). | |
| 1164 | + | Msg("split converted to stack") | |
| 1165 | + | } | |
| 1166 | + | return &ConsumeOrExpelResult{Action: "consumed"}, nil | |
| 1167 | + | } | |
| 1168 | + | } | |
| 1169 | + | ||
| 1170 | + | moved, err := detachLeafByPromotingSibling(ws, activeNode) | |
| 1171 | + | if err != nil { | |
| 1172 | + | return nil, err | |
| 1173 | + | } | |
| 1174 | + | moved.Parent = nil | |
| 1175 | + | ||
| 1176 | + | stackNode := sibling | |
| 1177 | + | if stackNode.IsLeaf() { | |
| 1178 | + | stackNode = convertLeafToStackContainer(stackNode) | |
| 1179 | + | } | |
| 1180 | + | if !stackNode.IsStacked { | |
| 1181 | + | return nil, fmt.Errorf("target sibling is not stackable") | |
| 1182 | + | } | |
| 1183 | + | ||
| 1184 | + | // Insert consumed pane at the bottom/end. | |
| 1185 | + | moved.Parent = stackNode | |
| 1186 | + | stackNode.Children = append(stackNode.Children, moved) | |
| 1187 | + | stackNode.ActiveStackIndex = len(stackNode.Children) - 1 | |
| 1188 | + | ws.ActivePaneID = moved.Pane.ID | |
| 1189 | + | ||
| 1190 | + | log.Info(). | |
| 1191 | + | Str("pane_id", moved.ID). | |
| 1192 | + | Str("stack_id", stackNode.ID). | |
| 1193 | + | Int("stack_size", len(stackNode.Children)). | |
| 1194 | + | Msg("pane consumed into sibling stack") | |
| 1195 | + | ||
| 1196 | + | return &ConsumeOrExpelResult{Action: "consumed"}, nil | |
| 1197 | + | } | |
| 1198 | + | ||
| 1199 | + | func (uc *ManagePanesUseCase) expelFromStack( | |
| 1200 | + | ctx context.Context, | |
| 1201 | + | ws *entity.Workspace, | |
| 1202 | + | activeNode *entity.PaneNode, | |
| 1203 | + | direction ConsumeOrExpelDirection, | |
| 1204 | + | ) (*ConsumeOrExpelResult, error) { | |
| 1205 | + | log := logging.FromContext(ctx) | |
| 1206 | + | ||
| 1207 | + | stackNode := activeNode.Parent | |
| 1208 | + | if stackNode == nil || !stackNode.IsStacked { | |
| 1209 | + | return &ConsumeOrExpelResult{Action: "none"}, nil | |
| 1210 | + | } | |
| 1211 | + | ||
| 1212 | + | expelled, err := removeLeafFromStack(stackNode, activeNode) | |
| 1213 | + | if err != nil { | |
| 1214 | + | return nil, err | |
| 1215 | + | } | |
| 1216 | + | expelled.Parent = nil | |
| 1217 | + | ||
| 1218 | + | // If only one pane remains, dissolve stack into a leaf node. | |
| 1219 | + | if len(stackNode.Children) == 1 { | |
| 1220 | + | dissolveStackIntoLeaf(stackNode) | |
| 1221 | + | } | |
| 1222 | + | ||
| 1223 | + | // Split around the remaining container (stackNode) with expelled pane. | |
| 1224 | + | // For left/right, expel cycles stack -> vertical split first: | |
| 1225 | + | // - left: expelled pane above | |
| 1226 | + | // - right: expelled pane below | |
| 1227 | + | splitDirection := direction | |
| 1228 | + | switch direction { | |
| 1229 | + | case ConsumeOrExpelLeft: | |
| 1230 | + | splitDirection = ConsumeOrExpelUp | |
| 1231 | + | case ConsumeOrExpelRight: | |
| 1232 | + | splitDirection = ConsumeOrExpelDown | |
| 1233 | + | } | |
| 1234 | + | ||
| 1235 | + | if direction == ConsumeOrExpelLeft || direction == ConsumeOrExpelRight { | |
| 1236 | + | err := splitExistingNodeWithMarker( | |
| 1237 | + | ws, | |
| 1238 | + | stackNode, | |
| 1239 | + | expelled, | |
| 1240 | + | splitDirection, | |
| 1241 | + | uc.idGenerator, | |
| 1242 | + | consumeOrExpelExpelCycleMarker, | |
| 1243 | + | ) | |
| 1244 | + | if err != nil { | |
| 1245 | + | return nil, err | |
| 1246 | + | } | |
| 1247 | + | } else { | |
| 1248 | + | if err := splitExistingNode(ws, stackNode, expelled, splitDirection, uc.idGenerator); err != nil { | |
| 1249 | + | return nil, err | |
| 1250 | + | } | |
| 1251 | + | } | |
| 1252 | + | ws.ActivePaneID = expelled.Pane.ID | |
| 1253 | + | ||
| 1254 | + | log.Info(). | |
| 1255 | + | Str("pane_id", expelled.ID). | |
| 1256 | + | Str("direction", string(direction)). | |
| 1257 | + | Msg("pane expelled from stack") | |
| 1258 | + | ||
| 1259 | + | return &ConsumeOrExpelResult{Action: "expelled"}, nil | |
| 1260 | + | } | |
| 1261 | + | ||
| 1262 | + | func directionNoSiblingMessage(direction ConsumeOrExpelDirection) string { | |
| 1263 | + | switch direction { | |
| 1264 | + | case ConsumeOrExpelLeft: | |
| 1265 | + | return "No pane to the left" | |
| 1266 | + | case ConsumeOrExpelRight: | |
| 1267 | + | return "No pane to the right" | |
| 1268 | + | case ConsumeOrExpelUp: | |
| 1269 | + | return "No pane above" | |
| 1270 | + | case ConsumeOrExpelDown: | |
| 1271 | + | return "No pane below" | |
| 1272 | + | default: | |
| 1273 | + | return "No pane in that direction" | |
| 1274 | + | } | |
| 1275 | + | } | |
| 1276 | + | ||
| 1277 | + | func findAdjacentSiblingForConsumeOrExpel(node *entity.PaneNode, direction ConsumeOrExpelDirection) (*entity.PaneNode, string) { | |
| 1278 | + | if node == nil { | |
| 1279 | + | return nil, "" | |
| 1280 | + | } | |
| 1281 | + | ||
| 1282 | + | // Determine if we need to go horizontal or vertical. | |
| 1283 | + | isHorizontal := direction == ConsumeOrExpelLeft || direction == ConsumeOrExpelRight | |
| 1284 | + | isForward := direction == ConsumeOrExpelRight || direction == ConsumeOrExpelDown | |
| 1285 | + | ||
| 1286 | + | // Walk up the tree to find an ancestor split matching our direction axis. | |
| 1287 | + | current := node | |
| 1288 | + | for current.Parent != nil { | |
| 1289 | + | parent := current.Parent | |
| 1290 | + | if parent.IsSplit() { | |
| 1291 | + | parentIsHorizontal := parent.SplitDir == entity.SplitHorizontal | |
| 1292 | + | if parentIsHorizontal == isHorizontal { | |
| 1293 | + | if isForward { | |
| 1294 | + | if parent.Right() != nil && parent.Left() == current { | |
| 1295 | + | return parent.Right(), "" | |
| 1296 | + | } | |
| 1297 | + | } else { | |
| 1298 | + | if parent.Left() != nil && parent.Right() == current { | |
| 1299 | + | return parent.Left(), "" | |
| 1300 | + | } | |
| 1301 | + | } | |
| 1302 | + | } | |
| 1303 | + | } | |
| 1304 | + | current = parent | |
| 1305 | + | } | |
| 1306 | + | ||
| 1307 | + | return nil, directionNoSiblingMessage(direction) | |
| 1308 | + | } | |
| 1309 | + | ||
| 1310 | + | func fallbackSiblingForConsumeOrExpel(node *entity.PaneNode, direction ConsumeOrExpelDirection) *entity.PaneNode { | |
| 1311 | + | if node == nil || node.Parent == nil { | |
| 1312 | + | return nil | |
| 1313 | + | } | |
| 1314 | + | ||
| 1315 | + | parent := node.Parent | |
| 1316 | + | if !parent.IsSplit() { | |
| 1317 | + | return nil | |
| 1318 | + | } | |
| 1319 | + | ||
| 1320 | + | axisHorizontal := direction == ConsumeOrExpelLeft || direction == ConsumeOrExpelRight | |
| 1321 | + | if axisHorizontal { | |
| 1322 | + | if parent.SplitDir == entity.SplitHorizontal { | |
| 1323 | + | return nil | |
| 1324 | + | } | |
| 1325 | + | } else { | |
| 1326 | + | if parent.SplitDir == entity.SplitVertical { | |
| 1327 | + | return nil | |
| 1328 | + | } | |
| 1329 | + | } | |
| 1330 | + | ||
| 1331 | + | // Cross-axis fallback is only used to enable the leaf cycling behavior | |
| 1332 | + | // (H -> V -> stack, and after expel: V -> H). It should never cause a leaf to | |
| 1333 | + | // consume into a stacked/split sibling when pressing an unrelated axis key. | |
| 1334 | + | var sibling *entity.PaneNode | |
| 1335 | + | if parent.Left() == node { | |
| 1336 | + | sibling = parent.Right() | |
| 1337 | + | } else if parent.Right() == node { | |
| 1338 | + | sibling = parent.Left() | |
| 1339 | + | } | |
| 1340 | + | if sibling == nil { | |
| 1341 | + | return nil | |
| 1342 | + | } | |
| 1343 | + | if !node.IsLeaf() || !sibling.IsLeaf() { | |
| 1344 | + | return nil | |
| 1345 | + | } | |
| 1346 | + | return sibling | |
| 1347 | + | } | |
| 1348 | + | ||
| 1349 | + | func cycleHorizontalToVerticalThenStack( | |
| 1350 | + | ws *entity.Workspace, | |
| 1351 | + | activeNode *entity.PaneNode, | |
| 1352 | + | sibling *entity.PaneNode, | |
| 1353 | + | direction ConsumeOrExpelDirection, | |
| 1354 | + | ) (cycled bool, cycleAction string, err error) { | |
| 1355 | + | if ws == nil { | |
| 1356 | + | return false, "", fmt.Errorf("workspace is required") | |
| 1357 | + | } | |
| 1358 | + | if activeNode == nil || sibling == nil { | |
| 1359 | + | return false, "", nil | |
| 1360 | + | } | |
| 1361 | + | if !activeNode.IsLeaf() || !sibling.IsLeaf() { | |
| 1362 | + | return false, "", nil | |
| 1363 | + | } | |
| 1364 | + | ||
| 1365 | + | parent := activeNode.Parent | |
| 1366 | + | if parent == nil || sibling.Parent != parent || !parent.IsSplit() { | |
| 1367 | + | return false, "", nil | |
| 1368 | + | } | |
| 1369 | + | ||
| 1370 | + | switch parent.SplitDir { | |
| 1371 | + | case entity.SplitHorizontal: | |
| 1372 | + | // Horizontal -> Vertical: target sibling should be on top. | |
| 1373 | + | parent.SplitDir = entity.SplitVertical | |
| 1374 | + | if parent.Children[0] != sibling { | |
| 1375 | + | parent.Children[0], parent.Children[1] = parent.Children[1], parent.Children[0] | |
| 1376 | + | } | |
| 1377 | + | ws.ActivePaneID = activeNode.Pane.ID | |
| 1378 | + | return true, "vertical_split", nil | |
| 1379 | + | case entity.SplitVertical: | |
| 1380 | + | // Vertical -> Horizontal when pressing left/right (reverse of first step). | |
| 1381 | + | // This is used after expel: stack -> vertical; then Ctrl+[ / Ctrl+] should turn it into left/right. | |
| 1382 | + | if direction == ConsumeOrExpelRight && strings.Contains(parent.ID, consumeOrExpelExpelCycleMarker) { | |
| 1383 | + | parent.SplitDir = entity.SplitHorizontal | |
| 1384 | + | // Active should become the right child. | |
| 1385 | + | if parent.Children[1] != activeNode { | |
| 1386 | + | parent.Children[0], parent.Children[1] = parent.Children[1], parent.Children[0] | |
| 1387 | + | } | |
| 1388 | + | ws.ActivePaneID = activeNode.Pane.ID | |
| 1389 | + | return true, "horizontal_split", nil | |
| 1390 | + | } | |
| 1391 | + | ||
| 1392 | + | // Otherwise, vertical -> stack. | |
| 1393 | + | parent.SplitDir = entity.SplitNone | |
| 1394 | + | parent.SplitRatio = 0 | |
| 1395 | + | parent.IsStacked = true | |
| 1396 | + | parent.Pane = nil | |
| 1397 | + | if parent.Children[0] == activeNode { | |
| 1398 | + | parent.ActiveStackIndex = 0 | |
| 1399 | + | } else if parent.Children[1] == activeNode { | |
| 1400 | + | parent.ActiveStackIndex = 1 | |
| 1401 | + | } else { | |
| 1402 | + | parent.ActiveStackIndex = 0 | |
| 1403 | + | } | |
| 1404 | + | ws.ActivePaneID = activeNode.Pane.ID | |
| 1405 | + | return true, "stack", nil | |
| 1406 | + | default: | |
| 1407 | + | return false, "", nil | |
| 1408 | + | } | |
| 1409 | + | } | |
| 1410 | + | ||
| 1411 | + | func detachLeafByPromotingSibling(ws *entity.Workspace, leaf *entity.PaneNode) (*entity.PaneNode, error) { | |
| 1412 | + | if ws == nil { | |
| 1413 | + | return nil, fmt.Errorf("workspace is required") | |
| 1414 | + | } | |
| 1415 | + | if leaf == nil { | |
| 1416 | + | return nil, fmt.Errorf("leaf is required") | |
| 1417 | + | } | |
| 1418 | + | if !leaf.IsLeaf() { | |
| 1419 | + | return nil, fmt.Errorf("can only detach leaf node") | |
| 1420 | + | } | |
| 1421 | + | ||
| 1422 | + | parent := leaf.Parent | |
| 1423 | + | if parent == nil { | |
| 1424 | + | return nil, fmt.Errorf("cannot detach root leaf") | |
| 1425 | + | } | |
| 1426 | + | if !parent.IsSplit() { | |
| 1427 | + | return nil, fmt.Errorf("leaf parent is not a split") | |
| 1428 | + | } | |
| 1429 | + | ||
| 1430 | + | var sibling *entity.PaneNode | |
| 1431 | + | for _, child := range parent.Children { | |
| 1432 | + | if child != leaf { | |
| 1433 | + | sibling = child | |
| 1434 | + | break | |
| 1435 | + | } | |
| 1436 | + | } | |
| 1437 | + | if sibling == nil { | |
| 1438 | + | return nil, fmt.Errorf("no sibling found for leaf") | |
| 1439 | + | } | |
| 1440 | + | ||
| 1441 | + | grandparent := parent.Parent | |
| 1442 | + | if grandparent == nil { | |
| 1443 | + | ws.Root = sibling | |
| 1444 | + | sibling.Parent = nil | |
| 1445 | + | } else { | |
| 1446 | + | for i, child := range grandparent.Children { | |
| 1447 | + | if child == parent { | |
| 1448 | + | grandparent.Children[i] = sibling | |
| 1449 | + | break | |
| 1450 | + | } | |
| 1451 | + | } | |
| 1452 | + | sibling.Parent = grandparent | |
| 1453 | + | } | |
| 1454 | + | ||
| 1455 | + | leaf.Parent = nil | |
| 1456 | + | return leaf, nil | |
| 1457 | + | } | |
| 1458 | + | ||
| 1459 | + | func convertLeafToStackContainer(leaf *entity.PaneNode) *entity.PaneNode { | |
| 1460 | + | if leaf == nil || !leaf.IsLeaf() { | |
| 1461 | + | return leaf | |
| 1462 | + | } | |
| 1463 | + | ||
| 1464 | + | originalPane := leaf.Pane | |
| 1465 | + | originalChildNode := &entity.PaneNode{ | |
| 1466 | + | ID: leaf.ID + "_0", | |
| 1467 | + | Pane: originalPane, | |
| 1468 | + | Parent: leaf, | |
| 1469 | + | } | |
| 1470 | + | ||
| 1471 | + | leaf.Pane = nil | |
| 1472 | + | leaf.IsStacked = true | |
| 1473 | + | leaf.ActiveStackIndex = 0 | |
| 1474 | + | leaf.Children = []*entity.PaneNode{originalChildNode} | |
| 1475 | + | ||
| 1476 | + | return leaf | |
| 1477 | + | } | |
| 1478 | + | ||
| 1479 | + | func removeLeafFromStack(stackNode, leaf *entity.PaneNode) (*entity.PaneNode, error) { | |
| 1480 | + | if stackNode == nil { | |
| 1481 | + | return nil, fmt.Errorf("stack node is required") | |
| 1482 | + | } | |
| 1483 | + | if !stackNode.IsStacked { | |
| 1484 | + | return nil, fmt.Errorf("node is not a stack") | |
| 1485 | + | } | |
| 1486 | + | if leaf == nil || leaf.Pane == nil { | |
| 1487 | + | return nil, fmt.Errorf("leaf is required") | |
| 1488 | + | } | |
| 1489 | + | ||
| 1490 | + | idx := -1 | |
| 1491 | + | for i, child := range stackNode.Children { | |
| 1492 | + | if child == leaf { | |
| 1493 | + | idx = i | |
| 1494 | + | break | |
| 1495 | + | } | |
| 1496 | + | } | |
| 1497 | + | if idx < 0 { | |
| 1498 | + | return nil, fmt.Errorf("pane not found in stack") | |
| 1499 | + | } | |
| 1500 | + | ||
| 1501 | + | stackNode.Children = append(stackNode.Children[:idx], stackNode.Children[idx+1:]...) | |
| 1502 | + | if stackNode.ActiveStackIndex >= len(stackNode.Children) { | |
| 1503 | + | stackNode.ActiveStackIndex = len(stackNode.Children) - 1 | |
| 1504 | + | } | |
| 1505 | + | if stackNode.ActiveStackIndex < 0 { | |
| 1506 | + | stackNode.ActiveStackIndex = 0 | |
| 1507 | + | } | |
| 1508 | + | ||
| 1509 | + | leaf.Parent = nil | |
| 1510 | + | return leaf, nil | |
| 1511 | + | } | |
| 1512 | + | ||
| 1513 | + | func dissolveStackIntoLeaf(stackNode *entity.PaneNode) { | |
| 1514 | + | if stackNode == nil || !stackNode.IsStacked || len(stackNode.Children) != 1 { | |
| 1515 | + | return | |
| 1516 | + | } | |
| 1517 | + | ||
| 1518 | + | remaining := stackNode.Children[0] | |
| 1519 | + | stackNode.Pane = remaining.Pane | |
| 1520 | + | stackNode.Children = nil | |
| 1521 | + | stackNode.IsStacked = false | |
| 1522 | + | stackNode.ActiveStackIndex = 0 | |
| 1523 | + | } | |
| 1524 | + | ||
| 1525 | + | func splitExistingNode( | |
| 1526 | + | ws *entity.Workspace, | |
| 1527 | + | targetNode *entity.PaneNode, | |
| 1528 | + | newSibling *entity.PaneNode, | |
| 1529 | + | direction ConsumeOrExpelDirection, | |
| 1530 | + | idGen IDGenerator, | |
| 1531 | + | ) error { | |
| 1532 | + | if ws == nil { | |
| 1533 | + | return fmt.Errorf("workspace is required") | |
| 1534 | + | } | |
| 1535 | + | if targetNode == nil { | |
| 1536 | + | return fmt.Errorf("target node is required") | |
| 1537 | + | } | |
| 1538 | + | if newSibling == nil { | |
| 1539 | + | return fmt.Errorf("new sibling node is required") | |
| 1540 | + | } | |
| 1541 | + | if idGen == nil { | |
| 1542 | + | return fmt.Errorf("id generator is required") | |
| 1543 | + | } | |
| 1544 | + | ||
| 1545 | + | var splitDir entity.SplitDirection | |
| 1546 | + | switch direction { | |
| 1547 | + | case ConsumeOrExpelLeft, ConsumeOrExpelRight: | |
| 1548 | + | splitDir = entity.SplitHorizontal | |
| 1549 | + | case ConsumeOrExpelUp, ConsumeOrExpelDown: | |
| 1550 | + | splitDir = entity.SplitVertical | |
| 1551 | + | default: | |
| 1552 | + | return fmt.Errorf("invalid direction: %s", direction) | |
| 1553 | + | } | |
| 1554 | + | ||
| 1555 | + | parentNode := &entity.PaneNode{ | |
| 1556 | + | ID: idGen(), | |
| 1557 | + | SplitDir: splitDir, | |
| 1558 | + | SplitRatio: 0.5, | |
| 1559 | + | Children: make([]*entity.PaneNode, 2), | |
| 1560 | + | } | |
| 1561 | + | ||
| 1562 | + | switch direction { | |
| 1563 | + | case ConsumeOrExpelLeft, ConsumeOrExpelUp: | |
| 1564 | + | parentNode.Children[0] = newSibling | |
| 1565 | + | parentNode.Children[1] = targetNode | |
| 1566 | + | case ConsumeOrExpelRight, ConsumeOrExpelDown: | |
| 1567 | + | parentNode.Children[0] = targetNode | |
| 1568 | + | parentNode.Children[1] = newSibling | |
| 1569 | + | } | |
| 1570 | + | ||
| 1571 | + | newSibling.Parent = parentNode | |
| 1572 | + | oldParent := targetNode.Parent | |
| 1573 | + | targetNode.Parent = parentNode | |
| 1574 | + | ||
| 1575 | + | if oldParent == nil { | |
| 1576 | + | ws.Root = parentNode | |
| 1577 | + | } else { | |
| 1578 | + | for i, child := range oldParent.Children { | |
| 1579 | + | if child == targetNode { | |
| 1580 | + | oldParent.Children[i] = parentNode | |
| 1581 | + | break | |
| 1582 | + | } | |
| 1583 | + | } | |
| 1584 | + | parentNode.Parent = oldParent | |
| 1585 | + | } | |
| 1586 | + | ||
| 1587 | + | return nil | |
| 1588 | + | } | |
| 1589 | + | ||
| 1590 | + | func splitExistingNodeWithMarker( | |
| 1591 | + | ws *entity.Workspace, | |
| 1592 | + | targetNode *entity.PaneNode, | |
| 1593 | + | newSibling *entity.PaneNode, | |
| 1594 | + | direction ConsumeOrExpelDirection, | |
| 1595 | + | idGen IDGenerator, | |
| 1596 | + | marker string, | |
| 1597 | + | ) error { | |
| 1598 | + | if ws == nil { | |
| 1599 | + | return fmt.Errorf("workspace is required") | |
| 1600 | + | } | |
| 1601 | + | if targetNode == nil { | |
| 1602 | + | return fmt.Errorf("target node is required") | |
| 1603 | + | } | |
| 1604 | + | if newSibling == nil { | |
| 1605 | + | return fmt.Errorf("new sibling node is required") | |
| 1606 | + | } | |
| 1607 | + | if idGen == nil { | |
| 1608 | + | return fmt.Errorf("id generator is required") | |
| 1609 | + | } | |
| 1610 | + | ||
| 1611 | + | var splitDir entity.SplitDirection | |
| 1612 | + | switch direction { | |
| 1613 | + | case ConsumeOrExpelLeft, ConsumeOrExpelRight: | |
| 1614 | + | splitDir = entity.SplitHorizontal | |
| 1615 | + | case ConsumeOrExpelUp, ConsumeOrExpelDown: | |
| 1616 | + | splitDir = entity.SplitVertical | |
| 1617 | + | default: | |
| 1618 | + | return fmt.Errorf("invalid direction: %s", direction) | |
| 1619 | + | } | |
| 1620 | + | ||
| 1621 | + | parentNode := &entity.PaneNode{ | |
| 1622 | + | ID: idGen() + marker, | |
| 1623 | + | SplitDir: splitDir, | |
| 1624 | + | SplitRatio: 0.5, | |
| 1625 | + | Children: make([]*entity.PaneNode, 2), | |
| 1626 | + | } | |
| 1627 | + | ||
| 1628 | + | switch direction { | |
| 1629 | + | case ConsumeOrExpelLeft, ConsumeOrExpelUp: | |
| 1630 | + | parentNode.Children[0] = newSibling | |
| 1631 | + | parentNode.Children[1] = targetNode | |
| 1632 | + | case ConsumeOrExpelRight, ConsumeOrExpelDown: | |
| 1633 | + | parentNode.Children[0] = targetNode | |
| 1634 | + | parentNode.Children[1] = newSibling | |
| 1635 | + | } | |
| 1636 | + | ||
| 1637 | + | newSibling.Parent = parentNode | |
| 1638 | + | oldParent := targetNode.Parent | |
| 1639 | + | targetNode.Parent = parentNode | |
| 1640 | + | ||
| 1641 | + | if oldParent == nil { | |
| 1642 | + | ws.Root = parentNode | |
| 1643 | + | } else { | |
| 1644 | + | for i, child := range oldParent.Children { | |
| 1645 | + | if child == targetNode { | |
| 1646 | + | oldParent.Children[i] = parentNode | |
| 1647 | + | break | |
| 1648 | + | } | |
| 1649 | + | } | |
| 1650 | + | parentNode.Parent = oldParent | |
| 1651 | + | } | |
| 1652 | + | ||
| 1653 | + | return nil | |
| 1654 | + | } | |
| 1655 | + | ||
| 1077 | 1656 | // GetAllPanes returns all leaf panes in a workspace. | |
| 1078 | 1657 | // | |
| 1079 | 1658 | //nolint:revive // receiver required for interface consistency | |
| @@ -6,6 +6,7 @@ import ( | |||||
| 6 | "fmt" | 6 | "fmt" | ||
| 7 | "math" | 7 | "math" | ||
| 8 | "sort" | 8 | "sort" | ||
| 9 | + | "strings" | |||
| 9 | 10 | ||||
| 10 | "github.com/bnema/dumber/internal/domain/entity" | 11 | "github.com/bnema/dumber/internal/domain/entity" | ||
| 11 | domainurl "github.com/bnema/dumber/internal/domain/url" | 12 | domainurl "github.com/bnema/dumber/internal/domain/url" | ||
| @@ -52,6 +53,22 @@ const ( | |||||
| 52 | 53 | ||||
| 53 | var ErrNothingToResize = errors.New("nothing to resize") | 54 | var ErrNothingToResize = errors.New("nothing to resize") | ||
| 54 | 55 | ||||
| 56 | + | type ConsumeOrExpelDirection string | |||
| 57 | + | ||||
| 58 | + | const ( | |||
| 59 | + | ConsumeOrExpelLeft ConsumeOrExpelDirection = "left" | |||
| 60 | + | ConsumeOrExpelRight ConsumeOrExpelDirection = "right" | |||
| 61 | + | ConsumeOrExpelUp ConsumeOrExpelDirection = "up" | |||
| 62 | + | ConsumeOrExpelDown ConsumeOrExpelDirection = "down" | |||
| 63 | + | ) | |||
| 64 | + | ||||
| 65 | + | type ConsumeOrExpelResult struct { | |||
| 66 | + | Action string // "consumed", "expelled", or "none" | |||
| 67 | + | ErrorMessage string | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | const consumeOrExpelExpelCycleMarker = "_expel_cycle" | |||
| 71 | + | ||||
| 55 | // ManagePanesUseCase handles pane tree operations. | 72 | // ManagePanesUseCase handles pane tree operations. | ||
| 56 | type ManagePanesUseCase struct { | 73 | type ManagePanesUseCase struct { | ||
| 57 | idGenerator IDGenerator | 74 | idGenerator IDGenerator | ||
| @@ -1074,6 +1091,568 @@ func (uc *ManagePanesUseCase) RemoveFromStack(ctx context.Context, stackNode *en | |||||
| 1074 | return nil | 1091 | return nil | ||
| 1075 | } | 1092 | } | ||
| 1076 | 1093 | ||||
| 1094 | + | func (uc *ManagePanesUseCase) ConsumeOrExpel( | |||
| 1095 | + | ctx context.Context, | |||
| 1096 | + | ws *entity.Workspace, | |||
| 1097 | + | activeNode *entity.PaneNode, | |||
| 1098 | + | direction ConsumeOrExpelDirection, | |||
| 1099 | + | ) (*ConsumeOrExpelResult, error) { | |||
| 1100 | + | if uc == nil { | |||
| 1101 | + | return nil, fmt.Errorf("manage panes use case is nil") | |||
| 1102 | + | } | |||
| 1103 | + | if ws == nil { | |||
| 1104 | + | return nil, fmt.Errorf("workspace is required") | |||
| 1105 | + | } | |||
| 1106 | + | if ws.Root == nil { | |||
| 1107 | + | return &ConsumeOrExpelResult{Action: "none", ErrorMessage: "Only one pane"}, nil | |||
| 1108 | + | } | |||
| 1109 | + | if activeNode == nil { | |||
| 1110 | + | return nil, fmt.Errorf("active pane node is required") | |||
| 1111 | + | } | |||
| 1112 | + | if activeNode.Pane == nil { | |||
| 1113 | + | return nil, fmt.Errorf("active pane node has no pane") | |||
| 1114 | + | } | |||
| 1115 | + | ||||
| 1116 | + | if ws.PaneCount() <= 1 { | |||
| 1117 | + | return &ConsumeOrExpelResult{Action: "none", ErrorMessage: "Only one pane"}, nil | |||
| 1118 | + | } | |||
| 1119 | + | ||||
| 1120 | + | // Expel if active pane is inside a stack. | |||
| 1121 | + | if activeNode.Parent != nil && activeNode.Parent.IsStacked { | |||
| 1122 | + | return uc.expelFromStack(ctx, ws, activeNode, direction) | |||
| 1123 | + | } | |||
| 1124 | + | ||||
| 1125 | + | // Consume if active pane is not inside a stack. | |||
| 1126 | + | return uc.consumeIntoSiblingStack(ctx, ws, activeNode, direction) | |||
| 1127 | + | } | |||
| 1128 | + | ||||
| 1129 | + | func (*ManagePanesUseCase) consumeIntoSiblingStack( | |||
| 1130 | + | ctx context.Context, | |||
| 1131 | + | ws *entity.Workspace, | |||
| 1132 | + | activeNode *entity.PaneNode, | |||
| 1133 | + | direction ConsumeOrExpelDirection, | |||
| 1134 | + | ) (*ConsumeOrExpelResult, error) { | |||
| 1135 | + | log := logging.FromContext(ctx) | |||
| 1136 | + | ||||
| 1137 | + | sibling, _ := findAdjacentSiblingForConsumeOrExpel(activeNode, direction) | |||
| 1138 | + | if sibling == nil { | |||
| 1139 | + | sibling = fallbackSiblingForConsumeOrExpel(activeNode, direction) | |||
| 1140 | + | } | |||
| 1141 | + | if sibling == nil { | |||
| 1142 | + | return &ConsumeOrExpelResult{Action: "none", ErrorMessage: directionNoSiblingMessage(direction)}, nil | |||
| 1143 | + | } | |||
| 1144 | + | ||||
| 1145 | + | // Left/right consume cycles layout for immediate split siblings: | |||
| 1146 | + | // 1) horizontal split -> vertical split (target on top) | |||
| 1147 | + | // 2) vertical split -> stack (top then bottom) | |||
| 1148 | + | if direction == ConsumeOrExpelLeft || direction == ConsumeOrExpelRight { | |||
| 1149 | + | cycled, cycleAction, err := cycleHorizontalToVerticalThenStack(ws, activeNode, sibling, direction) | |||
| 1150 | + | if err != nil { | |||
| 1151 | + | return nil, err | |||
| 1152 | + | } | |||
| 1153 | + | if cycled { | |||
| 1154 | + | switch cycleAction { | |||
| 1155 | + | case "vertical_split": | |||
| 1156 | + | log.Info(). | |||
| 1157 | + | Str("pane_id", activeNode.ID). | |||
| 1158 | + | Str("sibling_id", sibling.ID). | |||
| 1159 | + | Msg("pane moved below sibling") | |||
| 1160 | + | case "stack": | |||
| 1161 | + | log.Info(). | |||
| 1162 | + | Str("pane_id", activeNode.ID). | |||
| 1163 | + | Str("sibling_id", sibling.ID). | |||
| 1164 | + | Msg("split converted to stack") | |||
| 1165 | + | } | |||
| 1166 | + | return &ConsumeOrExpelResult{Action: "consumed"}, nil | |||
| 1167 | + | } | |||
| 1168 | + | } | |||
| 1169 | + | ||||
| 1170 | + | moved, err := detachLeafByPromotingSibling(ws, activeNode) | |||
| 1171 | + | if err != nil { | |||
| 1172 | + | return nil, err | |||
| 1173 | + | } | |||
| 1174 | + | moved.Parent = nil | |||
| 1175 | + | ||||
| 1176 | + | stackNode := sibling | |||
| 1177 | + | if stackNode.IsLeaf() { | |||
| 1178 | + | stackNode = convertLeafToStackContainer(stackNode) | |||
| 1179 | + | } | |||
| 1180 | + | if !stackNode.IsStacked { | |||
| 1181 | + | return nil, fmt.Errorf("target sibling is not stackable") | |||
| 1182 | + | } | |||
| 1183 | + | ||||
| 1184 | + | // Insert consumed pane at the bottom/end. | |||
| 1185 | + | moved.Parent = stackNode | |||
| 1186 | + | stackNode.Children = append(stackNode.Children, moved) | |||
| 1187 | + | stackNode.ActiveStackIndex = len(stackNode.Children) - 1 | |||
| 1188 | + | ws.ActivePaneID = moved.Pane.ID | |||
| 1189 | + | ||||
| 1190 | + | log.Info(). | |||
| 1191 | + | Str("pane_id", moved.ID). | |||
| 1192 | + | Str("stack_id", stackNode.ID). | |||
| 1193 | + | Int("stack_size", len(stackNode.Children)). | |||
| 1194 | + | Msg("pane consumed into sibling stack") | |||
| 1195 | + | ||||
| 1196 | + | return &ConsumeOrExpelResult{Action: "consumed"}, nil | |||
| 1197 | + | } | |||
| 1198 | + | ||||
| 1199 | + | func (uc *ManagePanesUseCase) expelFromStack( | |||
| 1200 | + | ctx context.Context, | |||
| 1201 | + | ws *entity.Workspace, | |||
| 1202 | + | activeNode *entity.PaneNode, | |||
| 1203 | + | direction ConsumeOrExpelDirection, | |||
| 1204 | + | ) (*ConsumeOrExpelResult, error) { | |||
| 1205 | + | log := logging.FromContext(ctx) | |||
| 1206 | + | ||||
| 1207 | + | stackNode := activeNode.Parent | |||
| 1208 | + | if stackNode == nil || !stackNode.IsStacked { | |||
| 1209 | + | return &ConsumeOrExpelResult{Action: "none"}, nil | |||
| 1210 | + | } | |||
| 1211 | + | ||||
| 1212 | + | expelled, err := removeLeafFromStack(stackNode, activeNode) | |||
| 1213 | + | if err != nil { | |||
| 1214 | + | return nil, err | |||
| 1215 | + | } | |||
| 1216 | + | expelled.Parent = nil | |||
| 1217 | + | ||||
| 1218 | + | // If only one pane remains, dissolve stack into a leaf node. | |||
| 1219 | + | if len(stackNode.Children) == 1 { | |||
| 1220 | + | dissolveStackIntoLeaf(stackNode) | |||
| 1221 | + | } | |||
| 1222 | + | ||||
| 1223 | + | // Split around the remaining container (stackNode) with expelled pane. | |||
| 1224 | + | // For left/right, expel cycles stack -> vertical split first: | |||
| 1225 | + | // - left: expelled pane above | |||
| 1226 | + | // - right: expelled pane below | |||
| 1227 | + | splitDirection := direction | |||
| 1228 | + | switch direction { | |||
| 1229 | + | case ConsumeOrExpelLeft: | |||
| 1230 | + | splitDirection = ConsumeOrExpelUp | |||
| 1231 | + | case ConsumeOrExpelRight: | |||
| 1232 | + | splitDirection = ConsumeOrExpelDown | |||
| 1233 | + | } | |||
| 1234 | + | ||||
| 1235 | + | if direction == ConsumeOrExpelLeft || direction == ConsumeOrExpelRight { | |||
| 1236 | + | err := splitExistingNodeWithMarker( | |||
| 1237 | + | ws, | |||
| 1238 | + | stackNode, | |||
| 1239 | + | expelled, | |||
| 1240 | + | splitDirection, | |||
| 1241 | + | uc.idGenerator, | |||
| 1242 | + | consumeOrExpelExpelCycleMarker, | |||
| 1243 | + | ) | |||
| 1244 | + | if err != nil { | |||
| 1245 | + | return nil, err | |||
| 1246 | + | } | |||
| 1247 | + | } else { | |||
| 1248 | + | if err := splitExistingNode(ws, stackNode, expelled, splitDirection, uc.idGenerator); err != nil { | |||
| 1249 | + | return nil, err | |||
| 1250 | + | } | |||
| 1251 | + | } | |||
| 1252 | + | ws.ActivePaneID = expelled.Pane.ID | |||
| 1253 | + | ||||
| 1254 | + | log.Info(). | |||
| 1255 | + | Str("pane_id", expelled.ID). | |||
| 1256 | + | Str("direction", string(direction)). | |||
| 1257 | + | Msg("pane expelled from stack") | |||
| 1258 | + | ||||
| 1259 | + | return &ConsumeOrExpelResult{Action: "expelled"}, nil | |||
| 1260 | + | } | |||
| 1261 | + | ||||
| 1262 | + | func directionNoSiblingMessage(direction ConsumeOrExpelDirection) string { | |||
| 1263 | + | switch direction { | |||
| 1264 | + | case ConsumeOrExpelLeft: | |||
| 1265 | + | return "No pane to the left" | |||
| 1266 | + | case ConsumeOrExpelRight: | |||
| 1267 | + | return "No pane to the right" | |||
| 1268 | + | case ConsumeOrExpelUp: | |||
| 1269 | + | return "No pane above" | |||
| 1270 | + | case ConsumeOrExpelDown: | |||
| 1271 | + | return "No pane below" | |||
| 1272 | + | default: | |||
| 1273 | + | return "No pane in that direction" | |||
| 1274 | + | } | |||
| 1275 | + | } | |||
| 1276 | + | ||||
| 1277 | + | func findAdjacentSiblingForConsumeOrExpel(node *entity.PaneNode, direction ConsumeOrExpelDirection) (*entity.PaneNode, string) { | |||
| 1278 | + | if node == nil { | |||
| 1279 | + | return nil, "" | |||
| 1280 | + | } | |||
| 1281 | + | ||||
| 1282 | + | // Determine if we need to go horizontal or vertical. | |||
| 1283 | + | isHorizontal := direction == ConsumeOrExpelLeft || direction == ConsumeOrExpelRight | |||
| 1284 | + | isForward := direction == ConsumeOrExpelRight || direction == ConsumeOrExpelDown | |||
| 1285 | + | ||||
| 1286 | + | // Walk up the tree to find an ancestor split matching our direction axis. | |||
| 1287 | + | current := node | |||
| 1288 | + | for current.Parent != nil { | |||
| 1289 | + | parent := current.Parent | |||
| 1290 | + | if parent.IsSplit() { | |||
| 1291 | + | parentIsHorizontal := parent.SplitDir == entity.SplitHorizontal | |||
| 1292 | + | if parentIsHorizontal == isHorizontal { | |||
| 1293 | + | if isForward { | |||
| 1294 | + | if parent.Right() != nil && parent.Left() == current { | |||
| 1295 | + | return parent.Right(), "" | |||
| 1296 | + | } | |||
| 1297 | + | } else { | |||
| 1298 | + | if parent.Left() != nil && parent.Right() == current { | |||
| 1299 | + | return parent.Left(), "" | |||
| 1300 | + | } | |||
| 1301 | + | } | |||
| 1302 | + | } | |||
| 1303 | + | } | |||
| 1304 | + | current = parent | |||
| 1305 | + | } | |||
| 1306 | + | ||||
| 1307 | + | return nil, directionNoSiblingMessage(direction) | |||
| 1308 | + | } | |||
| 1309 | + | ||||
| 1310 | + | func fallbackSiblingForConsumeOrExpel(node *entity.PaneNode, direction ConsumeOrExpelDirection) *entity.PaneNode { | |||
| 1311 | + | if node == nil || node.Parent == nil { | |||
| 1312 | + | return nil | |||
| 1313 | + | } | |||
| 1314 | + | ||||
| 1315 | + | parent := node.Parent | |||
| 1316 | + | if !parent.IsSplit() { | |||
| 1317 | + | return nil | |||
| 1318 | + | } | |||
| 1319 | + | ||||
| 1320 | + | axisHorizontal := direction == ConsumeOrExpelLeft || direction == ConsumeOrExpelRight | |||
| 1321 | + | if axisHorizontal { | |||
| 1322 | + | if parent.SplitDir == entity.SplitHorizontal { | |||
| 1323 | + | return nil | |||
| 1324 | + | } | |||
| 1325 | + | } else { | |||
| 1326 | + | if parent.SplitDir == entity.SplitVertical { | |||
| 1327 | + | return nil | |||
| 1328 | + | } | |||
| 1329 | + | } | |||
| 1330 | + | ||||
| 1331 | + | // Cross-axis fallback is only used to enable the leaf cycling behavior | |||
| 1332 | + | // (H -> V -> stack, and after expel: V -> H). It should never cause a leaf to | |||
| 1333 | + | // consume into a stacked/split sibling when pressing an unrelated axis key. | |||
| 1334 | + | var sibling *entity.PaneNode | |||
| 1335 | + | if parent.Left() == node { | |||
| 1336 | + | sibling = parent.Right() | |||
| 1337 | + | } else if parent.Right() == node { | |||
| 1338 | + | sibling = parent.Left() | |||
| 1339 | + | } | |||
| 1340 | + | if sibling == nil { | |||
| 1341 | + | return nil | |||
| 1342 | + | } | |||
| 1343 | + | if !node.IsLeaf() || !sibling.IsLeaf() { | |||
| 1344 | + | return nil | |||
| 1345 | + | } | |||
| 1346 | + | return sibling | |||
| 1347 | + | } | |||
| 1348 | + | ||||
| 1349 | + | func cycleHorizontalToVerticalThenStack( | |||
| 1350 | + | ws *entity.Workspace, | |||
| 1351 | + | activeNode *entity.PaneNode, | |||
| 1352 | + | sibling *entity.PaneNode, | |||
| 1353 | + | direction ConsumeOrExpelDirection, | |||
| 1354 | + | ) (cycled bool, cycleAction string, err error) { | |||
| 1355 | + | if ws == nil { | |||
| 1356 | + | return false, "", fmt.Errorf("workspace is required") | |||
| 1357 | + | } | |||
| 1358 | + | if activeNode == nil || sibling == nil { | |||
| 1359 | + | return false, "", nil | |||
| 1360 | + | } | |||
| 1361 | + | if !activeNode.IsLeaf() || !sibling.IsLeaf() { | |||
| 1362 | + | return false, "", nil | |||
| 1363 | + | } | |||
| 1364 | + | ||||
| 1365 | + | parent := activeNode.Parent | |||
| 1366 | + | if parent == nil || sibling.Parent != parent || !parent.IsSplit() { | |||
| 1367 | + | return false, "", nil | |||
| 1368 | + | } | |||
| 1369 | + | ||||
| 1370 | + | switch parent.SplitDir { | |||
| 1371 | + | case entity.SplitHorizontal: | |||
| 1372 | + | // Horizontal -> Vertical: target sibling should be on top. | |||
| 1373 | + | parent.SplitDir = entity.SplitVertical | |||
| 1374 | + | if parent.Children[0] != sibling { | |||
| 1375 | + | parent.Children[0], parent.Children[1] = parent.Children[1], parent.Children[0] | |||
| 1376 | + | } | |||
| 1377 | + | ws.ActivePaneID = activeNode.Pane.ID | |||
| 1378 | + | return true, "vertical_split", nil | |||
| 1379 | + | case entity.SplitVertical: | |||
| 1380 | + | // Vertical -> Horizontal when pressing left/right (reverse of first step). | |||
| 1381 | + | // This is used after expel: stack -> vertical; then Ctrl+[ / Ctrl+] should turn it into left/right. | |||
| 1382 | + | if direction == ConsumeOrExpelRight && strings.Contains(parent.ID, consumeOrExpelExpelCycleMarker) { | |||
| 1383 | + | parent.SplitDir = entity.SplitHorizontal | |||
| 1384 | + | // Active should become the right child. | |||
| 1385 | + | if parent.Children[1] != activeNode { | |||
| 1386 | + | parent.Children[0], parent.Children[1] = parent.Children[1], parent.Children[0] | |||
| 1387 | + | } | |||
| 1388 | + | ws.ActivePaneID = activeNode.Pane.ID | |||
| 1389 | + | return true, "horizontal_split", nil | |||
| 1390 | + | } | |||
| 1391 | + | ||||
| 1392 | + | // Otherwise, vertical -> stack. | |||
| 1393 | + | parent.SplitDir = entity.SplitNone | |||
| 1394 | + | parent.SplitRatio = 0 | |||
| 1395 | + | parent.IsStacked = true | |||
| 1396 | + | parent.Pane = nil | |||
| 1397 | + | if parent.Children[0] == activeNode { | |||
| 1398 | + | parent.ActiveStackIndex = 0 | |||
| 1399 | + | } else if parent.Children[1] == activeNode { | |||
| 1400 | + | parent.ActiveStackIndex = 1 | |||
| 1401 | + | } else { | |||
| 1402 | + | parent.ActiveStackIndex = 0 | |||
| 1403 | + | } | |||
| 1404 | + | ws.ActivePaneID = activeNode.Pane.ID | |||
| 1405 | + | return true, "stack", nil | |||
| 1406 | + | default: | |||
| 1407 | + | return false, "", nil | |||
| 1408 | + | } | |||
| 1409 | + | } | |||
| 1410 | + | ||||
| 1411 | + | func detachLeafByPromotingSibling(ws *entity.Workspace, leaf *entity.PaneNode) (*entity.PaneNode, error) { | |||
| 1412 | + | if ws == nil { | |||
| 1413 | + | return nil, fmt.Errorf("workspace is required") | |||
| 1414 | + | } | |||
| 1415 | + | if leaf == nil { | |||
| 1416 | + | return nil, fmt.Errorf("leaf is required") | |||
| 1417 | + | } | |||
| 1418 | + | if !leaf.IsLeaf() { | |||
| 1419 | + | return nil, fmt.Errorf("can only detach leaf node") | |||
| 1420 | + | } | |||
| 1421 | + | ||||
| 1422 | + | parent := leaf.Parent | |||
| 1423 | + | if parent == nil { | |||
| 1424 | + | return nil, fmt.Errorf("cannot detach root leaf") | |||
| 1425 | + | } | |||
| 1426 | + | if !parent.IsSplit() { | |||
| 1427 | + | return nil, fmt.Errorf("leaf parent is not a split") | |||
| 1428 | + | } | |||
| 1429 | + | ||||
| 1430 | + | var sibling *entity.PaneNode | |||
| 1431 | + | for _, child := range parent.Children { | |||
| 1432 | + | if child != leaf { | |||
| 1433 | + | sibling = child | |||
| 1434 | + | break | |||
| 1435 | + | } | |||
| 1436 | + | } | |||
| 1437 | + | if sibling == nil { | |||
| 1438 | + | return nil, fmt.Errorf("no sibling found for leaf") | |||
| 1439 | + | } | |||
| 1440 | + | ||||
| 1441 | + | grandparent := parent.Parent | |||
| 1442 | + | if grandparent == nil { | |||
| 1443 | + | ws.Root = sibling | |||
| 1444 | + | sibling.Parent = nil | |||
| 1445 | + | } else { | |||
| 1446 | + | for i, child := range grandparent.Children { | |||
| 1447 | + | if child == parent { | |||
| 1448 | + | grandparent.Children[i] = sibling | |||
| 1449 | + | break | |||
| 1450 | + | } | |||
| 1451 | + | } | |||
| 1452 | + | sibling.Parent = grandparent | |||
| 1453 | + | } | |||
| 1454 | + | ||||
| 1455 | + | leaf.Parent = nil | |||
| 1456 | + | return leaf, nil | |||
| 1457 | + | } | |||
| 1458 | + | ||||
| 1459 | + | func convertLeafToStackContainer(leaf *entity.PaneNode) *entity.PaneNode { | |||
| 1460 | + | if leaf == nil || !leaf.IsLeaf() { | |||
| 1461 | + | return leaf | |||
| 1462 | + | } | |||
| 1463 | + | ||||
| 1464 | + | originalPane := leaf.Pane | |||
| 1465 | + | originalChildNode := &entity.PaneNode{ | |||
| 1466 | + | ID: leaf.ID + "_0", | |||
| 1467 | + | Pane: originalPane, | |||
| 1468 | + | Parent: leaf, | |||
| 1469 | + | } | |||
| 1470 | + | ||||
| 1471 | + | leaf.Pane = nil | |||
| 1472 | + | leaf.IsStacked = true | |||
| 1473 | + | leaf.ActiveStackIndex = 0 | |||
| 1474 | + | leaf.Children = []*entity.PaneNode{originalChildNode} | |||
| 1475 | + | ||||
| 1476 | + | return leaf | |||
| 1477 | + | } | |||
| 1478 | + | ||||
| 1479 | + | func removeLeafFromStack(stackNode, leaf *entity.PaneNode) (*entity.PaneNode, error) { | |||
| 1480 | + | if stackNode == nil { | |||
| 1481 | + | return nil, fmt.Errorf("stack node is required") | |||
| 1482 | + | } | |||
| 1483 | + | if !stackNode.IsStacked { | |||
| 1484 | + | return nil, fmt.Errorf("node is not a stack") | |||
| 1485 | + | } | |||
| 1486 | + | if leaf == nil || leaf.Pane == nil { | |||
| 1487 | + | return nil, fmt.Errorf("leaf is required") | |||
| 1488 | + | } | |||
| 1489 | + | ||||
| 1490 | + | idx := -1 | |||
| 1491 | + | for i, child := range stackNode.Children { | |||
| 1492 | + | if child == leaf { | |||
| 1493 | + | idx = i | |||
| 1494 | + | break | |||
| 1495 | + | } | |||
| 1496 | + | } | |||
| 1497 | + | if idx < 0 { | |||
| 1498 | + | return nil, fmt.Errorf("pane not found in stack") | |||
| 1499 | + | } | |||
| 1500 | + | ||||
| 1501 | + | stackNode.Children = append(stackNode.Children[:idx], stackNode.Children[idx+1:]...) | |||
| 1502 | + | if stackNode.ActiveStackIndex >= len(stackNode.Children) { | |||
| 1503 | + | stackNode.ActiveStackIndex = len(stackNode.Children) - 1 | |||
| 1504 | + | } | |||
| 1505 | + | if stackNode.ActiveStackIndex < 0 { | |||
| 1506 | + | stackNode.ActiveStackIndex = 0 | |||
| 1507 | + | } | |||
| 1508 | + | ||||
| 1509 | + | leaf.Parent = nil | |||
| 1510 | + | return leaf, nil | |||
| 1511 | + | } | |||
| 1512 | + | ||||
| 1513 | + | func dissolveStackIntoLeaf(stackNode *entity.PaneNode) { | |||
| 1514 | + | if stackNode == nil || !stackNode.IsStacked || len(stackNode.Children) != 1 { | |||
| 1515 | + | return | |||
| 1516 | + | } | |||
| 1517 | + | ||||
| 1518 | + | remaining := stackNode.Children[0] | |||
| 1519 | + | stackNode.Pane = remaining.Pane | |||
| 1520 | + | stackNode.Children = nil | |||
| 1521 | + | stackNode.IsStacked = false | |||
| 1522 | + | stackNode.ActiveStackIndex = 0 | |||
| 1523 | + | } | |||
| 1524 | + | ||||
| 1525 | + | func splitExistingNode( | |||
| 1526 | + | ws *entity.Workspace, | |||
| 1527 | + | targetNode *entity.PaneNode, | |||
| 1528 | + | newSibling *entity.PaneNode, | |||
| 1529 | + | direction ConsumeOrExpelDirection, | |||
| 1530 | + | idGen IDGenerator, | |||
| 1531 | + | ) error { | |||
| 1532 | + | if ws == nil { | |||
| 1533 | + | return fmt.Errorf("workspace is required") | |||
| 1534 | + | } | |||
| 1535 | + | if targetNode == nil { | |||
| 1536 | + | return fmt.Errorf("target node is required") | |||
| 1537 | + | } | |||
| 1538 | + | if newSibling == nil { | |||
| 1539 | + | return fmt.Errorf("new sibling node is required") | |||
| 1540 | + | } | |||
| 1541 | + | if idGen == nil { | |||
| 1542 | + | return fmt.Errorf("id generator is required") | |||
| 1543 | + | } | |||
| 1544 | + | ||||
| 1545 | + | var splitDir entity.SplitDirection | |||
| 1546 | + | switch direction { | |||
| 1547 | + | case ConsumeOrExpelLeft, ConsumeOrExpelRight: | |||
| 1548 | + | splitDir = entity.SplitHorizontal | |||
| 1549 | + | case ConsumeOrExpelUp, ConsumeOrExpelDown: | |||
| 1550 | + | splitDir = entity.SplitVertical | |||
| 1551 | + | default: | |||
| 1552 | + | return fmt.Errorf("invalid direction: %s", direction) | |||
| 1553 | + | } | |||
| 1554 | + | ||||
| 1555 | + | parentNode := &entity.PaneNode{ | |||
| 1556 | + | ID: idGen(), | |||
| 1557 | + | SplitDir: splitDir, | |||
| 1558 | + | SplitRatio: 0.5, | |||
| 1559 | + | Children: make([]*entity.PaneNode, 2), | |||
| 1560 | + | } | |||
| 1561 | + | ||||
| 1562 | + | switch direction { | |||
| 1563 | + | case ConsumeOrExpelLeft, ConsumeOrExpelUp: | |||
| 1564 | + | parentNode.Children[0] = newSibling | |||
| 1565 | + | parentNode.Children[1] = targetNode | |||
| 1566 | + | case ConsumeOrExpelRight, ConsumeOrExpelDown: | |||
| 1567 | + | parentNode.Children[0] = targetNode | |||
| 1568 | + | parentNode.Children[1] = newSibling | |||
| 1569 | + | } | |||
| 1570 | + | ||||
| 1571 | + | newSibling.Parent = parentNode | |||
| 1572 | + | oldParent := targetNode.Parent | |||
| 1573 | + | targetNode.Parent = parentNode | |||
| 1574 | + | ||||
| 1575 | + | if oldParent == nil { | |||
| 1576 | + | ws.Root = parentNode | |||
| 1577 | + | } else { | |||
| 1578 | + | for i, child := range oldParent.Children { | |||
| 1579 | + | if child == targetNode { | |||
| 1580 | + | oldParent.Children[i] = parentNode | |||
| 1581 | + | break | |||
| 1582 | + | } | |||
| 1583 | + | } | |||
| 1584 | + | parentNode.Parent = oldParent | |||
| 1585 | + | } | |||
| 1586 | + | ||||
| 1587 | + | return nil | |||
| 1588 | + | } | |||
| 1589 | + | ||||
| 1590 | + | func splitExistingNodeWithMarker( | |||
| 1591 | + | ws *entity.Workspace, | |||
| 1592 | + | targetNode *entity.PaneNode, | |||
| 1593 | + | newSibling *entity.PaneNode, | |||
| 1594 | + | direction ConsumeOrExpelDirection, | |||
| 1595 | + | idGen IDGenerator, | |||
| 1596 | + | marker string, | |||
| 1597 | + | ) error { | |||
| 1598 | + | if ws == nil { | |||
| 1599 | + | return fmt.Errorf("workspace is required") | |||
| 1600 | + | } | |||
| 1601 | + | if targetNode == nil { | |||
| 1602 | + | return fmt.Errorf("target node is required") | |||
| 1603 | + | } | |||
| 1604 | + | if newSibling == nil { | |||
| 1605 | + | return fmt.Errorf("new sibling node is required") | |||
| 1606 | + | } | |||
| 1607 | + | if idGen == nil { | |||
| 1608 | + | return fmt.Errorf("id generator is required") | |||
| 1609 | + | } | |||
| 1610 | + | ||||
| 1611 | + | var splitDir entity.SplitDirection | |||
| 1612 | + | switch direction { | |||
| 1613 | + | case ConsumeOrExpelLeft, ConsumeOrExpelRight: | |||
| 1614 | + | splitDir = entity.SplitHorizontal | |||
| 1615 | + | case ConsumeOrExpelUp, ConsumeOrExpelDown: | |||
| 1616 | + | splitDir = entity.SplitVertical | |||
| 1617 | + | default: | |||
| 1618 | + | return fmt.Errorf("invalid direction: %s", direction) | |||
| 1619 | + | } | |||
| 1620 | + | ||||
| 1621 | + | parentNode := &entity.PaneNode{ | |||
| 1622 | + | ID: idGen() + marker, | |||
| 1623 | + | SplitDir: splitDir, | |||
| 1624 | + | SplitRatio: 0.5, | |||
| 1625 | + | Children: make([]*entity.PaneNode, 2), | |||
| 1626 | + | } | |||
| 1627 | + | ||||
| 1628 | + | switch direction { | |||
| 1629 | + | case ConsumeOrExpelLeft, ConsumeOrExpelUp: | |||
| 1630 | + | parentNode.Children[0] = newSibling | |||
| 1631 | + | parentNode.Children[1] = targetNode | |||
| 1632 | + | case ConsumeOrExpelRight, ConsumeOrExpelDown: | |||
| 1633 | + | parentNode.Children[0] = targetNode | |||
| 1634 | + | parentNode.Children[1] = newSibling | |||
| 1635 | + | } | |||
| 1636 | + | ||||
| 1637 | + | newSibling.Parent = parentNode | |||
| 1638 | + | oldParent := targetNode.Parent | |||
| 1639 | + | targetNode.Parent = parentNode | |||
| 1640 | + | ||||
| 1641 | + | if oldParent == nil { | |||
| 1642 | + | ws.Root = parentNode | |||
| 1643 | + | } else { | |||
| 1644 | + | for i, child := range oldParent.Children { | |||
| 1645 | + | if child == targetNode { | |||
| 1646 | + | oldParent.Children[i] = parentNode | |||
| 1647 | + | break | |||
| 1648 | + | } | |||
| 1649 | + | } | |||
| 1650 | + | parentNode.Parent = oldParent | |||
| 1651 | + | } | |||
| 1652 | + | ||||
| 1653 | + | return nil | |||
| 1654 | + | } | |||
| 1655 | + | ||||
| 1077 | // GetAllPanes returns all leaf panes in a workspace. | 1656 | // GetAllPanes returns all leaf panes in a workspace. | ||
| 1078 | // | 1657 | // | ||
| 1079 | //nolint:revive // receiver required for interface consistency | 1658 | //nolint:revive // receiver required for interface consistency | ||
A internal/application/usecase/manage_panes_consume_expel_test.go +438 -0
| @@ -0,0 +1,438 @@ | |||
| 1 | + | package usecase | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestManagePanesUseCase_ConsumeOrExpel_ConsumeLeft_CyclesHorizontalToVerticalThenStack(t *testing.T) { | |
| 11 | + | uc := NewManagePanesUseCase(func() string { return "id" }) | |
| 12 | + | ctx := context.Background() | |
| 13 | + | ||
| 14 | + | left := leaf("left") | |
| 15 | + | right := leaf("right") | |
| 16 | + | root := split(entity.SplitHorizontal, left, right) | |
| 17 | + | ws := &entity.Workspace{Root: root, ActivePaneID: right.Pane.ID} | |
| 18 | + | ||
| 19 | + | // First press: horizontal -> vertical split (left on top, right on bottom). | |
| 20 | + | res, err := uc.ConsumeOrExpel(ctx, ws, right, ConsumeOrExpelLeft) | |
| 21 | + | if err != nil { | |
| 22 | + | t.Fatalf("unexpected error: %v", err) | |
| 23 | + | } | |
| 24 | + | if res == nil || res.Action != "consumed" { | |
| 25 | + | t.Fatalf("result action=%v, want consumed", res) | |
| 26 | + | } | |
| 27 | + | if ws.Root == nil || !ws.Root.IsSplit() || ws.Root.SplitDir != entity.SplitVertical { | |
| 28 | + | t.Fatalf("root should be a vertical split") | |
| 29 | + | } | |
| 30 | + | if ws.Root.Left() == nil || ws.Root.Left().Pane.ID != "left" { | |
| 31 | + | t.Fatalf("top pane=%v, want left", ws.Root.Left()) | |
| 32 | + | } | |
| 33 | + | if ws.Root.Right() == nil || ws.Root.Right().Pane.ID != "right" { | |
| 34 | + | t.Fatalf("bottom pane=%v, want right", ws.Root.Right()) | |
| 35 | + | } | |
| 36 | + | if ws.ActivePaneID != "right" { | |
| 37 | + | t.Fatalf("active=%s, want right", ws.ActivePaneID) | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | // Second press: vertical split -> stack. | |
| 41 | + | active := ws.ActivePane() | |
| 42 | + | if active == nil { | |
| 43 | + | t.Fatalf("expected active pane") | |
| 44 | + | } | |
| 45 | + | res, err = uc.ConsumeOrExpel(ctx, ws, active, ConsumeOrExpelLeft) | |
| 46 | + | if err != nil { | |
| 47 | + | t.Fatalf("unexpected error: %v", err) | |
| 48 | + | } | |
| 49 | + | if res == nil || res.Action != "consumed" { | |
| 50 | + | t.Fatalf("result action=%v, want consumed", res) | |
| 51 | + | } | |
| 52 | + | if ws.Root == nil || !ws.Root.IsStacked { | |
| 53 | + | t.Fatalf("root should be a stack") | |
| 54 | + | } | |
| 55 | + | if got := panesInOrder(ws.Root); got != "left,right" { | |
| 56 | + | t.Fatalf("stack panes=%s, want left,right", got) | |
| 57 | + | } | |
| 58 | + | if ws.ActivePaneID != "right" { | |
| 59 | + | t.Fatalf("active=%s, want right", ws.ActivePaneID) | |
| 60 | + | } | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | func TestManagePanesUseCase_ConsumeOrExpel_ConsumeRight_CyclesHorizontalToVerticalThenStack(t *testing.T) { | |
| 64 | + | uc := NewManagePanesUseCase(func() string { return "id" }) | |
| 65 | + | ctx := context.Background() | |
| 66 | + | ||
| 67 | + | left := leaf("left") | |
| 68 | + | right := leaf("right") | |
| 69 | + | root := split(entity.SplitHorizontal, left, right) | |
| 70 | + | ws := &entity.Workspace{Root: root, ActivePaneID: left.Pane.ID} | |
| 71 | + | ||
| 72 | + | // First press: horizontal -> vertical split (right on top, left on bottom). | |
| 73 | + | res, err := uc.ConsumeOrExpel(ctx, ws, left, ConsumeOrExpelRight) | |
| 74 | + | if err != nil { | |
| 75 | + | t.Fatalf("unexpected error: %v", err) | |
| 76 | + | } | |
| 77 | + | if res == nil || res.Action != "consumed" { | |
| 78 | + | t.Fatalf("result action=%v, want consumed", res) | |
| 79 | + | } | |
| 80 | + | if ws.Root == nil || !ws.Root.IsSplit() || ws.Root.SplitDir != entity.SplitVertical { | |
| 81 | + | t.Fatalf("root should be a vertical split") | |
| 82 | + | } | |
| 83 | + | if ws.Root.Left() == nil || ws.Root.Left().Pane.ID != "right" { | |
| 84 | + | t.Fatalf("top pane=%v, want right", ws.Root.Left()) | |
| 85 | + | } | |
| 86 | + | if ws.Root.Right() == nil || ws.Root.Right().Pane.ID != "left" { | |
| 87 | + | t.Fatalf("bottom pane=%v, want left", ws.Root.Right()) | |
| 88 | + | } | |
| 89 | + | if ws.ActivePaneID != "left" { | |
| 90 | + | t.Fatalf("active=%s, want left", ws.ActivePaneID) | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | // Second press: vertical split -> stack. | |
| 94 | + | active := ws.ActivePane() | |
| 95 | + | if active == nil { | |
| 96 | + | t.Fatalf("expected active pane") | |
| 97 | + | } | |
| 98 | + | res, err = uc.ConsumeOrExpel(ctx, ws, active, ConsumeOrExpelRight) | |
| 99 | + | if err != nil { | |
| 100 | + | t.Fatalf("unexpected error: %v", err) | |
| 101 | + | } | |
| 102 | + | if res == nil || res.Action != "consumed" { | |
| 103 | + | t.Fatalf("result action=%v, want consumed", res) | |
| 104 | + | } | |
| 105 | + | if ws.Root == nil || !ws.Root.IsStacked { | |
| 106 | + | t.Fatalf("root should be a stack") | |
| 107 | + | } | |
| 108 | + | if got := panesInOrder(ws.Root); got != "right,left" { | |
| 109 | + | t.Fatalf("stack panes=%s, want right,left", got) | |
| 110 | + | } | |
| 111 | + | if ws.ActivePaneID != "left" { | |
| 112 | + | t.Fatalf("active=%s, want left", ws.ActivePaneID) | |
| 113 | + | } | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | func TestManagePanesUseCase_ConsumeOrExpel_ConsumeIntoExistingStack_AppendsToEnd(t *testing.T) { | |
| 117 | + | uc := NewManagePanesUseCase(func() string { return "id" }) | |
| 118 | + | ctx := context.Background() | |
| 119 | + | ||
| 120 | + | stackNode := stack(leaf("a"), leaf("b")) | |
| 121 | + | c := leaf("c") | |
| 122 | + | root := split(entity.SplitHorizontal, stackNode, c) | |
| 123 | + | ws := &entity.Workspace{Root: root, ActivePaneID: c.Pane.ID} | |
| 124 | + | ||
| 125 | + | res, err := uc.ConsumeOrExpel(ctx, ws, c, ConsumeOrExpelLeft) | |
| 126 | + | if err != nil { | |
| 127 | + | t.Fatalf("unexpected error: %v", err) | |
| 128 | + | } | |
| 129 | + | if res == nil || res.Action != "consumed" { | |
| 130 | + | t.Fatalf("result action=%v, want consumed", res) | |
| 131 | + | } | |
| 132 | + | if ws.Root == nil || !ws.Root.IsStacked { | |
| 133 | + | t.Fatalf("root should be a stack") | |
| 134 | + | } | |
| 135 | + | if got := panesInOrder(ws.Root); got != "a,b,c" { | |
| 136 | + | t.Fatalf("stack panes=%s, want a,b,c", got) | |
| 137 | + | } | |
| 138 | + | if ws.ActivePaneID != "c" { | |
| 139 | + | t.Fatalf("active=%s, want c", ws.ActivePaneID) | |
| 140 | + | } | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | func TestManagePanesUseCase_ConsumeOrExpel_ConsumeLeft_InVerticalSplitWithStackedSibling_Noops(t *testing.T) { | |
| 144 | + | uc := NewManagePanesUseCase(func() string { return "id" }) | |
| 145 | + | ctx := context.Background() | |
| 146 | + | ||
| 147 | + | top := leaf("top") | |
| 148 | + | bottomStack := stack(leaf("a"), leaf("b")) | |
| 149 | + | root := split(entity.SplitVertical, top, bottomStack) | |
| 150 | + | ws := &entity.Workspace{Root: root, ActivePaneID: top.Pane.ID} | |
| 151 | + | ||
| 152 | + | res, err := uc.ConsumeOrExpel(ctx, ws, top, ConsumeOrExpelLeft) | |
| 153 | + | if err != nil { | |
| 154 | + | t.Fatalf("unexpected error: %v", err) | |
| 155 | + | } | |
| 156 | + | if res == nil || res.Action != "none" { | |
| 157 | + | t.Fatalf("result action=%v, want none", res) | |
| 158 | + | } | |
| 159 | + | if res.ErrorMessage != "No pane to the left" { | |
| 160 | + | t.Fatalf("error=%q, want %q", res.ErrorMessage, "No pane to the left") | |
| 161 | + | } | |
| 162 | + | if ws.Root != root { | |
| 163 | + | t.Fatalf("tree should be unchanged") | |
| 164 | + | } | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | func TestManagePanesUseCase_ConsumeOrExpel_Expel_DissolvesTwoPaneStack(t *testing.T) { | |
| 168 | + | uc := NewManagePanesUseCase(func() string { return "id" }) | |
| 169 | + | ctx := context.Background() | |
| 170 | + | ||
| 171 | + | stackNode := stack(leaf("a"), leaf("b")) | |
| 172 | + | b := stackNode.Children[1] | |
| 173 | + | ws := &entity.Workspace{Root: stackNode, ActivePaneID: b.Pane.ID} | |
| 174 | + | ||
| 175 | + | res, err := uc.ConsumeOrExpel(ctx, ws, b, ConsumeOrExpelRight) | |
| 176 | + | if err != nil { | |
| 177 | + | t.Fatalf("unexpected error: %v", err) | |
| 178 | + | } | |
| 179 | + | if res == nil || res.Action != "expelled" { | |
| 180 | + | t.Fatalf("result action=%v, want expelled", res) | |
| 181 | + | } | |
| 182 | + | if ws.Root == nil || !ws.Root.IsSplit() || ws.Root.SplitDir != entity.SplitVertical { | |
| 183 | + | t.Fatalf("root should be a vertical split") | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | top := ws.Root.Left() | |
| 187 | + | bottom := ws.Root.Right() | |
| 188 | + | if top == nil || bottom == nil { | |
| 189 | + | t.Fatalf("split children should exist") | |
| 190 | + | } | |
| 191 | + | if !top.IsLeaf() || top.Pane == nil || top.Pane.ID != "a" { | |
| 192 | + | t.Fatalf("top child should be leaf a") | |
| 193 | + | } | |
| 194 | + | if !bottom.IsLeaf() || bottom.Pane == nil || bottom.Pane.ID != "b" { | |
| 195 | + | t.Fatalf("bottom child should be leaf b") | |
| 196 | + | } | |
| 197 | + | if ws.ActivePaneID != "b" { | |
| 198 | + | t.Fatalf("active=%s, want b", ws.ActivePaneID) | |
| 199 | + | } | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | func TestManagePanesUseCase_ConsumeOrExpel_ExpelThenConsumeRight_CyclesVerticalToHorizontal(t *testing.T) { | |
| 203 | + | uc := NewManagePanesUseCase(func() string { return "id" }) | |
| 204 | + | ctx := context.Background() | |
| 205 | + | ||
| 206 | + | stackNode := stack(leaf("a"), leaf("b")) | |
| 207 | + | b := stackNode.Children[1] | |
| 208 | + | ws := &entity.Workspace{Root: stackNode, ActivePaneID: b.Pane.ID} | |
| 209 | + | ||
| 210 | + | // First press (on stacked pane): stack -> vertical split with active on bottom. | |
| 211 | + | mustConsumeOrExpelAction(t, uc, ctx, ws, b, ConsumeOrExpelRight, "expelled") | |
| 212 | + | mustSplitRoot(t, ws, entity.SplitVertical) | |
| 213 | + | if string(ws.ActivePaneID) != "b" { | |
| 214 | + | t.Fatalf("active=%s, want b", ws.ActivePaneID) | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | active := ws.ActivePane() | |
| 218 | + | mustLeafPaneID(t, active, "b") | |
| 219 | + | ||
| 220 | + | // Second press (on bottom pane): vertical -> horizontal split with active on right. | |
| 221 | + | mustConsumeOrExpelAction(t, uc, ctx, ws, active, ConsumeOrExpelRight, "consumed") | |
| 222 | + | root := mustSplitRoot(t, ws, entity.SplitHorizontal) | |
| 223 | + | mustLeafPaneID(t, root.Left(), "a") | |
| 224 | + | mustLeafPaneID(t, root.Right(), "b") | |
| 225 | + | if string(ws.ActivePaneID) != "b" { | |
| 226 | + | t.Fatalf("active=%s, want b", ws.ActivePaneID) | |
| 227 | + | } | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | func TestManagePanesUseCase_ConsumeOrExpel_ExpelThenConsumeLeft_ReturnsToStack(t *testing.T) { | |
| 231 | + | uc := NewManagePanesUseCase(func() string { return "id" }) | |
| 232 | + | ctx := context.Background() | |
| 233 | + | ||
| 234 | + | stackNode := stack(leaf("a"), leaf("b")) | |
| 235 | + | b := stackNode.Children[1] | |
| 236 | + | ws := &entity.Workspace{Root: stackNode, ActivePaneID: b.Pane.ID} | |
| 237 | + | ||
| 238 | + | // First press (on stacked pane): stack -> vertical split with active on bottom. | |
| 239 | + | mustConsumeOrExpelAction(t, uc, ctx, ws, b, ConsumeOrExpelRight, "expelled") | |
| 240 | + | mustSplitRoot(t, ws, entity.SplitVertical) | |
| 241 | + | if string(ws.ActivePaneID) != "b" { | |
| 242 | + | t.Fatalf("active=%s, want b", ws.ActivePaneID) | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | active := ws.ActivePane() | |
| 246 | + | mustLeafPaneID(t, active, "b") | |
| 247 | + | ||
| 248 | + | // Second press (on bottom pane): vertical -> stack (return to original). | |
| 249 | + | mustConsumeOrExpelAction(t, uc, ctx, ws, active, ConsumeOrExpelLeft, "consumed") | |
| 250 | + | if ws.Root == nil || !ws.Root.IsStacked { | |
| 251 | + | t.Fatalf("root should be a stack") | |
| 252 | + | } | |
| 253 | + | if got := panesInOrder(ws.Root); got != "a,b" { | |
| 254 | + | t.Fatalf("stack panes=%s, want a,b", got) | |
| 255 | + | } | |
| 256 | + | if string(ws.ActivePaneID) != "b" { | |
| 257 | + | t.Fatalf("active=%s, want b", ws.ActivePaneID) | |
| 258 | + | } | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | func TestManagePanesUseCase_ConsumeOrExpel_Expel_StackRemainsWhenMoreThanTwo(t *testing.T) { | |
| 262 | + | uc := NewManagePanesUseCase(func() string { return "id" }) | |
| 263 | + | ctx := context.Background() | |
| 264 | + | ||
| 265 | + | stackNode := stack(leaf("a"), leaf("b"), leaf("c")) | |
| 266 | + | b := stackNode.Children[1] | |
| 267 | + | ws := &entity.Workspace{Root: stackNode, ActivePaneID: b.Pane.ID} | |
| 268 | + | ||
| 269 | + | res, err := uc.ConsumeOrExpel(ctx, ws, b, ConsumeOrExpelRight) | |
| 270 | + | if err != nil { | |
| 271 | + | t.Fatalf("unexpected error: %v", err) | |
| 272 | + | } | |
| 273 | + | if res == nil || res.Action != "expelled" { | |
| 274 | + | t.Fatalf("result action=%v, want expelled", res) | |
| 275 | + | } | |
| 276 | + | if ws.Root == nil || !ws.Root.IsSplit() || ws.Root.SplitDir != entity.SplitVertical { | |
| 277 | + | t.Fatalf("root should be a vertical split") | |
| 278 | + | } | |
| 279 | + | top := ws.Root.Left() | |
| 280 | + | bottom := ws.Root.Right() | |
| 281 | + | if top == nil || !top.IsStacked { | |
| 282 | + | t.Fatalf("top child should be stack") | |
| 283 | + | } | |
| 284 | + | if panesInOrder(top) != "a,c" { | |
| 285 | + | t.Fatalf("remaining stack panes=%s, want a,c", panesInOrder(top)) | |
| 286 | + | } | |
| 287 | + | if bottom == nil || !bottom.IsLeaf() || bottom.Pane.ID != "b" { | |
| 288 | + | t.Fatalf("bottom child should be expelled leaf b") | |
| 289 | + | } | |
| 290 | + | if ws.ActivePaneID != "b" { | |
| 291 | + | t.Fatalf("active=%s, want b", ws.ActivePaneID) | |
| 292 | + | } | |
| 293 | + | } | |
| 294 | + | ||
| 295 | + | func TestManagePanesUseCase_ConsumeOrExpel_NoSibling_ReturnsWarningAndNoop(t *testing.T) { | |
| 296 | + | uc := NewManagePanesUseCase(func() string { return "id" }) | |
| 297 | + | ctx := context.Background() | |
| 298 | + | ||
| 299 | + | left := leaf("left") | |
| 300 | + | right := leaf("right") | |
| 301 | + | root := split(entity.SplitHorizontal, left, right) | |
| 302 | + | ws := &entity.Workspace{Root: root, ActivePaneID: left.Pane.ID} | |
| 303 | + | ||
| 304 | + | res, err := uc.ConsumeOrExpel(ctx, ws, left, ConsumeOrExpelLeft) | |
| 305 | + | if err != nil { | |
| 306 | + | t.Fatalf("unexpected error: %v", err) | |
| 307 | + | } | |
| 308 | + | if res == nil || res.Action != "none" { | |
| 309 | + | t.Fatalf("result action=%v, want none", res) | |
| 310 | + | } | |
| 311 | + | if res.ErrorMessage == "" { | |
| 312 | + | t.Fatalf("expected warning message") | |
| 313 | + | } | |
| 314 | + | if ws.Root != root { | |
| 315 | + | t.Fatalf("tree should be unchanged") | |
| 316 | + | } | |
| 317 | + | } | |
| 318 | + | ||
| 319 | + | func TestManagePanesUseCase_ConsumeOrExpel_NestedSplits_ConsumesImmediateSiblingOnly(t *testing.T) { | |
| 320 | + | uc := NewManagePanesUseCase(func() string { return "id" }) | |
| 321 | + | ctx := context.Background() | |
| 322 | + | ||
| 323 | + | a := leaf("a") | |
| 324 | + | b := leaf("b") | |
| 325 | + | top := split(entity.SplitHorizontal, a, b) | |
| 326 | + | c := leaf("c") | |
| 327 | + | root := split(entity.SplitVertical, top, c) | |
| 328 | + | ws := &entity.Workspace{Root: root, ActivePaneID: a.Pane.ID} | |
| 329 | + | ||
| 330 | + | res, err := uc.ConsumeOrExpel(ctx, ws, a, ConsumeOrExpelDown) | |
| 331 | + | if err != nil { | |
| 332 | + | t.Fatalf("unexpected error: %v", err) | |
| 333 | + | } | |
| 334 | + | if res == nil || res.Action != "consumed" { | |
| 335 | + | t.Fatalf("result action=%v, want consumed", res) | |
| 336 | + | } | |
| 337 | + | if ws.Root == nil || !ws.Root.IsSplit() || ws.Root.SplitDir != entity.SplitVertical { | |
| 338 | + | t.Fatalf("root should remain a vertical split") | |
| 339 | + | } | |
| 340 | + | if ws.Root.Right() == nil || !ws.Root.Right().IsStacked { | |
| 341 | + | t.Fatalf("expected bottom pane to become a stack") | |
| 342 | + | } | |
| 343 | + | if panesInOrder(ws.Root.Right()) != "c,a" { | |
| 344 | + | t.Fatalf("bottom stack panes=%s, want c,a", panesInOrder(ws.Root.Right())) | |
| 345 | + | } | |
| 346 | + | } | |
| 347 | + | ||
| 348 | + | func TestManagePanesUseCase_ConsumeOrExpel_OnlyOnePane(t *testing.T) { | |
| 349 | + | uc := NewManagePanesUseCase(func() string { return "id" }) | |
| 350 | + | ctx := context.Background() | |
| 351 | + | ||
| 352 | + | only := leaf("only") | |
| 353 | + | ws := &entity.Workspace{Root: only, ActivePaneID: only.Pane.ID} | |
| 354 | + | ||
| 355 | + | res, err := uc.ConsumeOrExpel(ctx, ws, only, ConsumeOrExpelLeft) | |
| 356 | + | if err != nil { | |
| 357 | + | t.Fatalf("unexpected error: %v", err) | |
| 358 | + | } | |
| 359 | + | if res == nil || res.Action != "none" { | |
| 360 | + | t.Fatalf("result action=%v, want none", res) | |
| 361 | + | } | |
| 362 | + | if res.ErrorMessage == "" { | |
| 363 | + | t.Fatalf("expected warning message") | |
| 364 | + | } | |
| 365 | + | } | |
| 366 | + | ||
| 367 | + | func mustConsumeOrExpelAction( | |
| 368 | + | t *testing.T, | |
| 369 | + | uc *ManagePanesUseCase, | |
| 370 | + | ctx context.Context, | |
| 371 | + | ws *entity.Workspace, | |
| 372 | + | node *entity.PaneNode, | |
| 373 | + | direction ConsumeOrExpelDirection, | |
| 374 | + | wantAction string, | |
| 375 | + | ) { | |
| 376 | + | t.Helper() | |
| 377 | + | res, err := uc.ConsumeOrExpel(ctx, ws, node, direction) | |
| 378 | + | if err != nil { | |
| 379 | + | t.Fatalf("unexpected error: %v", err) | |
| 380 | + | } | |
| 381 | + | if res == nil || res.Action != wantAction { | |
| 382 | + | t.Fatalf("result action=%v, want %s", res, wantAction) | |
| 383 | + | } | |
| 384 | + | } | |
| 385 | + | ||
| 386 | + | func mustSplitRoot(t *testing.T, ws *entity.Workspace, dir entity.SplitDirection) *entity.PaneNode { | |
| 387 | + | t.Helper() | |
| 388 | + | if ws == nil || ws.Root == nil || !ws.Root.IsSplit() || ws.Root.SplitDir != dir { | |
| 389 | + | t.Fatalf("root should be a %v split", dir) | |
| 390 | + | } | |
| 391 | + | return ws.Root | |
| 392 | + | } | |
| 393 | + | ||
| 394 | + | func mustLeafPaneID(t *testing.T, node *entity.PaneNode, want string) { | |
| 395 | + | t.Helper() | |
| 396 | + | if node == nil || !node.IsLeaf() || node.Pane == nil || string(node.Pane.ID) != want { | |
| 397 | + | t.Fatalf("leaf pane=%v, want %s", node, want) | |
| 398 | + | } | |
| 399 | + | } | |
| 400 | + | ||
| 401 | + | func leaf(id string) *entity.PaneNode { | |
| 402 | + | return &entity.PaneNode{ID: id, Pane: &entity.Pane{ID: entity.PaneID(id)}} | |
| 403 | + | } | |
| 404 | + | ||
| 405 | + | func split(dir entity.SplitDirection, left, right *entity.PaneNode) *entity.PaneNode { | |
| 406 | + | root := &entity.PaneNode{ID: "split", SplitDir: dir, SplitRatio: 0.5, Children: []*entity.PaneNode{left, right}} | |
| 407 | + | left.Parent = root | |
| 408 | + | right.Parent = root | |
| 409 | + | return root | |
| 410 | + | } | |
| 411 | + | ||
| 412 | + | func stack(panes ...*entity.PaneNode) *entity.PaneNode { | |
| 413 | + | n := &entity.PaneNode{ID: "stack", IsStacked: true, ActiveStackIndex: 0} | |
| 414 | + | children := make([]*entity.PaneNode, 0, len(panes)) | |
| 415 | + | for _, p := range panes { | |
| 416 | + | p.Parent = n | |
| 417 | + | children = append(children, p) | |
| 418 | + | } | |
| 419 | + | n.Children = children | |
| 420 | + | return n | |
| 421 | + | } | |
| 422 | + | ||
| 423 | + | func panesInOrder(stackNode *entity.PaneNode) string { | |
| 424 | + | if stackNode == nil || len(stackNode.Children) == 0 { | |
| 425 | + | return "" | |
| 426 | + | } | |
| 427 | + | out := "" | |
| 428 | + | for i, child := range stackNode.Children { | |
| 429 | + | if child == nil || child.Pane == nil { | |
| 430 | + | continue | |
| 431 | + | } | |
| 432 | + | if i > 0 { | |
| 433 | + | out += "," | |
| 434 | + | } | |
| 435 | + | out += string(child.Pane.ID) | |
| 436 | + | } | |
| 437 | + | return out | |
| 438 | + | } | |
| @@ -0,0 +1,438 @@ | |||||
| 1 | + | package usecase | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestManagePanesUseCase_ConsumeOrExpel_ConsumeLeft_CyclesHorizontalToVerticalThenStack(t *testing.T) { | |||
| 11 | + | uc := NewManagePanesUseCase(func() string { return "id" }) | |||
| 12 | + | ctx := context.Background() | |||
| 13 | + | ||||
| 14 | + | left := leaf("left") | |||
| 15 | + | right := leaf("right") | |||
| 16 | + | root := split(entity.SplitHorizontal, left, right) | |||
| 17 | + | ws := &entity.Workspace{Root: root, ActivePaneID: right.Pane.ID} | |||
| 18 | + | ||||
| 19 | + | // First press: horizontal -> vertical split (left on top, right on bottom). | |||
| 20 | + | res, err := uc.ConsumeOrExpel(ctx, ws, right, ConsumeOrExpelLeft) | |||
| 21 | + | if err != nil { | |||
| 22 | + | t.Fatalf("unexpected error: %v", err) | |||
| 23 | + | } | |||
| 24 | + | if res == nil || res.Action != "consumed" { | |||
| 25 | + | t.Fatalf("result action=%v, want consumed", res) | |||
| 26 | + | } | |||
| 27 | + | if ws.Root == nil || !ws.Root.IsSplit() || ws.Root.SplitDir != entity.SplitVertical { | |||
| 28 | + | t.Fatalf("root should be a vertical split") | |||
| 29 | + | } | |||
| 30 | + | if ws.Root.Left() == nil || ws.Root.Left().Pane.ID != "left" { | |||
| 31 | + | t.Fatalf("top pane=%v, want left", ws.Root.Left()) | |||
| 32 | + | } | |||
| 33 | + | if ws.Root.Right() == nil || ws.Root.Right().Pane.ID != "right" { | |||
| 34 | + | t.Fatalf("bottom pane=%v, want right", ws.Root.Right()) | |||
| 35 | + | } | |||
| 36 | + | if ws.ActivePaneID != "right" { | |||
| 37 | + | t.Fatalf("active=%s, want right", ws.ActivePaneID) | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | // Second press: vertical split -> stack. | |||
| 41 | + | active := ws.ActivePane() | |||
| 42 | + | if active == nil { | |||
| 43 | + | t.Fatalf("expected active pane") | |||
| 44 | + | } | |||
| 45 | + | res, err = uc.ConsumeOrExpel(ctx, ws, active, ConsumeOrExpelLeft) | |||
| 46 | + | if err != nil { | |||
| 47 | + | t.Fatalf("unexpected error: %v", err) | |||
| 48 | + | } | |||
| 49 | + | if res == nil || res.Action != "consumed" { | |||
| 50 | + | t.Fatalf("result action=%v, want consumed", res) | |||
| 51 | + | } | |||
| 52 | + | if ws.Root == nil || !ws.Root.IsStacked { | |||
| 53 | + | t.Fatalf("root should be a stack") | |||
| 54 | + | } | |||
| 55 | + | if got := panesInOrder(ws.Root); got != "left,right" { | |||
| 56 | + | t.Fatalf("stack panes=%s, want left,right", got) | |||
| 57 | + | } | |||
| 58 | + | if ws.ActivePaneID != "right" { | |||
| 59 | + | t.Fatalf("active=%s, want right", ws.ActivePaneID) | |||
| 60 | + | } | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | func TestManagePanesUseCase_ConsumeOrExpel_ConsumeRight_CyclesHorizontalToVerticalThenStack(t *testing.T) { | |||
| 64 | + | uc := NewManagePanesUseCase(func() string { return "id" }) | |||
| 65 | + | ctx := context.Background() | |||
| 66 | + | ||||
| 67 | + | left := leaf("left") | |||
| 68 | + | right := leaf("right") | |||
| 69 | + | root := split(entity.SplitHorizontal, left, right) | |||
| 70 | + | ws := &entity.Workspace{Root: root, ActivePaneID: left.Pane.ID} | |||
| 71 | + | ||||
| 72 | + | // First press: horizontal -> vertical split (right on top, left on bottom). | |||
| 73 | + | res, err := uc.ConsumeOrExpel(ctx, ws, left, ConsumeOrExpelRight) | |||
| 74 | + | if err != nil { | |||
| 75 | + | t.Fatalf("unexpected error: %v", err) | |||
| 76 | + | } | |||
| 77 | + | if res == nil || res.Action != "consumed" { | |||
| 78 | + | t.Fatalf("result action=%v, want consumed", res) | |||
| 79 | + | } | |||
| 80 | + | if ws.Root == nil || !ws.Root.IsSplit() || ws.Root.SplitDir != entity.SplitVertical { | |||
| 81 | + | t.Fatalf("root should be a vertical split") | |||
| 82 | + | } | |||
| 83 | + | if ws.Root.Left() == nil || ws.Root.Left().Pane.ID != "right" { | |||
| 84 | + | t.Fatalf("top pane=%v, want right", ws.Root.Left()) | |||
| 85 | + | } | |||
| 86 | + | if ws.Root.Right() == nil || ws.Root.Right().Pane.ID != "left" { | |||
| 87 | + | t.Fatalf("bottom pane=%v, want left", ws.Root.Right()) | |||
| 88 | + | } | |||
| 89 | + | if ws.ActivePaneID != "left" { | |||
| 90 | + | t.Fatalf("active=%s, want left", ws.ActivePaneID) | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | // Second press: vertical split -> stack. | |||
| 94 | + | active := ws.ActivePane() | |||
| 95 | + | if active == nil { | |||
| 96 | + | t.Fatalf("expected active pane") | |||
| 97 | + | } | |||
| 98 | + | res, err = uc.ConsumeOrExpel(ctx, ws, active, ConsumeOrExpelRight) | |||
| 99 | + | if err != nil { | |||
| 100 | + | t.Fatalf("unexpected error: %v", err) | |||
| 101 | + | } | |||
| 102 | + | if res == nil || res.Action != "consumed" { | |||
| 103 | + | t.Fatalf("result action=%v, want consumed", res) | |||
| 104 | + | } | |||
| 105 | + | if ws.Root == nil || !ws.Root.IsStacked { | |||
| 106 | + | t.Fatalf("root should be a stack") | |||
| 107 | + | } | |||
| 108 | + | if got := panesInOrder(ws.Root); got != "right,left" { | |||
| 109 | + | t.Fatalf("stack panes=%s, want right,left", got) | |||
| 110 | + | } | |||
| 111 | + | if ws.ActivePaneID != "left" { | |||
| 112 | + | t.Fatalf("active=%s, want left", ws.ActivePaneID) | |||
| 113 | + | } | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | func TestManagePanesUseCase_ConsumeOrExpel_ConsumeIntoExistingStack_AppendsToEnd(t *testing.T) { | |||
| 117 | + | uc := NewManagePanesUseCase(func() string { return "id" }) | |||
| 118 | + | ctx := context.Background() | |||
| 119 | + | ||||
| 120 | + | stackNode := stack(leaf("a"), leaf("b")) | |||
| 121 | + | c := leaf("c") | |||
| 122 | + | root := split(entity.SplitHorizontal, stackNode, c) | |||
| 123 | + | ws := &entity.Workspace{Root: root, ActivePaneID: c.Pane.ID} | |||
| 124 | + | ||||
| 125 | + | res, err := uc.ConsumeOrExpel(ctx, ws, c, ConsumeOrExpelLeft) | |||
| 126 | + | if err != nil { | |||
| 127 | + | t.Fatalf("unexpected error: %v", err) | |||
| 128 | + | } | |||
| 129 | + | if res == nil || res.Action != "consumed" { | |||
| 130 | + | t.Fatalf("result action=%v, want consumed", res) | |||
| 131 | + | } | |||
| 132 | + | if ws.Root == nil || !ws.Root.IsStacked { | |||
| 133 | + | t.Fatalf("root should be a stack") | |||
| 134 | + | } | |||
| 135 | + | if got := panesInOrder(ws.Root); got != "a,b,c" { | |||
| 136 | + | t.Fatalf("stack panes=%s, want a,b,c", got) | |||
| 137 | + | } | |||
| 138 | + | if ws.ActivePaneID != "c" { | |||
| 139 | + | t.Fatalf("active=%s, want c", ws.ActivePaneID) | |||
| 140 | + | } | |||
| 141 | + | } | |||
| 142 | + | ||||
| 143 | + | func TestManagePanesUseCase_ConsumeOrExpel_ConsumeLeft_InVerticalSplitWithStackedSibling_Noops(t *testing.T) { | |||
| 144 | + | uc := NewManagePanesUseCase(func() string { return "id" }) | |||
| 145 | + | ctx := context.Background() | |||
| 146 | + | ||||
| 147 | + | top := leaf("top") | |||
| 148 | + | bottomStack := stack(leaf("a"), leaf("b")) | |||
| 149 | + | root := split(entity.SplitVertical, top, bottomStack) | |||
| 150 | + | ws := &entity.Workspace{Root: root, ActivePaneID: top.Pane.ID} | |||
| 151 | + | ||||
| 152 | + | res, err := uc.ConsumeOrExpel(ctx, ws, top, ConsumeOrExpelLeft) | |||
| 153 | + | if err != nil { | |||
| 154 | + | t.Fatalf("unexpected error: %v", err) | |||
| 155 | + | } | |||
| 156 | + | if res == nil || res.Action != "none" { | |||
| 157 | + | t.Fatalf("result action=%v, want none", res) | |||
| 158 | + | } | |||
| 159 | + | if res.ErrorMessage != "No pane to the left" { | |||
| 160 | + | t.Fatalf("error=%q, want %q", res.ErrorMessage, "No pane to the left") | |||
| 161 | + | } | |||
| 162 | + | if ws.Root != root { | |||
| 163 | + | t.Fatalf("tree should be unchanged") | |||
| 164 | + | } | |||
| 165 | + | } | |||
| 166 | + | ||||
| 167 | + | func TestManagePanesUseCase_ConsumeOrExpel_Expel_DissolvesTwoPaneStack(t *testing.T) { | |||
| 168 | + | uc := NewManagePanesUseCase(func() string { return "id" }) | |||
| 169 | + | ctx := context.Background() | |||
| 170 | + | ||||
| 171 | + | stackNode := stack(leaf("a"), leaf("b")) | |||
| 172 | + | b := stackNode.Children[1] | |||
| 173 | + | ws := &entity.Workspace{Root: stackNode, ActivePaneID: b.Pane.ID} | |||
| 174 | + | ||||
| 175 | + | res, err := uc.ConsumeOrExpel(ctx, ws, b, ConsumeOrExpelRight) | |||
| 176 | + | if err != nil { | |||
| 177 | + | t.Fatalf("unexpected error: %v", err) | |||
| 178 | + | } | |||
| 179 | + | if res == nil || res.Action != "expelled" { | |||
| 180 | + | t.Fatalf("result action=%v, want expelled", res) | |||
| 181 | + | } | |||
| 182 | + | if ws.Root == nil || !ws.Root.IsSplit() || ws.Root.SplitDir != entity.SplitVertical { | |||
| 183 | + | t.Fatalf("root should be a vertical split") | |||
| 184 | + | } | |||
| 185 | + | ||||
| 186 | + | top := ws.Root.Left() | |||
| 187 | + | bottom := ws.Root.Right() | |||
| 188 | + | if top == nil || bottom == nil { | |||
| 189 | + | t.Fatalf("split children should exist") | |||
| 190 | + | } | |||
| 191 | + | if !top.IsLeaf() || top.Pane == nil || top.Pane.ID != "a" { | |||
| 192 | + | t.Fatalf("top child should be leaf a") | |||
| 193 | + | } | |||
| 194 | + | if !bottom.IsLeaf() || bottom.Pane == nil || bottom.Pane.ID != "b" { | |||
| 195 | + | t.Fatalf("bottom child should be leaf b") | |||
| 196 | + | } | |||
| 197 | + | if ws.ActivePaneID != "b" { | |||
| 198 | + | t.Fatalf("active=%s, want b", ws.ActivePaneID) | |||
| 199 | + | } | |||
| 200 | + | } | |||
| 201 | + | ||||
| 202 | + | func TestManagePanesUseCase_ConsumeOrExpel_ExpelThenConsumeRight_CyclesVerticalToHorizontal(t *testing.T) { | |||
| 203 | + | uc := NewManagePanesUseCase(func() string { return "id" }) | |||
| 204 | + | ctx := context.Background() | |||
| 205 | + | ||||
| 206 | + | stackNode := stack(leaf("a"), leaf("b")) | |||
| 207 | + | b := stackNode.Children[1] | |||
| 208 | + | ws := &entity.Workspace{Root: stackNode, ActivePaneID: b.Pane.ID} | |||
| 209 | + | ||||
| 210 | + | // First press (on stacked pane): stack -> vertical split with active on bottom. | |||
| 211 | + | mustConsumeOrExpelAction(t, uc, ctx, ws, b, ConsumeOrExpelRight, "expelled") | |||
| 212 | + | mustSplitRoot(t, ws, entity.SplitVertical) | |||
| 213 | + | if string(ws.ActivePaneID) != "b" { | |||
| 214 | + | t.Fatalf("active=%s, want b", ws.ActivePaneID) | |||
| 215 | + | } | |||
| 216 | + | ||||
| 217 | + | active := ws.ActivePane() | |||
| 218 | + | mustLeafPaneID(t, active, "b") | |||
| 219 | + | ||||
| 220 | + | // Second press (on bottom pane): vertical -> horizontal split with active on right. | |||
| 221 | + | mustConsumeOrExpelAction(t, uc, ctx, ws, active, ConsumeOrExpelRight, "consumed") | |||
| 222 | + | root := mustSplitRoot(t, ws, entity.SplitHorizontal) | |||
| 223 | + | mustLeafPaneID(t, root.Left(), "a") | |||
| 224 | + | mustLeafPaneID(t, root.Right(), "b") | |||
| 225 | + | if string(ws.ActivePaneID) != "b" { | |||
| 226 | + | t.Fatalf("active=%s, want b", ws.ActivePaneID) | |||
| 227 | + | } | |||
| 228 | + | } | |||
| 229 | + | ||||
| 230 | + | func TestManagePanesUseCase_ConsumeOrExpel_ExpelThenConsumeLeft_ReturnsToStack(t *testing.T) { | |||
| 231 | + | uc := NewManagePanesUseCase(func() string { return "id" }) | |||
| 232 | + | ctx := context.Background() | |||
| 233 | + | ||||
| 234 | + | stackNode := stack(leaf("a"), leaf("b")) | |||
| 235 | + | b := stackNode.Children[1] | |||
| 236 | + | ws := &entity.Workspace{Root: stackNode, ActivePaneID: b.Pane.ID} | |||
| 237 | + | ||||
| 238 | + | // First press (on stacked pane): stack -> vertical split with active on bottom. | |||
| 239 | + | mustConsumeOrExpelAction(t, uc, ctx, ws, b, ConsumeOrExpelRight, "expelled") | |||
| 240 | + | mustSplitRoot(t, ws, entity.SplitVertical) | |||
| 241 | + | if string(ws.ActivePaneID) != "b" { | |||
| 242 | + | t.Fatalf("active=%s, want b", ws.ActivePaneID) | |||
| 243 | + | } | |||
| 244 | + | ||||
| 245 | + | active := ws.ActivePane() | |||
| 246 | + | mustLeafPaneID(t, active, "b") | |||
| 247 | + | ||||
| 248 | + | // Second press (on bottom pane): vertical -> stack (return to original). | |||
| 249 | + | mustConsumeOrExpelAction(t, uc, ctx, ws, active, ConsumeOrExpelLeft, "consumed") | |||
| 250 | + | if ws.Root == nil || !ws.Root.IsStacked { | |||
| 251 | + | t.Fatalf("root should be a stack") | |||
| 252 | + | } | |||
| 253 | + | if got := panesInOrder(ws.Root); got != "a,b" { | |||
| 254 | + | t.Fatalf("stack panes=%s, want a,b", got) | |||
| 255 | + | } | |||
| 256 | + | if string(ws.ActivePaneID) != "b" { | |||
| 257 | + | t.Fatalf("active=%s, want b", ws.ActivePaneID) | |||
| 258 | + | } | |||
| 259 | + | } | |||
| 260 | + | ||||
| 261 | + | func TestManagePanesUseCase_ConsumeOrExpel_Expel_StackRemainsWhenMoreThanTwo(t *testing.T) { | |||
| 262 | + | uc := NewManagePanesUseCase(func() string { return "id" }) | |||
| 263 | + | ctx := context.Background() | |||
| 264 | + | ||||
| 265 | + | stackNode := stack(leaf("a"), leaf("b"), leaf("c")) | |||
| 266 | + | b := stackNode.Children[1] | |||
| 267 | + | ws := &entity.Workspace{Root: stackNode, ActivePaneID: b.Pane.ID} | |||
| 268 | + | ||||
| 269 | + | res, err := uc.ConsumeOrExpel(ctx, ws, b, ConsumeOrExpelRight) | |||
| 270 | + | if err != nil { | |||
| 271 | + | t.Fatalf("unexpected error: %v", err) | |||
| 272 | + | } | |||
| 273 | + | if res == nil || res.Action != "expelled" { | |||
| 274 | + | t.Fatalf("result action=%v, want expelled", res) | |||
| 275 | + | } | |||
| 276 | + | if ws.Root == nil || !ws.Root.IsSplit() || ws.Root.SplitDir != entity.SplitVertical { | |||
| 277 | + | t.Fatalf("root should be a vertical split") | |||
| 278 | + | } | |||
| 279 | + | top := ws.Root.Left() | |||
| 280 | + | bottom := ws.Root.Right() | |||
| 281 | + | if top == nil || !top.IsStacked { | |||
| 282 | + | t.Fatalf("top child should be stack") | |||
| 283 | + | } | |||
| 284 | + | if panesInOrder(top) != "a,c" { | |||
| 285 | + | t.Fatalf("remaining stack panes=%s, want a,c", panesInOrder(top)) | |||
| 286 | + | } | |||
| 287 | + | if bottom == nil || !bottom.IsLeaf() || bottom.Pane.ID != "b" { | |||
| 288 | + | t.Fatalf("bottom child should be expelled leaf b") | |||
| 289 | + | } | |||
| 290 | + | if ws.ActivePaneID != "b" { | |||
| 291 | + | t.Fatalf("active=%s, want b", ws.ActivePaneID) | |||
| 292 | + | } | |||
| 293 | + | } | |||
| 294 | + | ||||
| 295 | + | func TestManagePanesUseCase_ConsumeOrExpel_NoSibling_ReturnsWarningAndNoop(t *testing.T) { | |||
| 296 | + | uc := NewManagePanesUseCase(func() string { return "id" }) | |||
| 297 | + | ctx := context.Background() | |||
| 298 | + | ||||
| 299 | + | left := leaf("left") | |||
| 300 | + | right := leaf("right") | |||
| 301 | + | root := split(entity.SplitHorizontal, left, right) | |||
| 302 | + | ws := &entity.Workspace{Root: root, ActivePaneID: left.Pane.ID} | |||
| 303 | + | ||||
| 304 | + | res, err := uc.ConsumeOrExpel(ctx, ws, left, ConsumeOrExpelLeft) | |||
| 305 | + | if err != nil { | |||
| 306 | + | t.Fatalf("unexpected error: %v", err) | |||
| 307 | + | } | |||
| 308 | + | if res == nil || res.Action != "none" { | |||
| 309 | + | t.Fatalf("result action=%v, want none", res) | |||
| 310 | + | } | |||
| 311 | + | if res.ErrorMessage == "" { | |||
| 312 | + | t.Fatalf("expected warning message") | |||
| 313 | + | } | |||
| 314 | + | if ws.Root != root { | |||
| 315 | + | t.Fatalf("tree should be unchanged") | |||
| 316 | + | } | |||
| 317 | + | } | |||
| 318 | + | ||||
| 319 | + | func TestManagePanesUseCase_ConsumeOrExpel_NestedSplits_ConsumesImmediateSiblingOnly(t *testing.T) { | |||
| 320 | + | uc := NewManagePanesUseCase(func() string { return "id" }) | |||
| 321 | + | ctx := context.Background() | |||
| 322 | + | ||||
| 323 | + | a := leaf("a") | |||
| 324 | + | b := leaf("b") | |||
| 325 | + | top := split(entity.SplitHorizontal, a, b) | |||
| 326 | + | c := leaf("c") | |||
| 327 | + | root := split(entity.SplitVertical, top, c) | |||
| 328 | + | ws := &entity.Workspace{Root: root, ActivePaneID: a.Pane.ID} | |||
| 329 | + | ||||
| 330 | + | res, err := uc.ConsumeOrExpel(ctx, ws, a, ConsumeOrExpelDown) | |||
| 331 | + | if err != nil { | |||
| 332 | + | t.Fatalf("unexpected error: %v", err) | |||
| 333 | + | } | |||
| 334 | + | if res == nil || res.Action != "consumed" { | |||
| 335 | + | t.Fatalf("result action=%v, want consumed", res) | |||
| 336 | + | } | |||
| 337 | + | if ws.Root == nil || !ws.Root.IsSplit() || ws.Root.SplitDir != entity.SplitVertical { | |||
| 338 | + | t.Fatalf("root should remain a vertical split") | |||
| 339 | + | } | |||
| 340 | + | if ws.Root.Right() == nil || !ws.Root.Right().IsStacked { | |||
| 341 | + | t.Fatalf("expected bottom pane to become a stack") | |||
| 342 | + | } | |||
| 343 | + | if panesInOrder(ws.Root.Right()) != "c,a" { | |||
| 344 | + | t.Fatalf("bottom stack panes=%s, want c,a", panesInOrder(ws.Root.Right())) | |||
| 345 | + | } | |||
| 346 | + | } | |||
| 347 | + | ||||
| 348 | + | func TestManagePanesUseCase_ConsumeOrExpel_OnlyOnePane(t *testing.T) { | |||
| 349 | + | uc := NewManagePanesUseCase(func() string { return "id" }) | |||
| 350 | + | ctx := context.Background() | |||
| 351 | + | ||||
| 352 | + | only := leaf("only") | |||
| 353 | + | ws := &entity.Workspace{Root: only, ActivePaneID: only.Pane.ID} | |||
| 354 | + | ||||
| 355 | + | res, err := uc.ConsumeOrExpel(ctx, ws, only, ConsumeOrExpelLeft) | |||
| 356 | + | if err != nil { | |||
| 357 | + | t.Fatalf("unexpected error: %v", err) | |||
| 358 | + | } | |||
| 359 | + | if res == nil || res.Action != "none" { | |||
| 360 | + | t.Fatalf("result action=%v, want none", res) | |||
| 361 | + | } | |||
| 362 | + | if res.ErrorMessage == "" { | |||
| 363 | + | t.Fatalf("expected warning message") | |||
| 364 | + | } | |||
| 365 | + | } | |||
| 366 | + | ||||
| 367 | + | func mustConsumeOrExpelAction( | |||
| 368 | + | t *testing.T, | |||
| 369 | + | uc *ManagePanesUseCase, | |||
| 370 | + | ctx context.Context, | |||
| 371 | + | ws *entity.Workspace, | |||
| 372 | + | node *entity.PaneNode, | |||
| 373 | + | direction ConsumeOrExpelDirection, | |||
| 374 | + | wantAction string, | |||
| 375 | + | ) { | |||
| 376 | + | t.Helper() | |||
| 377 | + | res, err := uc.ConsumeOrExpel(ctx, ws, node, direction) | |||
| 378 | + | if err != nil { | |||
| 379 | + | t.Fatalf("unexpected error: %v", err) | |||
| 380 | + | } | |||
| 381 | + | if res == nil || res.Action != wantAction { | |||
| 382 | + | t.Fatalf("result action=%v, want %s", res, wantAction) | |||
| 383 | + | } | |||
| 384 | + | } | |||
| 385 | + | ||||
| 386 | + | func mustSplitRoot(t *testing.T, ws *entity.Workspace, dir entity.SplitDirection) *entity.PaneNode { | |||
| 387 | + | t.Helper() | |||
| 388 | + | if ws == nil || ws.Root == nil || !ws.Root.IsSplit() || ws.Root.SplitDir != dir { | |||
| 389 | + | t.Fatalf("root should be a %v split", dir) | |||
| 390 | + | } | |||
| 391 | + | return ws.Root | |||
| 392 | + | } | |||
| 393 | + | ||||
| 394 | + | func mustLeafPaneID(t *testing.T, node *entity.PaneNode, want string) { | |||
| 395 | + | t.Helper() | |||
| 396 | + | if node == nil || !node.IsLeaf() || node.Pane == nil || string(node.Pane.ID) != want { | |||
| 397 | + | t.Fatalf("leaf pane=%v, want %s", node, want) | |||
| 398 | + | } | |||
| 399 | + | } | |||
| 400 | + | ||||
| 401 | + | func leaf(id string) *entity.PaneNode { | |||
| 402 | + | return &entity.PaneNode{ID: id, Pane: &entity.Pane{ID: entity.PaneID(id)}} | |||
| 403 | + | } | |||
| 404 | + | ||||
| 405 | + | func split(dir entity.SplitDirection, left, right *entity.PaneNode) *entity.PaneNode { | |||
| 406 | + | root := &entity.PaneNode{ID: "split", SplitDir: dir, SplitRatio: 0.5, Children: []*entity.PaneNode{left, right}} | |||
| 407 | + | left.Parent = root | |||
| 408 | + | right.Parent = root | |||
| 409 | + | return root | |||
| 410 | + | } | |||
| 411 | + | ||||
| 412 | + | func stack(panes ...*entity.PaneNode) *entity.PaneNode { | |||
| 413 | + | n := &entity.PaneNode{ID: "stack", IsStacked: true, ActiveStackIndex: 0} | |||
| 414 | + | children := make([]*entity.PaneNode, 0, len(panes)) | |||
| 415 | + | for _, p := range panes { | |||
| 416 | + | p.Parent = n | |||
| 417 | + | children = append(children, p) | |||
| 418 | + | } | |||
| 419 | + | n.Children = children | |||
| 420 | + | return n | |||
| 421 | + | } | |||
| 422 | + | ||||
| 423 | + | func panesInOrder(stackNode *entity.PaneNode) string { | |||
| 424 | + | if stackNode == nil || len(stackNode.Children) == 0 { | |||
| 425 | + | return "" | |||
| 426 | + | } | |||
| 427 | + | out := "" | |||
| 428 | + | for i, child := range stackNode.Children { | |||
| 429 | + | if child == nil || child.Pane == nil { | |||
| 430 | + | continue | |||
| 431 | + | } | |||
| 432 | + | if i > 0 { | |||
| 433 | + | out += "," | |||
| 434 | + | } | |||
| 435 | + | out += string(child.Pane.ID) | |||
| 436 | + | } | |||
| 437 | + | return out | |||
| 438 | + | } | |||
M internal/infrastructure/config/defaults.go +11 -0
| @@ -166,6 +166,12 @@ func DefaultConfig() *Config { | |||
| 166 | 166 | "split-down": {"arrowdown", "d"}, | |
| 167 | 167 | "stack-pane": {"s"}, | |
| 168 | 168 | "close-pane": {"x"}, | |
| 169 | + | ||
| 170 | + | "consume-or-expel-left": {"bracketleft", "["}, | |
| 171 | + | "consume-or-expel-right": {"bracketright", "]"}, | |
| 172 | + | "consume-or-expel-up": {"shift+bracketleft", "braceleft", "{"}, | |
| 173 | + | "consume-or-expel-down": {"shift+bracketright", "braceright", "}"}, | |
| 174 | + | ||
| 169 | 175 | "focus-right": {"shift+arrowright", "shift+l"}, | |
| 170 | 176 | "focus-left": {"shift+arrowleft", "shift+h"}, | |
| 171 | 177 | "focus-up": {"shift+arrowup", "shift+k"}, | |
| @@ -211,6 +217,11 @@ func DefaultConfig() *Config { | |||
| 211 | 217 | ClosePane: "ctrl+w", | |
| 212 | 218 | NextTab: "ctrl+tab", | |
| 213 | 219 | PreviousTab: "ctrl+shift+tab", | |
| 220 | + | ||
| 221 | + | ConsumeOrExpelLeft: "alt+bracketleft", | |
| 222 | + | ConsumeOrExpelRight: "alt+bracketright", | |
| 223 | + | ConsumeOrExpelUp: "alt+shift+bracketleft", | |
| 224 | + | ConsumeOrExpelDown: "alt+shift+bracketright", | |
| 214 | 225 | }, | |
| 215 | 226 | TabBarPosition: defaultTabBarPosition, | |
| 216 | 227 | HideTabBarWhenSingleTab: true, | |
| @@ -166,6 +166,12 @@ func DefaultConfig() *Config { | |||||
| 166 | "split-down": {"arrowdown", "d"}, | 166 | "split-down": {"arrowdown", "d"}, | ||
| 167 | "stack-pane": {"s"}, | 167 | "stack-pane": {"s"}, | ||
| 168 | "close-pane": {"x"}, | 168 | "close-pane": {"x"}, | ||
| 169 | + | ||||
| 170 | + | "consume-or-expel-left": {"bracketleft", "["}, | |||
| 171 | + | "consume-or-expel-right": {"bracketright", "]"}, | |||
| 172 | + | "consume-or-expel-up": {"shift+bracketleft", "braceleft", "{"}, | |||
| 173 | + | "consume-or-expel-down": {"shift+bracketright", "braceright", "}"}, | |||
| 174 | + | ||||
| 169 | "focus-right": {"shift+arrowright", "shift+l"}, | 175 | "focus-right": {"shift+arrowright", "shift+l"}, | ||
| 170 | "focus-left": {"shift+arrowleft", "shift+h"}, | 176 | "focus-left": {"shift+arrowleft", "shift+h"}, | ||
| 171 | "focus-up": {"shift+arrowup", "shift+k"}, | 177 | "focus-up": {"shift+arrowup", "shift+k"}, | ||
| @@ -211,6 +217,11 @@ func DefaultConfig() *Config { | |||||
| 211 | ClosePane: "ctrl+w", | 217 | ClosePane: "ctrl+w", | ||
| 212 | NextTab: "ctrl+tab", | 218 | NextTab: "ctrl+tab", | ||
| 213 | PreviousTab: "ctrl+shift+tab", | 219 | PreviousTab: "ctrl+shift+tab", | ||
| 220 | + | ||||
| 221 | + | ConsumeOrExpelLeft: "alt+bracketleft", | |||
| 222 | + | ConsumeOrExpelRight: "alt+bracketright", | |||
| 223 | + | ConsumeOrExpelUp: "alt+shift+bracketleft", | |||
| 224 | + | ConsumeOrExpelDown: "alt+shift+bracketright", | |||
| 214 | }, | 225 | }, | ||
| 215 | TabBarPosition: defaultTabBarPosition, | 226 | TabBarPosition: defaultTabBarPosition, | ||
| 216 | HideTabBarWhenSingleTab: true, | 227 | HideTabBarWhenSingleTab: true, | ||
M internal/infrastructure/config/loader.go +4 -0
| @@ -395,6 +395,10 @@ func (m *Manager) setWorkspaceDefaults(defaults *Config) { | |||
| 395 | 395 | m.viper.SetDefault("workspace.shortcuts.close_pane", defaults.Workspace.Shortcuts.ClosePane) | |
| 396 | 396 | m.viper.SetDefault("workspace.shortcuts.next_tab", defaults.Workspace.Shortcuts.NextTab) | |
| 397 | 397 | m.viper.SetDefault("workspace.shortcuts.previous_tab", defaults.Workspace.Shortcuts.PreviousTab) | |
| 398 | + | m.viper.SetDefault("workspace.shortcuts.consume_or_expel_left", defaults.Workspace.Shortcuts.ConsumeOrExpelLeft) | |
| 399 | + | m.viper.SetDefault("workspace.shortcuts.consume_or_expel_right", defaults.Workspace.Shortcuts.ConsumeOrExpelRight) | |
| 400 | + | m.viper.SetDefault("workspace.shortcuts.consume_or_expel_up", defaults.Workspace.Shortcuts.ConsumeOrExpelUp) | |
| 401 | + | m.viper.SetDefault("workspace.shortcuts.consume_or_expel_down", defaults.Workspace.Shortcuts.ConsumeOrExpelDown) | |
| 398 | 402 | m.viper.SetDefault("workspace.tab_bar_position", defaults.Workspace.TabBarPosition) | |
| 399 | 403 | m.viper.SetDefault("workspace.hide_tab_bar_when_single_tab", defaults.Workspace.HideTabBarWhenSingleTab) | |
| 400 | 404 | m.viper.SetDefault("workspace.popups.behavior", string(defaults.Workspace.Popups.Behavior)) | |
| @@ -395,6 +395,10 @@ func (m *Manager) setWorkspaceDefaults(defaults *Config) { | |||||
| 395 | m.viper.SetDefault("workspace.shortcuts.close_pane", defaults.Workspace.Shortcuts.ClosePane) | 395 | m.viper.SetDefault("workspace.shortcuts.close_pane", defaults.Workspace.Shortcuts.ClosePane) | ||
| 396 | m.viper.SetDefault("workspace.shortcuts.next_tab", defaults.Workspace.Shortcuts.NextTab) | 396 | m.viper.SetDefault("workspace.shortcuts.next_tab", defaults.Workspace.Shortcuts.NextTab) | ||
| 397 | m.viper.SetDefault("workspace.shortcuts.previous_tab", defaults.Workspace.Shortcuts.PreviousTab) | 397 | m.viper.SetDefault("workspace.shortcuts.previous_tab", defaults.Workspace.Shortcuts.PreviousTab) | ||
| 398 | + | m.viper.SetDefault("workspace.shortcuts.consume_or_expel_left", defaults.Workspace.Shortcuts.ConsumeOrExpelLeft) | |||
| 399 | + | m.viper.SetDefault("workspace.shortcuts.consume_or_expel_right", defaults.Workspace.Shortcuts.ConsumeOrExpelRight) | |||
| 400 | + | m.viper.SetDefault("workspace.shortcuts.consume_or_expel_up", defaults.Workspace.Shortcuts.ConsumeOrExpelUp) | |||
| 401 | + | m.viper.SetDefault("workspace.shortcuts.consume_or_expel_down", defaults.Workspace.Shortcuts.ConsumeOrExpelDown) | |||
| 398 | m.viper.SetDefault("workspace.tab_bar_position", defaults.Workspace.TabBarPosition) | 402 | m.viper.SetDefault("workspace.tab_bar_position", defaults.Workspace.TabBarPosition) | ||
| 399 | m.viper.SetDefault("workspace.hide_tab_bar_when_single_tab", defaults.Workspace.HideTabBarWhenSingleTab) | 403 | m.viper.SetDefault("workspace.hide_tab_bar_when_single_tab", defaults.Workspace.HideTabBarWhenSingleTab) | ||
| 400 | m.viper.SetDefault("workspace.popups.behavior", string(defaults.Workspace.Popups.Behavior)) | 404 | m.viper.SetDefault("workspace.popups.behavior", string(defaults.Workspace.Popups.Behavior)) | ||
M internal/infrastructure/config/schema.go +5 -0
| @@ -383,6 +383,11 @@ type GlobalShortcutsConfig struct { | |||
| 383 | 383 | ClosePane string `mapstructure:"close_pane" yaml:"close_pane" toml:"close_pane" json:"close_pane"` | |
| 384 | 384 | NextTab string `mapstructure:"next_tab" yaml:"next_tab" toml:"next_tab" json:"next_tab"` | |
| 385 | 385 | PreviousTab string `mapstructure:"previous_tab" yaml:"previous_tab" toml:"previous_tab" json:"previous_tab"` | |
| 386 | + | ||
| 387 | + | ConsumeOrExpelLeft string `mapstructure:"consume_or_expel_left" yaml:"consume_or_expel_left" toml:"consume_or_expel_left" json:"consume_or_expel_left"` //nolint:lll // struct tags must stay on one line | |
| 388 | + | ConsumeOrExpelRight string `mapstructure:"consume_or_expel_right" yaml:"consume_or_expel_right" toml:"consume_or_expel_right" json:"consume_or_expel_right"` //nolint:lll // struct tags must stay on one line | |
| 389 | + | ConsumeOrExpelUp string `mapstructure:"consume_or_expel_up" yaml:"consume_or_expel_up" toml:"consume_or_expel_up" json:"consume_or_expel_up"` //nolint:lll // struct tags must stay on one line | |
| 390 | + | ConsumeOrExpelDown string `mapstructure:"consume_or_expel_down" yaml:"consume_or_expel_down" toml:"consume_or_expel_down" json:"consume_or_expel_down"` //nolint:lll // struct tags must stay on one line | |
| 386 | 391 | } | |
| 387 | 392 | ||
| 388 | 393 | // PopupBehavior defines how popup windows should be opened | |
| @@ -383,6 +383,11 @@ type GlobalShortcutsConfig struct { | |||||
| 383 | ClosePane string `mapstructure:"close_pane" yaml:"close_pane" toml:"close_pane" json:"close_pane"` | 383 | ClosePane string `mapstructure:"close_pane" yaml:"close_pane" toml:"close_pane" json:"close_pane"` | ||
| 384 | NextTab string `mapstructure:"next_tab" yaml:"next_tab" toml:"next_tab" json:"next_tab"` | 384 | NextTab string `mapstructure:"next_tab" yaml:"next_tab" toml:"next_tab" json:"next_tab"` | ||
| 385 | PreviousTab string `mapstructure:"previous_tab" yaml:"previous_tab" toml:"previous_tab" json:"previous_tab"` | 385 | PreviousTab string `mapstructure:"previous_tab" yaml:"previous_tab" toml:"previous_tab" json:"previous_tab"` | ||
| 386 | + | ||||
| 387 | + | ConsumeOrExpelLeft string `mapstructure:"consume_or_expel_left" yaml:"consume_or_expel_left" toml:"consume_or_expel_left" json:"consume_or_expel_left"` //nolint:lll // struct tags must stay on one line | |||
| 388 | + | ConsumeOrExpelRight string `mapstructure:"consume_or_expel_right" yaml:"consume_or_expel_right" toml:"consume_or_expel_right" json:"consume_or_expel_right"` //nolint:lll // struct tags must stay on one line | |||
| 389 | + | ConsumeOrExpelUp string `mapstructure:"consume_or_expel_up" yaml:"consume_or_expel_up" toml:"consume_or_expel_up" json:"consume_or_expel_up"` //nolint:lll // struct tags must stay on one line | |||
| 390 | + | ConsumeOrExpelDown string `mapstructure:"consume_or_expel_down" yaml:"consume_or_expel_down" toml:"consume_or_expel_down" json:"consume_or_expel_down"` //nolint:lll // struct tags must stay on one line | |||
| 386 | } | 391 | } | ||
| 387 | 392 | ||||
| 388 | // PopupBehavior defines how popup windows should be opened | 393 | // PopupBehavior defines how popup windows should be opened | ||
M internal/ui/app.go +1 -0
| @@ -335,6 +335,7 @@ func (a *App) initKeyboardHandler(ctx context.Context) { | |||
| 335 | 335 | a.globalShortcutHandler = input.NewGlobalShortcutHandler( | |
| 336 | 336 | ctx, | |
| 337 | 337 | a.mainWindow.Window(), | |
| 338 | + | a.deps.Config, | |
| 338 | 339 | func(ctx context.Context, action input.Action) error { | |
| 339 | 340 | return a.kbDispatcher.Dispatch(ctx, action) | |
| 340 | 341 | }, | |
| @@ -335,6 +335,7 @@ func (a *App) initKeyboardHandler(ctx context.Context) { | |||||
| 335 | a.globalShortcutHandler = input.NewGlobalShortcutHandler( | 335 | a.globalShortcutHandler = input.NewGlobalShortcutHandler( | ||
| 336 | ctx, | 336 | ctx, | ||
| 337 | a.mainWindow.Window(), | 337 | a.mainWindow.Window(), | ||
| 338 | + | a.deps.Config, | |||
| 338 | func(ctx context.Context, action input.Action) error { | 339 | func(ctx context.Context, action input.Action) error { | ||
| 339 | return a.kbDispatcher.Dispatch(ctx, action) | 340 | return a.kbDispatcher.Dispatch(ctx, action) | ||
| 340 | }, | 341 | }, | ||
M internal/ui/coordinator/workspace.go +44 -0
| @@ -1862,6 +1862,50 @@ func (c *WorkspaceCoordinator) insertPopupTabbed(ctx context.Context, input Inse | |||
| 1862 | 1862 | return nil | |
| 1863 | 1863 | } | |
| 1864 | 1864 | ||
| 1865 | + | func (c *WorkspaceCoordinator) ConsumeOrExpelPane(ctx context.Context, direction usecase.ConsumeOrExpelDirection) error { | |
| 1866 | + | log := logging.FromContext(ctx) | |
| 1867 | + | ||
| 1868 | + | if c.panesUC == nil { | |
| 1869 | + | log.Warn().Msg("panes use case not available") | |
| 1870 | + | return nil | |
| 1871 | + | } | |
| 1872 | + | ||
| 1873 | + | ws, wsView := c.getActiveWS() | |
| 1874 | + | if ws == nil { | |
| 1875 | + | log.Warn().Msg("no active workspace") | |
| 1876 | + | return nil | |
| 1877 | + | } | |
| 1878 | + | ||
| 1879 | + | activeNode := ws.ActivePane() | |
| 1880 | + | if activeNode == nil { | |
| 1881 | + | log.Warn().Msg("no active pane") | |
| 1882 | + | return nil | |
| 1883 | + | } | |
| 1884 | + | ||
| 1885 | + | result, err := c.panesUC.ConsumeOrExpel(ctx, ws, activeNode, direction) | |
| 1886 | + | if err != nil { | |
| 1887 | + | return err | |
| 1888 | + | } | |
| 1889 | + | if result != nil && result.ErrorMessage != "" { | |
| 1890 | + | c.ShowToastOnActivePane(ctx, result.ErrorMessage, component.ToastWarning) | |
| 1891 | + | return nil | |
| 1892 | + | } | |
| 1893 | + | ||
| 1894 | + | if wsView != nil { | |
| 1895 | + | if err := wsView.Rebuild(ctx); err != nil { | |
| 1896 | + | log.Warn().Err(err).Msg("failed to rebuild workspace view") | |
| 1897 | + | } | |
| 1898 | + | c.contentCoord.AttachToWorkspace(ctx, ws, wsView) | |
| 1899 | + | if err := wsView.SetActivePaneID(ws.ActivePaneID); err != nil { | |
| 1900 | + | log.Warn().Err(err).Msg("failed to set active pane in workspace view") | |
| 1901 | + | } | |
| 1902 | + | wsView.FocusPane(ws.ActivePaneID) | |
| 1903 | + | } | |
| 1904 | + | ||
| 1905 | + | c.notifyStateChanged() | |
| 1906 | + | return nil | |
| 1907 | + | } | |
| 1908 | + | ||
| 1865 | 1909 | // ShowZoomToast displays a zoom level toast on the active pane. | |
| 1866 | 1910 | func (c *WorkspaceCoordinator) ShowZoomToast(ctx context.Context, zoomPercent int) { | |
| 1867 | 1911 | _, wsView := c.getActiveWS() | |
| @@ -1862,6 +1862,50 @@ func (c *WorkspaceCoordinator) insertPopupTabbed(ctx context.Context, input Inse | |||||
| 1862 | return nil | 1862 | return nil | ||
| 1863 | } | 1863 | } | ||
| 1864 | 1864 | ||||
| 1865 | + | func (c *WorkspaceCoordinator) ConsumeOrExpelPane(ctx context.Context, direction usecase.ConsumeOrExpelDirection) error { | |||
| 1866 | + | log := logging.FromContext(ctx) | |||
| 1867 | + | ||||
| 1868 | + | if c.panesUC == nil { | |||
| 1869 | + | log.Warn().Msg("panes use case not available") | |||
| 1870 | + | return nil | |||
| 1871 | + | } | |||
| 1872 | + | ||||
| 1873 | + | ws, wsView := c.getActiveWS() | |||
| 1874 | + | if ws == nil { | |||
| 1875 | + | log.Warn().Msg("no active workspace") | |||
| 1876 | + | return nil | |||
| 1877 | + | } | |||
| 1878 | + | ||||
| 1879 | + | activeNode := ws.ActivePane() | |||
| 1880 | + | if activeNode == nil { | |||
| 1881 | + | log.Warn().Msg("no active pane") | |||
| 1882 | + | return nil | |||
| 1883 | + | } | |||
| 1884 | + | ||||
| 1885 | + | result, err := c.panesUC.ConsumeOrExpel(ctx, ws, activeNode, direction) | |||
| 1886 | + | if err != nil { | |||
| 1887 | + | return err | |||
| 1888 | + | } | |||
| 1889 | + | if result != nil && result.ErrorMessage != "" { | |||
| 1890 | + | c.ShowToastOnActivePane(ctx, result.ErrorMessage, component.ToastWarning) | |||
| 1891 | + | return nil | |||
| 1892 | + | } | |||
| 1893 | + | ||||
| 1894 | + | if wsView != nil { | |||
| 1895 | + | if err := wsView.Rebuild(ctx); err != nil { | |||
| 1896 | + | log.Warn().Err(err).Msg("failed to rebuild workspace view") | |||
| 1897 | + | } | |||
| 1898 | + | c.contentCoord.AttachToWorkspace(ctx, ws, wsView) | |||
| 1899 | + | if err := wsView.SetActivePaneID(ws.ActivePaneID); err != nil { | |||
| 1900 | + | log.Warn().Err(err).Msg("failed to set active pane in workspace view") | |||
| 1901 | + | } | |||
| 1902 | + | wsView.FocusPane(ws.ActivePaneID) | |||
| 1903 | + | } | |||
| 1904 | + | ||||
| 1905 | + | c.notifyStateChanged() | |||
| 1906 | + | return nil | |||
| 1907 | + | } | |||
| 1908 | + | ||||
| 1865 | // ShowZoomToast displays a zoom level toast on the active pane. | 1909 | // ShowZoomToast displays a zoom level toast on the active pane. | ||
| 1866 | func (c *WorkspaceCoordinator) ShowZoomToast(ctx context.Context, zoomPercent int) { | 1910 | func (c *WorkspaceCoordinator) ShowZoomToast(ctx context.Context, zoomPercent int) { | ||
| 1867 | _, wsView := c.getActiveWS() | 1911 | _, wsView := c.getActiveWS() | ||
M internal/ui/dispatcher/keyboard.go +12 -0
| @@ -128,6 +128,18 @@ func (d *KeyboardDispatcher) initActionHandlers() { | |||
| 128 | 128 | input.ActionSplitDown: func(ctx context.Context) error { return d.wsCoord.Split(ctx, usecase.SplitDown) }, | |
| 129 | 129 | input.ActionClosePane: d.wsCoord.ClosePane, | |
| 130 | 130 | input.ActionStackPane: d.wsCoord.StackPane, | |
| 131 | + | input.ActionConsumeOrExpelLeft: func(ctx context.Context) error { | |
| 132 | + | return d.wsCoord.ConsumeOrExpelPane(ctx, usecase.ConsumeOrExpelLeft) | |
| 133 | + | }, | |
| 134 | + | input.ActionConsumeOrExpelRight: func(ctx context.Context) error { | |
| 135 | + | return d.wsCoord.ConsumeOrExpelPane(ctx, usecase.ConsumeOrExpelRight) | |
| 136 | + | }, | |
| 137 | + | input.ActionConsumeOrExpelUp: func(ctx context.Context) error { | |
| 138 | + | return d.wsCoord.ConsumeOrExpelPane(ctx, usecase.ConsumeOrExpelUp) | |
| 139 | + | }, | |
| 140 | + | input.ActionConsumeOrExpelDown: func(ctx context.Context) error { | |
| 141 | + | return d.wsCoord.ConsumeOrExpelPane(ctx, usecase.ConsumeOrExpelDown) | |
| 142 | + | }, | |
| 131 | 143 | input.ActionFocusRight: func(ctx context.Context) error { return d.wsCoord.FocusPane(ctx, usecase.NavRight) }, | |
| 132 | 144 | input.ActionFocusLeft: func(ctx context.Context) error { return d.wsCoord.FocusPane(ctx, usecase.NavLeft) }, | |
| 133 | 145 | input.ActionFocusUp: func(ctx context.Context) error { return d.wsCoord.FocusPane(ctx, usecase.NavUp) }, | |
| @@ -128,6 +128,18 @@ func (d *KeyboardDispatcher) initActionHandlers() { | |||||
| 128 | input.ActionSplitDown: func(ctx context.Context) error { return d.wsCoord.Split(ctx, usecase.SplitDown) }, | 128 | input.ActionSplitDown: func(ctx context.Context) error { return d.wsCoord.Split(ctx, usecase.SplitDown) }, | ||
| 129 | input.ActionClosePane: d.wsCoord.ClosePane, | 129 | input.ActionClosePane: d.wsCoord.ClosePane, | ||
| 130 | input.ActionStackPane: d.wsCoord.StackPane, | 130 | input.ActionStackPane: d.wsCoord.StackPane, | ||
| 131 | + | input.ActionConsumeOrExpelLeft: func(ctx context.Context) error { | |||
| 132 | + | return d.wsCoord.ConsumeOrExpelPane(ctx, usecase.ConsumeOrExpelLeft) | |||
| 133 | + | }, | |||
| 134 | + | input.ActionConsumeOrExpelRight: func(ctx context.Context) error { | |||
| 135 | + | return d.wsCoord.ConsumeOrExpelPane(ctx, usecase.ConsumeOrExpelRight) | |||
| 136 | + | }, | |||
| 137 | + | input.ActionConsumeOrExpelUp: func(ctx context.Context) error { | |||
| 138 | + | return d.wsCoord.ConsumeOrExpelPane(ctx, usecase.ConsumeOrExpelUp) | |||
| 139 | + | }, | |||
| 140 | + | input.ActionConsumeOrExpelDown: func(ctx context.Context) error { | |||
| 141 | + | return d.wsCoord.ConsumeOrExpelPane(ctx, usecase.ConsumeOrExpelDown) | |||
| 142 | + | }, | |||
| 131 | input.ActionFocusRight: func(ctx context.Context) error { return d.wsCoord.FocusPane(ctx, usecase.NavRight) }, | 143 | input.ActionFocusRight: func(ctx context.Context) error { return d.wsCoord.FocusPane(ctx, usecase.NavRight) }, | ||
| 132 | input.ActionFocusLeft: func(ctx context.Context) error { return d.wsCoord.FocusPane(ctx, usecase.NavLeft) }, | 144 | input.ActionFocusLeft: func(ctx context.Context) error { return d.wsCoord.FocusPane(ctx, usecase.NavLeft) }, | ||
| 133 | input.ActionFocusUp: func(ctx context.Context) error { return d.wsCoord.FocusPane(ctx, usecase.NavUp) }, | 145 | input.ActionFocusUp: func(ctx context.Context) error { return d.wsCoord.FocusPane(ctx, usecase.NavUp) }, | ||
M internal/ui/input/global_shortcuts.go +19 -0
| @@ -4,6 +4,7 @@ package input | |||
| 4 | 4 | import ( | |
| 5 | 5 | "context" | |
| 6 | 6 | ||
| 7 | + | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 7 | 8 | "github.com/bnema/dumber/internal/logging" | |
| 8 | 9 | "github.com/jwijenbergh/puregotk/v4/gdk" | |
| 9 | 10 | "github.com/jwijenbergh/puregotk/v4/glib" | |
| @@ -27,6 +28,7 @@ type GlobalShortcutHandler struct { | |||
| 27 | 28 | func NewGlobalShortcutHandler( | |
| 28 | 29 | ctx context.Context, | |
| 29 | 30 | window *gtk.ApplicationWindow, | |
| 31 | + | cfg *config.Config, | |
| 30 | 32 | onAction ActionHandler, | |
| 31 | 33 | ) *GlobalShortcutHandler { | |
| 32 | 34 | log := logging.FromContext(ctx) | |
| @@ -91,6 +93,23 @@ func NewGlobalShortcutHandler( | |||
| 91 | 93 | Str("action", string(ActionOpenSessionManager)). | |
| 92 | 94 | Msg("registered global shortcut") | |
| 93 | 95 | ||
| 96 | + | if cfg != nil { | |
| 97 | + | registerFromConfig := func(key string, action Action) { | |
| 98 | + | binding, ok := ParseKeyString(key) | |
| 99 | + | if !ok { | |
| 100 | + | log.Warn().Str("shortcut", key).Str("action", string(action)).Msg("failed to parse global shortcut") | |
| 101 | + | return | |
| 102 | + | } | |
| 103 | + | h.registerShortcut(binding.Keyval, gdk.ModifierType(binding.Modifiers), action) | |
| 104 | + | log.Trace().Str("shortcut", key).Str("action", string(action)).Msg("registered global shortcut") | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | registerFromConfig(cfg.Workspace.Shortcuts.ConsumeOrExpelLeft, ActionConsumeOrExpelLeft) | |
| 108 | + | registerFromConfig(cfg.Workspace.Shortcuts.ConsumeOrExpelRight, ActionConsumeOrExpelRight) | |
| 109 | + | registerFromConfig(cfg.Workspace.Shortcuts.ConsumeOrExpelUp, ActionConsumeOrExpelUp) | |
| 110 | + | registerFromConfig(cfg.Workspace.Shortcuts.ConsumeOrExpelDown, ActionConsumeOrExpelDown) | |
| 111 | + | } | |
| 112 | + | ||
| 94 | 113 | // Attach to window | |
| 95 | 114 | window.AddController(&h.controller.EventController) | |
| 96 | 115 | ||
| @@ -4,6 +4,7 @@ package input | |||||
| 4 | import ( | 4 | import ( | ||
| 5 | "context" | 5 | "context" | ||
| 6 | 6 | ||||
| 7 | + | "github.com/bnema/dumber/internal/infrastructure/config" | |||
| 7 | "github.com/bnema/dumber/internal/logging" | 8 | "github.com/bnema/dumber/internal/logging" | ||
| 8 | "github.com/jwijenbergh/puregotk/v4/gdk" | 9 | "github.com/jwijenbergh/puregotk/v4/gdk" | ||
| 9 | "github.com/jwijenbergh/puregotk/v4/glib" | 10 | "github.com/jwijenbergh/puregotk/v4/glib" | ||
| @@ -27,6 +28,7 @@ type GlobalShortcutHandler struct { | |||||
| 27 | func NewGlobalShortcutHandler( | 28 | func NewGlobalShortcutHandler( | ||
| 28 | ctx context.Context, | 29 | ctx context.Context, | ||
| 29 | window *gtk.ApplicationWindow, | 30 | window *gtk.ApplicationWindow, | ||
| 31 | + | cfg *config.Config, | |||
| 30 | onAction ActionHandler, | 32 | onAction ActionHandler, | ||
| 31 | ) *GlobalShortcutHandler { | 33 | ) *GlobalShortcutHandler { | ||
| 32 | log := logging.FromContext(ctx) | 34 | log := logging.FromContext(ctx) | ||
| @@ -91,6 +93,23 @@ func NewGlobalShortcutHandler( | |||||
| 91 | Str("action", string(ActionOpenSessionManager)). | 93 | Str("action", string(ActionOpenSessionManager)). | ||
| 92 | Msg("registered global shortcut") | 94 | Msg("registered global shortcut") | ||
| 93 | 95 | ||||
| 96 | + | if cfg != nil { | |||
| 97 | + | registerFromConfig := func(key string, action Action) { | |||
| 98 | + | binding, ok := ParseKeyString(key) | |||
| 99 | + | if !ok { | |||
| 100 | + | log.Warn().Str("shortcut", key).Str("action", string(action)).Msg("failed to parse global shortcut") | |||
| 101 | + | return | |||
| 102 | + | } | |||
| 103 | + | h.registerShortcut(binding.Keyval, gdk.ModifierType(binding.Modifiers), action) | |||
| 104 | + | log.Trace().Str("shortcut", key).Str("action", string(action)).Msg("registered global shortcut") | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | registerFromConfig(cfg.Workspace.Shortcuts.ConsumeOrExpelLeft, ActionConsumeOrExpelLeft) | |||
| 108 | + | registerFromConfig(cfg.Workspace.Shortcuts.ConsumeOrExpelRight, ActionConsumeOrExpelRight) | |||
| 109 | + | registerFromConfig(cfg.Workspace.Shortcuts.ConsumeOrExpelUp, ActionConsumeOrExpelUp) | |||
| 110 | + | registerFromConfig(cfg.Workspace.Shortcuts.ConsumeOrExpelDown, ActionConsumeOrExpelDown) | |||
| 111 | + | } | |||
| 112 | + | ||||
| 94 | // Attach to window | 113 | // Attach to window | ||
| 95 | window.AddController(&h.controller.EventController) | 114 | window.AddController(&h.controller.EventController) | ||
| 96 | 115 | ||||
M internal/ui/input/keyboard.go +17 -0
| @@ -212,6 +212,23 @@ func (h *KeyboardHandler) lookupAction( | |||
| 212 | 212 | } | |
| 213 | 213 | } | |
| 214 | 214 | ||
| 215 | + | if !found && mode == ModeNormal && (modifiers == ModAlt || modifiers == (ModAlt|ModShift)) { | |
| 216 | + | if bracketActions, ok := KeycodeToBracketAction[keycode]; ok { | |
| 217 | + | if modifiers == (ModAlt | ModShift) { | |
| 218 | + | action = bracketActions.WithShift | |
| 219 | + | } else { | |
| 220 | + | action = bracketActions.NoShift | |
| 221 | + | } | |
| 222 | + | found = action != "" | |
| 223 | + | if found && log != nil { | |
| 224 | + | log.Debug(). | |
| 225 | + | Uint("keycode", keycode). | |
| 226 | + | Str("action", string(action)). | |
| 227 | + | Msg("consume/expel via hardware keycode fallback (non-QWERTY layout)") | |
| 228 | + | } | |
| 229 | + | } | |
| 230 | + | } | |
| 231 | + | ||
| 215 | 232 | return action, found | |
| 216 | 233 | } | |
| 217 | 234 | ||
| @@ -212,6 +212,23 @@ func (h *KeyboardHandler) lookupAction( | |||||
| 212 | } | 212 | } | ||
| 213 | } | 213 | } | ||
| 214 | 214 | ||||
| 215 | + | if !found && mode == ModeNormal && (modifiers == ModAlt || modifiers == (ModAlt|ModShift)) { | |||
| 216 | + | if bracketActions, ok := KeycodeToBracketAction[keycode]; ok { | |||
| 217 | + | if modifiers == (ModAlt | ModShift) { | |||
| 218 | + | action = bracketActions.WithShift | |||
| 219 | + | } else { | |||
| 220 | + | action = bracketActions.NoShift | |||
| 221 | + | } | |||
| 222 | + | found = action != "" | |||
| 223 | + | if found && log != nil { | |||
| 224 | + | log.Debug(). | |||
| 225 | + | Uint("keycode", keycode). | |||
| 226 | + | Str("action", string(action)). | |||
| 227 | + | Msg("consume/expel via hardware keycode fallback (non-QWERTY layout)") | |||
| 228 | + | } | |||
| 229 | + | } | |||
| 230 | + | } | |||
| 231 | + | ||||
| 215 | return action, found | 232 | return action, found | ||
| 216 | } | 233 | } | ||
| 217 | 234 | ||||
M internal/ui/input/keycodes.go +17 -0
| @@ -22,6 +22,9 @@ const ( | |||
| 22 | 22 | KeycodeDigit8 uint = 17 // Physical '8' key position | |
| 23 | 23 | KeycodeDigit9 uint = 18 // Physical '9' key position | |
| 24 | 24 | KeycodeDigit0 uint = 19 // Physical '0' key position | |
| 25 | + | ||
| 26 | + | KeycodeBracketLeft uint = 34 // Physical '[' key position (US layout) | |
| 27 | + | KeycodeBracketRight uint = 35 // Physical ']' key position (US layout) | |
| 25 | 28 | ) | |
| 26 | 29 | ||
| 27 | 30 | // KeycodeToTabAction maps hardware keycodes for the number row to tab switch actions. | |
| @@ -40,6 +43,20 @@ var KeycodeToTabAction = map[uint]Action{ | |||
| 40 | 43 | KeycodeDigit0: ActionSwitchTabIndex10, | |
| 41 | 44 | } | |
| 42 | 45 | ||
| 46 | + | var KeycodeToBracketAction = map[uint]struct { | |
| 47 | + | NoShift Action | |
| 48 | + | WithShift Action | |
| 49 | + | }{ | |
| 50 | + | KeycodeBracketLeft: { | |
| 51 | + | NoShift: ActionConsumeOrExpelLeft, | |
| 52 | + | WithShift: ActionConsumeOrExpelUp, | |
| 53 | + | }, | |
| 54 | + | KeycodeBracketRight: { | |
| 55 | + | NoShift: ActionConsumeOrExpelRight, | |
| 56 | + | WithShift: ActionConsumeOrExpelDown, | |
| 57 | + | }, | |
| 58 | + | } | |
| 59 | + | ||
| 43 | 60 | // KeycodeToDigitIndex converts a hardware keycode to a digit index (0-9). | |
| 44 | 61 | // Returns the index and true if the keycode is a number row key, or -1 and false otherwise. | |
| 45 | 62 | // This is useful for components like omnibox that need Ctrl+1-9/0 shortcuts. | |
| @@ -22,6 +22,9 @@ const ( | |||||
| 22 | KeycodeDigit8 uint = 17 // Physical '8' key position | 22 | KeycodeDigit8 uint = 17 // Physical '8' key position | ||
| 23 | KeycodeDigit9 uint = 18 // Physical '9' key position | 23 | KeycodeDigit9 uint = 18 // Physical '9' key position | ||
| 24 | KeycodeDigit0 uint = 19 // Physical '0' key position | 24 | KeycodeDigit0 uint = 19 // Physical '0' key position | ||
| 25 | + | ||||
| 26 | + | KeycodeBracketLeft uint = 34 // Physical '[' key position (US layout) | |||
| 27 | + | KeycodeBracketRight uint = 35 // Physical ']' key position (US layout) | |||
| 25 | ) | 28 | ) | ||
| 26 | 29 | ||||
| 27 | // KeycodeToTabAction maps hardware keycodes for the number row to tab switch actions. | 30 | // KeycodeToTabAction maps hardware keycodes for the number row to tab switch actions. | ||
| @@ -40,6 +43,20 @@ var KeycodeToTabAction = map[uint]Action{ | |||||
| 40 | KeycodeDigit0: ActionSwitchTabIndex10, | 43 | KeycodeDigit0: ActionSwitchTabIndex10, | ||
| 41 | } | 44 | } | ||
| 42 | 45 | ||||
| 46 | + | var KeycodeToBracketAction = map[uint]struct { | |||
| 47 | + | NoShift Action | |||
| 48 | + | WithShift Action | |||
| 49 | + | }{ | |||
| 50 | + | KeycodeBracketLeft: { | |||
| 51 | + | NoShift: ActionConsumeOrExpelLeft, | |||
| 52 | + | WithShift: ActionConsumeOrExpelUp, | |||
| 53 | + | }, | |||
| 54 | + | KeycodeBracketRight: { | |||
| 55 | + | NoShift: ActionConsumeOrExpelRight, | |||
| 56 | + | WithShift: ActionConsumeOrExpelDown, | |||
| 57 | + | }, | |||
| 58 | + | } | |||
| 59 | + | ||||
| 43 | // KeycodeToDigitIndex converts a hardware keycode to a digit index (0-9). | 60 | // KeycodeToDigitIndex converts a hardware keycode to a digit index (0-9). | ||
| 44 | // Returns the index and true if the keycode is a number row key, or -1 and false otherwise. | 61 | // Returns the index and true if the keycode is a number row key, or -1 and false otherwise. | ||
| 45 | // This is useful for components like omnibox that need Ctrl+1-9/0 shortcuts. | 62 | // This is useful for components like omnibox that need Ctrl+1-9/0 shortcuts. | ||
M internal/ui/input/shortcuts.go +33 -0
| @@ -79,6 +79,15 @@ var keyvalByName = map[string]uint{ | |||
| 79 | 79 | "7": uint(gdk.KEY_7), | |
| 80 | 80 | "8": uint(gdk.KEY_8), | |
| 81 | 81 | "9": uint(gdk.KEY_9), | |
| 82 | + | ||
| 83 | + | "bracketleft": uint(gdk.KEY_bracketleft), | |
| 84 | + | "bracketright": uint(gdk.KEY_bracketright), | |
| 85 | + | "[": uint(gdk.KEY_bracketleft), | |
| 86 | + | "]": uint(gdk.KEY_bracketright), | |
| 87 | + | "braceleft": uint(gdk.KEY_braceleft), | |
| 88 | + | "braceright": uint(gdk.KEY_braceright), | |
| 89 | + | "{": uint(gdk.KEY_braceleft), | |
| 90 | + | "}": uint(gdk.KEY_braceright), | |
| 82 | 91 | } | |
| 83 | 92 | ||
| 84 | 93 | // KeyBinding represents a single key combination. | |
| @@ -125,6 +134,11 @@ const ( | |||
| 125 | 134 | ActionClosePane Action = "close_pane" | |
| 126 | 135 | ActionStackPane Action = "stack_pane" | |
| 127 | 136 | ||
| 137 | + | ActionConsumeOrExpelLeft Action = "consume_or_expel_left" | |
| 138 | + | ActionConsumeOrExpelRight Action = "consume_or_expel_right" | |
| 139 | + | ActionConsumeOrExpelUp Action = "consume_or_expel_up" | |
| 140 | + | ActionConsumeOrExpelDown Action = "consume_or_expel_down" | |
| 141 | + | ||
| 128 | 142 | // Pane focus navigation | |
| 129 | 143 | ActionFocusRight Action = "focus_right" | |
| 130 | 144 | ActionFocusLeft Action = "focus_left" | |
| @@ -316,6 +330,19 @@ func (s *ShortcutSet) registerConfiguredShortcuts(cfg *config.WorkspaceConfig) { | |||
| 316 | 330 | if binding, ok := ParseKeyString(cfg.Shortcuts.PreviousTab); ok { | |
| 317 | 331 | s.Global[binding] = ActionPreviousTab // Ctrl+Shift+Tab | |
| 318 | 332 | } | |
| 333 | + | ||
| 334 | + | if binding, ok := ParseKeyString(cfg.Shortcuts.ConsumeOrExpelLeft); ok { | |
| 335 | + | s.Global[binding] = ActionConsumeOrExpelLeft | |
| 336 | + | } | |
| 337 | + | if binding, ok := ParseKeyString(cfg.Shortcuts.ConsumeOrExpelRight); ok { | |
| 338 | + | s.Global[binding] = ActionConsumeOrExpelRight | |
| 339 | + | } | |
| 340 | + | if binding, ok := ParseKeyString(cfg.Shortcuts.ConsumeOrExpelUp); ok { | |
| 341 | + | s.Global[binding] = ActionConsumeOrExpelUp | |
| 342 | + | } | |
| 343 | + | if binding, ok := ParseKeyString(cfg.Shortcuts.ConsumeOrExpelDown); ok { | |
| 344 | + | s.Global[binding] = ActionConsumeOrExpelDown | |
| 345 | + | } | |
| 319 | 346 | } | |
| 320 | 347 | ||
| 321 | 348 | func (s *ShortcutSet) registerStandardShortcuts() { | |
| @@ -409,6 +436,11 @@ var configActionToAction = map[string]Action{ | |||
| 409 | 436 | "close-pane": ActionClosePane, | |
| 410 | 437 | "stack-pane": ActionStackPane, | |
| 411 | 438 | ||
| 439 | + | "consume-or-expel-left": ActionConsumeOrExpelLeft, | |
| 440 | + | "consume-or-expel-right": ActionConsumeOrExpelRight, | |
| 441 | + | "consume-or-expel-up": ActionConsumeOrExpelUp, | |
| 442 | + | "consume-or-expel-down": ActionConsumeOrExpelDown, | |
| 443 | + | ||
| 412 | 444 | // Focus navigation | |
| 413 | 445 | "focus-right": ActionFocusRight, | |
| 414 | 446 | "focus-left": ActionFocusLeft, | |
| @@ -556,6 +588,7 @@ func ShouldAutoExitMode(action Action) bool { | |||
| 556 | 588 | case ActionNewTab, ActionCloseTab, ActionRenameTab, | |
| 557 | 589 | ActionSplitRight, ActionSplitLeft, ActionSplitUp, ActionSplitDown, | |
| 558 | 590 | ActionClosePane, ActionStackPane, | |
| 591 | + | ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight, ActionConsumeOrExpelUp, ActionConsumeOrExpelDown, | |
| 559 | 592 | ActionOpenSessionManager: | |
| 560 | 593 | return true | |
| 561 | 594 | default: | |
| @@ -79,6 +79,15 @@ var keyvalByName = map[string]uint{ | |||||
| 79 | "7": uint(gdk.KEY_7), | 79 | "7": uint(gdk.KEY_7), | ||
| 80 | "8": uint(gdk.KEY_8), | 80 | "8": uint(gdk.KEY_8), | ||
| 81 | "9": uint(gdk.KEY_9), | 81 | "9": uint(gdk.KEY_9), | ||
| 82 | + | ||||
| 83 | + | "bracketleft": uint(gdk.KEY_bracketleft), | |||
| 84 | + | "bracketright": uint(gdk.KEY_bracketright), | |||
| 85 | + | "[": uint(gdk.KEY_bracketleft), | |||
| 86 | + | "]": uint(gdk.KEY_bracketright), | |||
| 87 | + | "braceleft": uint(gdk.KEY_braceleft), | |||
| 88 | + | "braceright": uint(gdk.KEY_braceright), | |||
| 89 | + | "{": uint(gdk.KEY_braceleft), | |||
| 90 | + | "}": uint(gdk.KEY_braceright), | |||
| 82 | } | 91 | } | ||
| 83 | 92 | ||||
| 84 | // KeyBinding represents a single key combination. | 93 | // KeyBinding represents a single key combination. | ||
| @@ -125,6 +134,11 @@ const ( | |||||
| 125 | ActionClosePane Action = "close_pane" | 134 | ActionClosePane Action = "close_pane" | ||
| 126 | ActionStackPane Action = "stack_pane" | 135 | ActionStackPane Action = "stack_pane" | ||
| 127 | 136 | ||||
| 137 | + | ActionConsumeOrExpelLeft Action = "consume_or_expel_left" | |||
| 138 | + | ActionConsumeOrExpelRight Action = "consume_or_expel_right" | |||
| 139 | + | ActionConsumeOrExpelUp Action = "consume_or_expel_up" | |||
| 140 | + | ActionConsumeOrExpelDown Action = "consume_or_expel_down" | |||
| 141 | + | ||||
| 128 | // Pane focus navigation | 142 | // Pane focus navigation | ||
| 129 | ActionFocusRight Action = "focus_right" | 143 | ActionFocusRight Action = "focus_right" | ||
| 130 | ActionFocusLeft Action = "focus_left" | 144 | ActionFocusLeft Action = "focus_left" | ||
| @@ -316,6 +330,19 @@ func (s *ShortcutSet) registerConfiguredShortcuts(cfg *config.WorkspaceConfig) { | |||||
| 316 | if binding, ok := ParseKeyString(cfg.Shortcuts.PreviousTab); ok { | 330 | if binding, ok := ParseKeyString(cfg.Shortcuts.PreviousTab); ok { | ||
| 317 | s.Global[binding] = ActionPreviousTab // Ctrl+Shift+Tab | 331 | s.Global[binding] = ActionPreviousTab // Ctrl+Shift+Tab | ||
| 318 | } | 332 | } | ||
| 333 | + | ||||
| 334 | + | if binding, ok := ParseKeyString(cfg.Shortcuts.ConsumeOrExpelLeft); ok { | |||
| 335 | + | s.Global[binding] = ActionConsumeOrExpelLeft | |||
| 336 | + | } | |||
| 337 | + | if binding, ok := ParseKeyString(cfg.Shortcuts.ConsumeOrExpelRight); ok { | |||
| 338 | + | s.Global[binding] = ActionConsumeOrExpelRight | |||
| 339 | + | } | |||
| 340 | + | if binding, ok := ParseKeyString(cfg.Shortcuts.ConsumeOrExpelUp); ok { | |||
| 341 | + | s.Global[binding] = ActionConsumeOrExpelUp | |||
| 342 | + | } | |||
| 343 | + | if binding, ok := ParseKeyString(cfg.Shortcuts.ConsumeOrExpelDown); ok { | |||
| 344 | + | s.Global[binding] = ActionConsumeOrExpelDown | |||
| 345 | + | } | |||
| 319 | } | 346 | } | ||
| 320 | 347 | ||||
| 321 | func (s *ShortcutSet) registerStandardShortcuts() { | 348 | func (s *ShortcutSet) registerStandardShortcuts() { | ||
| @@ -409,6 +436,11 @@ var configActionToAction = map[string]Action{ | |||||
| 409 | "close-pane": ActionClosePane, | 436 | "close-pane": ActionClosePane, | ||
| 410 | "stack-pane": ActionStackPane, | 437 | "stack-pane": ActionStackPane, | ||
| 411 | 438 | ||||
| 439 | + | "consume-or-expel-left": ActionConsumeOrExpelLeft, | |||
| 440 | + | "consume-or-expel-right": ActionConsumeOrExpelRight, | |||
| 441 | + | "consume-or-expel-up": ActionConsumeOrExpelUp, | |||
| 442 | + | "consume-or-expel-down": ActionConsumeOrExpelDown, | |||
| 443 | + | ||||
| 412 | // Focus navigation | 444 | // Focus navigation | ||
| 413 | "focus-right": ActionFocusRight, | 445 | "focus-right": ActionFocusRight, | ||
| 414 | "focus-left": ActionFocusLeft, | 446 | "focus-left": ActionFocusLeft, | ||
| @@ -556,6 +588,7 @@ func ShouldAutoExitMode(action Action) bool { | |||||
| 556 | case ActionNewTab, ActionCloseTab, ActionRenameTab, | 588 | case ActionNewTab, ActionCloseTab, ActionRenameTab, | ||
| 557 | ActionSplitRight, ActionSplitLeft, ActionSplitUp, ActionSplitDown, | 589 | ActionSplitRight, ActionSplitLeft, ActionSplitUp, ActionSplitDown, | ||
| 558 | ActionClosePane, ActionStackPane, | 590 | ActionClosePane, ActionStackPane, | ||
| 591 | + | ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight, ActionConsumeOrExpelUp, ActionConsumeOrExpelDown, | |||
| 559 | ActionOpenSessionManager: | 592 | ActionOpenSessionManager: | ||
| 560 | return true | 593 | return true | ||
| 561 | default: | 594 | default: | ||