fix(panes): stabilize consume/expel cycling semantics
- commit
- b7c5bff25e6ee188e1893639ca1d6dacde7fcd97
- author
- brice · · 2025-12-26 13:21 UTC
- parent
- a9a926c
- signature
- unsigned
7 files · +297 -38
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 +132 -8
| @@ -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" | |
| @@ -66,6 +67,8 @@ type ConsumeOrExpelResult struct { | |||
| 66 | 67 | ErrorMessage string | |
| 67 | 68 | } | |
| 68 | 69 | ||
| 70 | + | const consumeOrExpelExpelCycleMarker = "_expel_cycle" | |
| 71 | + | ||
| 69 | 72 | // ManagePanesUseCase handles pane tree operations. | |
| 70 | 73 | type ManagePanesUseCase struct { | |
| 71 | 74 | idGenerator IDGenerator | |
| @@ -1143,7 +1146,7 @@ func (*ManagePanesUseCase) consumeIntoSiblingStack( | |||
| 1143 | 1146 | // 1) horizontal split -> vertical split (target on top) | |
| 1144 | 1147 | // 2) vertical split -> stack (top then bottom) | |
| 1145 | 1148 | if direction == ConsumeOrExpelLeft || direction == ConsumeOrExpelRight { | |
| 1146 | - | cycled, cycleAction, err := cycleHorizontalToVerticalThenStack(ws, activeNode, sibling) | |
| 1149 | + | cycled, cycleAction, err := cycleHorizontalToVerticalThenStack(ws, activeNode, sibling, direction) | |
| 1147 | 1150 | if err != nil { | |
| 1148 | 1151 | return nil, err | |
| 1149 | 1152 | } | |
| @@ -1218,8 +1221,33 @@ func (uc *ManagePanesUseCase) expelFromStack( | |||
| 1218 | 1221 | } | |
| 1219 | 1222 | ||
| 1220 | 1223 | // Split around the remaining container (stackNode) with expelled pane. | |
| 1221 | - | if err := splitExistingNode(ws, stackNode, expelled, direction, uc.idGenerator); err != nil { | |
| 1222 | - | return nil, err | |
| 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 | + | } | |
| 1223 | 1251 | } | |
| 1224 | 1252 | ws.ActivePaneID = expelled.Pane.ID | |
| 1225 | 1253 | ||
| @@ -1300,20 +1328,29 @@ func fallbackSiblingForConsumeOrExpel(node *entity.PaneNode, direction ConsumeOr | |||
| 1300 | 1328 | } | |
| 1301 | 1329 | } | |
| 1302 | 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 | |
| 1303 | 1335 | if parent.Left() == node { | |
| 1304 | - | return parent.Right() | |
| 1336 | + | sibling = parent.Right() | |
| 1337 | + | } else if parent.Right() == node { | |
| 1338 | + | sibling = parent.Left() | |
| 1305 | 1339 | } | |
| 1306 | - | if parent.Right() == node { | |
| 1307 | - | return parent.Left() | |
| 1340 | + | if sibling == nil { | |
| 1341 | + | return nil | |
| 1308 | 1342 | } | |
| 1309 | - | ||
| 1310 | - | return nil | |
| 1343 | + | if !node.IsLeaf() || !sibling.IsLeaf() { | |
| 1344 | + | return nil | |
| 1345 | + | } | |
| 1346 | + | return sibling | |
| 1311 | 1347 | } | |
| 1312 | 1348 | ||
| 1313 | 1349 | func cycleHorizontalToVerticalThenStack( | |
| 1314 | 1350 | ws *entity.Workspace, | |
| 1315 | 1351 | activeNode *entity.PaneNode, | |
| 1316 | 1352 | sibling *entity.PaneNode, | |
| 1353 | + | direction ConsumeOrExpelDirection, | |
| 1317 | 1354 | ) (cycled bool, cycleAction string, err error) { | |
| 1318 | 1355 | if ws == nil { | |
| 1319 | 1356 | return false, "", fmt.Errorf("workspace is required") | |
| @@ -1332,6 +1369,7 @@ func cycleHorizontalToVerticalThenStack( | |||
| 1332 | 1369 | ||
| 1333 | 1370 | switch parent.SplitDir { | |
| 1334 | 1371 | case entity.SplitHorizontal: | |
| 1372 | + | // Horizontal -> Vertical: target sibling should be on top. | |
| 1335 | 1373 | parent.SplitDir = entity.SplitVertical | |
| 1336 | 1374 | if parent.Children[0] != sibling { | |
| 1337 | 1375 | parent.Children[0], parent.Children[1] = parent.Children[1], parent.Children[0] | |
| @@ -1339,6 +1377,26 @@ func cycleHorizontalToVerticalThenStack( | |||
| 1339 | 1377 | ws.ActivePaneID = activeNode.Pane.ID | |
| 1340 | 1378 | return true, "vertical_split", nil | |
| 1341 | 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 | + | if direction == ConsumeOrExpelRight { | |
| 1385 | + | // Active should become the right child. | |
| 1386 | + | if parent.Children[1] != activeNode { | |
| 1387 | + | parent.Children[0], parent.Children[1] = parent.Children[1], parent.Children[0] | |
| 1388 | + | } | |
| 1389 | + | } else { | |
| 1390 | + | // Active should become the left child. | |
| 1391 | + | if parent.Children[0] != activeNode { | |
| 1392 | + | parent.Children[0], parent.Children[1] = parent.Children[1], parent.Children[0] | |
| 1393 | + | } | |
| 1394 | + | } | |
| 1395 | + | ws.ActivePaneID = activeNode.Pane.ID | |
| 1396 | + | return true, "horizontal_split", nil | |
| 1397 | + | } | |
| 1398 | + | ||
| 1399 | + | // Otherwise, vertical -> stack. | |
| 1342 | 1400 | parent.SplitDir = entity.SplitNone | |
| 1343 | 1401 | parent.SplitRatio = 0 | |
| 1344 | 1402 | parent.IsStacked = true | |
| @@ -1536,6 +1594,72 @@ func splitExistingNode( | |||
| 1536 | 1594 | return nil | |
| 1537 | 1595 | } | |
| 1538 | 1596 | ||
| 1597 | + | func splitExistingNodeWithMarker( | |
| 1598 | + | ws *entity.Workspace, | |
| 1599 | + | targetNode *entity.PaneNode, | |
| 1600 | + | newSibling *entity.PaneNode, | |
| 1601 | + | direction ConsumeOrExpelDirection, | |
| 1602 | + | idGen IDGenerator, | |
| 1603 | + | marker string, | |
| 1604 | + | ) error { | |
| 1605 | + | if ws == nil { | |
| 1606 | + | return fmt.Errorf("workspace is required") | |
| 1607 | + | } | |
| 1608 | + | if targetNode == nil { | |
| 1609 | + | return fmt.Errorf("target node is required") | |
| 1610 | + | } | |
| 1611 | + | if newSibling == nil { | |
| 1612 | + | return fmt.Errorf("new sibling node is required") | |
| 1613 | + | } | |
| 1614 | + | if idGen == nil { | |
| 1615 | + | return fmt.Errorf("id generator is required") | |
| 1616 | + | } | |
| 1617 | + | ||
| 1618 | + | var splitDir entity.SplitDirection | |
| 1619 | + | switch direction { | |
| 1620 | + | case ConsumeOrExpelLeft, ConsumeOrExpelRight: | |
| 1621 | + | splitDir = entity.SplitHorizontal | |
| 1622 | + | case ConsumeOrExpelUp, ConsumeOrExpelDown: | |
| 1623 | + | splitDir = entity.SplitVertical | |
| 1624 | + | default: | |
| 1625 | + | return fmt.Errorf("invalid direction: %s", direction) | |
| 1626 | + | } | |
| 1627 | + | ||
| 1628 | + | parentNode := &entity.PaneNode{ | |
| 1629 | + | ID: idGen() + marker, | |
| 1630 | + | SplitDir: splitDir, | |
| 1631 | + | SplitRatio: 0.5, | |
| 1632 | + | Children: make([]*entity.PaneNode, 2), | |
| 1633 | + | } | |
| 1634 | + | ||
| 1635 | + | switch direction { | |
| 1636 | + | case ConsumeOrExpelLeft, ConsumeOrExpelUp: | |
| 1637 | + | parentNode.Children[0] = newSibling | |
| 1638 | + | parentNode.Children[1] = targetNode | |
| 1639 | + | case ConsumeOrExpelRight, ConsumeOrExpelDown: | |
| 1640 | + | parentNode.Children[0] = targetNode | |
| 1641 | + | parentNode.Children[1] = newSibling | |
| 1642 | + | } | |
| 1643 | + | ||
| 1644 | + | newSibling.Parent = parentNode | |
| 1645 | + | oldParent := targetNode.Parent | |
| 1646 | + | targetNode.Parent = parentNode | |
| 1647 | + | ||
| 1648 | + | if oldParent == nil { | |
| 1649 | + | ws.Root = parentNode | |
| 1650 | + | } else { | |
| 1651 | + | for i, child := range oldParent.Children { | |
| 1652 | + | if child == targetNode { | |
| 1653 | + | oldParent.Children[i] = parentNode | |
| 1654 | + | break | |
| 1655 | + | } | |
| 1656 | + | } | |
| 1657 | + | parentNode.Parent = oldParent | |
| 1658 | + | } | |
| 1659 | + | ||
| 1660 | + | return nil | |
| 1661 | + | } | |
| 1662 | + | ||
| 1539 | 1663 | // GetAllPanes returns all leaf panes in a workspace. | |
| 1540 | 1664 | // | |
| 1541 | 1665 | //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" | ||
| @@ -66,6 +67,8 @@ type ConsumeOrExpelResult struct { | |||||
| 66 | ErrorMessage string | 67 | ErrorMessage string | ||
| 67 | } | 68 | } | ||
| 68 | 69 | ||||
| 70 | + | const consumeOrExpelExpelCycleMarker = "_expel_cycle" | |||
| 71 | + | ||||
| 69 | // ManagePanesUseCase handles pane tree operations. | 72 | // ManagePanesUseCase handles pane tree operations. | ||
| 70 | type ManagePanesUseCase struct { | 73 | type ManagePanesUseCase struct { | ||
| 71 | idGenerator IDGenerator | 74 | idGenerator IDGenerator | ||
| @@ -1143,7 +1146,7 @@ func (*ManagePanesUseCase) consumeIntoSiblingStack( | |||||
| 1143 | // 1) horizontal split -> vertical split (target on top) | 1146 | // 1) horizontal split -> vertical split (target on top) | ||
| 1144 | // 2) vertical split -> stack (top then bottom) | 1147 | // 2) vertical split -> stack (top then bottom) | ||
| 1145 | if direction == ConsumeOrExpelLeft || direction == ConsumeOrExpelRight { | 1148 | if direction == ConsumeOrExpelLeft || direction == ConsumeOrExpelRight { | ||
| 1146 | - | cycled, cycleAction, err := cycleHorizontalToVerticalThenStack(ws, activeNode, sibling) | 1149 | + | cycled, cycleAction, err := cycleHorizontalToVerticalThenStack(ws, activeNode, sibling, direction) |
| 1147 | if err != nil { | 1150 | if err != nil { | ||
| 1148 | return nil, err | 1151 | return nil, err | ||
| 1149 | } | 1152 | } | ||
| @@ -1218,8 +1221,33 @@ func (uc *ManagePanesUseCase) expelFromStack( | |||||
| 1218 | } | 1221 | } | ||
| 1219 | 1222 | ||||
| 1220 | // Split around the remaining container (stackNode) with expelled pane. | 1223 | // Split around the remaining container (stackNode) with expelled pane. | ||
| 1221 | - | if err := splitExistingNode(ws, stackNode, expelled, direction, uc.idGenerator); err != nil { | 1224 | + | // For left/right, expel cycles stack -> vertical split first: |
| 1222 | - | return nil, err | 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 | + | } | |||
| 1223 | } | 1251 | } | ||
| 1224 | ws.ActivePaneID = expelled.Pane.ID | 1252 | ws.ActivePaneID = expelled.Pane.ID | ||
| 1225 | 1253 | ||||
| @@ -1300,20 +1328,29 @@ func fallbackSiblingForConsumeOrExpel(node *entity.PaneNode, direction ConsumeOr | |||||
| 1300 | } | 1328 | } | ||
| 1301 | } | 1329 | } | ||
| 1302 | 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 | |||
| 1303 | if parent.Left() == node { | 1335 | if parent.Left() == node { | ||
| 1304 | - | return parent.Right() | 1336 | + | sibling = parent.Right() |
| 1337 | + | } else if parent.Right() == node { | |||
| 1338 | + | sibling = parent.Left() | |||
| 1305 | } | 1339 | } | ||
| 1306 | - | if parent.Right() == node { | 1340 | + | if sibling == nil { |
| 1307 | - | return parent.Left() | 1341 | + | return nil |
| 1308 | } | 1342 | } | ||
| 1309 | - | 1343 | + | if !node.IsLeaf() || !sibling.IsLeaf() { | |
| 1310 | - | return nil | 1344 | + | return nil |
| 1345 | + | } | |||
| 1346 | + | return sibling | |||
| 1311 | } | 1347 | } | ||
| 1312 | 1348 | ||||
| 1313 | func cycleHorizontalToVerticalThenStack( | 1349 | func cycleHorizontalToVerticalThenStack( | ||
| 1314 | ws *entity.Workspace, | 1350 | ws *entity.Workspace, | ||
| 1315 | activeNode *entity.PaneNode, | 1351 | activeNode *entity.PaneNode, | ||
| 1316 | sibling *entity.PaneNode, | 1352 | sibling *entity.PaneNode, | ||
| 1353 | + | direction ConsumeOrExpelDirection, | |||
| 1317 | ) (cycled bool, cycleAction string, err error) { | 1354 | ) (cycled bool, cycleAction string, err error) { | ||
| 1318 | if ws == nil { | 1355 | if ws == nil { | ||
| 1319 | return false, "", fmt.Errorf("workspace is required") | 1356 | return false, "", fmt.Errorf("workspace is required") | ||
| @@ -1332,6 +1369,7 @@ func cycleHorizontalToVerticalThenStack( | |||||
| 1332 | 1369 | ||||
| 1333 | switch parent.SplitDir { | 1370 | switch parent.SplitDir { | ||
| 1334 | case entity.SplitHorizontal: | 1371 | case entity.SplitHorizontal: | ||
| 1372 | + | // Horizontal -> Vertical: target sibling should be on top. | |||
| 1335 | parent.SplitDir = entity.SplitVertical | 1373 | parent.SplitDir = entity.SplitVertical | ||
| 1336 | if parent.Children[0] != sibling { | 1374 | if parent.Children[0] != sibling { | ||
| 1337 | parent.Children[0], parent.Children[1] = parent.Children[1], parent.Children[0] | 1375 | parent.Children[0], parent.Children[1] = parent.Children[1], parent.Children[0] | ||
| @@ -1339,6 +1377,26 @@ func cycleHorizontalToVerticalThenStack( | |||||
| 1339 | ws.ActivePaneID = activeNode.Pane.ID | 1377 | ws.ActivePaneID = activeNode.Pane.ID | ||
| 1340 | return true, "vertical_split", nil | 1378 | return true, "vertical_split", nil | ||
| 1341 | case entity.SplitVertical: | 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 | + | if direction == ConsumeOrExpelRight { | |||
| 1385 | + | // Active should become the right child. | |||
| 1386 | + | if parent.Children[1] != activeNode { | |||
| 1387 | + | parent.Children[0], parent.Children[1] = parent.Children[1], parent.Children[0] | |||
| 1388 | + | } | |||
| 1389 | + | } else { | |||
| 1390 | + | // Active should become the left child. | |||
| 1391 | + | if parent.Children[0] != activeNode { | |||
| 1392 | + | parent.Children[0], parent.Children[1] = parent.Children[1], parent.Children[0] | |||
| 1393 | + | } | |||
| 1394 | + | } | |||
| 1395 | + | ws.ActivePaneID = activeNode.Pane.ID | |||
| 1396 | + | return true, "horizontal_split", nil | |||
| 1397 | + | } | |||
| 1398 | + | ||||
| 1399 | + | // Otherwise, vertical -> stack. | |||
| 1342 | parent.SplitDir = entity.SplitNone | 1400 | parent.SplitDir = entity.SplitNone | ||
| 1343 | parent.SplitRatio = 0 | 1401 | parent.SplitRatio = 0 | ||
| 1344 | parent.IsStacked = true | 1402 | parent.IsStacked = true | ||
| @@ -1536,6 +1594,72 @@ func splitExistingNode( | |||||
| 1536 | return nil | 1594 | return nil | ||
| 1537 | } | 1595 | } | ||
| 1538 | 1596 | ||||
| 1597 | + | func splitExistingNodeWithMarker( | |||
| 1598 | + | ws *entity.Workspace, | |||
| 1599 | + | targetNode *entity.PaneNode, | |||
| 1600 | + | newSibling *entity.PaneNode, | |||
| 1601 | + | direction ConsumeOrExpelDirection, | |||
| 1602 | + | idGen IDGenerator, | |||
| 1603 | + | marker string, | |||
| 1604 | + | ) error { | |||
| 1605 | + | if ws == nil { | |||
| 1606 | + | return fmt.Errorf("workspace is required") | |||
| 1607 | + | } | |||
| 1608 | + | if targetNode == nil { | |||
| 1609 | + | return fmt.Errorf("target node is required") | |||
| 1610 | + | } | |||
| 1611 | + | if newSibling == nil { | |||
| 1612 | + | return fmt.Errorf("new sibling node is required") | |||
| 1613 | + | } | |||
| 1614 | + | if idGen == nil { | |||
| 1615 | + | return fmt.Errorf("id generator is required") | |||
| 1616 | + | } | |||
| 1617 | + | ||||
| 1618 | + | var splitDir entity.SplitDirection | |||
| 1619 | + | switch direction { | |||
| 1620 | + | case ConsumeOrExpelLeft, ConsumeOrExpelRight: | |||
| 1621 | + | splitDir = entity.SplitHorizontal | |||
| 1622 | + | case ConsumeOrExpelUp, ConsumeOrExpelDown: | |||
| 1623 | + | splitDir = entity.SplitVertical | |||
| 1624 | + | default: | |||
| 1625 | + | return fmt.Errorf("invalid direction: %s", direction) | |||
| 1626 | + | } | |||
| 1627 | + | ||||
| 1628 | + | parentNode := &entity.PaneNode{ | |||
| 1629 | + | ID: idGen() + marker, | |||
| 1630 | + | SplitDir: splitDir, | |||
| 1631 | + | SplitRatio: 0.5, | |||
| 1632 | + | Children: make([]*entity.PaneNode, 2), | |||
| 1633 | + | } | |||
| 1634 | + | ||||
| 1635 | + | switch direction { | |||
| 1636 | + | case ConsumeOrExpelLeft, ConsumeOrExpelUp: | |||
| 1637 | + | parentNode.Children[0] = newSibling | |||
| 1638 | + | parentNode.Children[1] = targetNode | |||
| 1639 | + | case ConsumeOrExpelRight, ConsumeOrExpelDown: | |||
| 1640 | + | parentNode.Children[0] = targetNode | |||
| 1641 | + | parentNode.Children[1] = newSibling | |||
| 1642 | + | } | |||
| 1643 | + | ||||
| 1644 | + | newSibling.Parent = parentNode | |||
| 1645 | + | oldParent := targetNode.Parent | |||
| 1646 | + | targetNode.Parent = parentNode | |||
| 1647 | + | ||||
| 1648 | + | if oldParent == nil { | |||
| 1649 | + | ws.Root = parentNode | |||
| 1650 | + | } else { | |||
| 1651 | + | for i, child := range oldParent.Children { | |||
| 1652 | + | if child == targetNode { | |||
| 1653 | + | oldParent.Children[i] = parentNode | |||
| 1654 | + | break | |||
| 1655 | + | } | |||
| 1656 | + | } | |||
| 1657 | + | parentNode.Parent = oldParent | |||
| 1658 | + | } | |||
| 1659 | + | ||||
| 1660 | + | return nil | |||
| 1661 | + | } | |||
| 1662 | + | ||||
| 1539 | // GetAllPanes returns all leaf panes in a workspace. | 1663 | // GetAllPanes returns all leaf panes in a workspace. | ||
| 1540 | // | 1664 | // | ||
| 1541 | //nolint:revive // receiver required for interface consistency | 1665 | //nolint:revive // receiver required for interface consistency | ||
M internal/application/usecase/manage_panes_consume_expel_test.go +141 -24
| @@ -117,7 +117,7 @@ func TestManagePanesUseCase_ConsumeOrExpel_ConsumeIntoExistingStack_AppendsToEnd | |||
| 117 | 117 | uc := NewManagePanesUseCase(func() string { return "id" }) | |
| 118 | 118 | ctx := context.Background() | |
| 119 | 119 | ||
| 120 | - | stackNode := stack("stack", leaf("a"), leaf("b")) | |
| 120 | + | stackNode := stack(leaf("a"), leaf("b")) | |
| 121 | 121 | c := leaf("c") | |
| 122 | 122 | root := split(entity.SplitHorizontal, stackNode, c) | |
| 123 | 123 | ws := &entity.Workspace{Root: root, ActivePaneID: c.Pane.ID} | |
| @@ -140,11 +140,35 @@ func TestManagePanesUseCase_ConsumeOrExpel_ConsumeIntoExistingStack_AppendsToEnd | |||
| 140 | 140 | } | |
| 141 | 141 | } | |
| 142 | 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 | + | ||
| 143 | 167 | func TestManagePanesUseCase_ConsumeOrExpel_Expel_DissolvesTwoPaneStack(t *testing.T) { | |
| 144 | 168 | uc := NewManagePanesUseCase(func() string { return "id" }) | |
| 145 | 169 | ctx := context.Background() | |
| 146 | 170 | ||
| 147 | - | stackNode := stack("stack", leaf("a"), leaf("b")) | |
| 171 | + | stackNode := stack(leaf("a"), leaf("b")) | |
| 148 | 172 | b := stackNode.Children[1] | |
| 149 | 173 | ws := &entity.Workspace{Root: stackNode, ActivePaneID: b.Pane.ID} | |
| 150 | 174 | ||
| @@ -155,31 +179,90 @@ func TestManagePanesUseCase_ConsumeOrExpel_Expel_DissolvesTwoPaneStack(t *testin | |||
| 155 | 179 | if res == nil || res.Action != "expelled" { | |
| 156 | 180 | t.Fatalf("result action=%v, want expelled", res) | |
| 157 | 181 | } | |
| 158 | - | if ws.Root == nil || !ws.Root.IsSplit() || ws.Root.SplitDir != entity.SplitHorizontal { | |
| 159 | - | t.Fatalf("root should be a horizontal split") | |
| 182 | + | if ws.Root == nil || !ws.Root.IsSplit() || ws.Root.SplitDir != entity.SplitVertical { | |
| 183 | + | t.Fatalf("root should be a vertical split") | |
| 160 | 184 | } | |
| 161 | 185 | ||
| 162 | - | left := ws.Root.Left() | |
| 163 | - | right := ws.Root.Right() | |
| 164 | - | if left == nil || right == nil { | |
| 186 | + | top := ws.Root.Left() | |
| 187 | + | bottom := ws.Root.Right() | |
| 188 | + | if top == nil || bottom == nil { | |
| 165 | 189 | t.Fatalf("split children should exist") | |
| 166 | 190 | } | |
| 167 | - | if !left.IsLeaf() || left.Pane == nil || left.Pane.ID != "a" { | |
| 168 | - | t.Fatalf("left child should be leaf a") | |
| 191 | + | if !top.IsLeaf() || top.Pane == nil || top.Pane.ID != "a" { | |
| 192 | + | t.Fatalf("top child should be leaf a") | |
| 169 | 193 | } | |
| 170 | - | if !right.IsLeaf() || right.Pane == nil || right.Pane.ID != "b" { | |
| 171 | - | t.Fatalf("right child should be leaf b") | |
| 194 | + | if !bottom.IsLeaf() || bottom.Pane == nil || bottom.Pane.ID != "b" { | |
| 195 | + | t.Fatalf("bottom child should be leaf b") | |
| 172 | 196 | } | |
| 173 | 197 | if ws.ActivePaneID != "b" { | |
| 174 | 198 | t.Fatalf("active=%s, want b", ws.ActivePaneID) | |
| 175 | 199 | } | |
| 176 | 200 | } | |
| 177 | 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 | + | ||
| 178 | 261 | func TestManagePanesUseCase_ConsumeOrExpel_Expel_StackRemainsWhenMoreThanTwo(t *testing.T) { | |
| 179 | 262 | uc := NewManagePanesUseCase(func() string { return "id" }) | |
| 180 | 263 | ctx := context.Background() | |
| 181 | 264 | ||
| 182 | - | stackNode := stack("stack", leaf("a"), leaf("b"), leaf("c")) | |
| 265 | + | stackNode := stack(leaf("a"), leaf("b"), leaf("c")) | |
| 183 | 266 | b := stackNode.Children[1] | |
| 184 | 267 | ws := &entity.Workspace{Root: stackNode, ActivePaneID: b.Pane.ID} | |
| 185 | 268 | ||
| @@ -190,19 +273,19 @@ func TestManagePanesUseCase_ConsumeOrExpel_Expel_StackRemainsWhenMoreThanTwo(t * | |||
| 190 | 273 | if res == nil || res.Action != "expelled" { | |
| 191 | 274 | t.Fatalf("result action=%v, want expelled", res) | |
| 192 | 275 | } | |
| 193 | - | if ws.Root == nil || !ws.Root.IsSplit() { | |
| 194 | - | t.Fatalf("root should be a split") | |
| 276 | + | if ws.Root == nil || !ws.Root.IsSplit() || ws.Root.SplitDir != entity.SplitVertical { | |
| 277 | + | t.Fatalf("root should be a vertical split") | |
| 195 | 278 | } | |
| 196 | - | left := ws.Root.Left() | |
| 197 | - | right := ws.Root.Right() | |
| 198 | - | if left == nil || !left.IsStacked { | |
| 199 | - | t.Fatalf("left child should be stack") | |
| 279 | + | top := ws.Root.Left() | |
| 280 | + | bottom := ws.Root.Right() | |
| 281 | + | if top == nil || !top.IsStacked { | |
| 282 | + | t.Fatalf("top child should be stack") | |
| 200 | 283 | } | |
| 201 | - | if panesInOrder(left) != "a,c" { | |
| 202 | - | t.Fatalf("remaining stack panes=%s, want a,c", panesInOrder(left)) | |
| 284 | + | if panesInOrder(top) != "a,c" { | |
| 285 | + | t.Fatalf("remaining stack panes=%s, want a,c", panesInOrder(top)) | |
| 203 | 286 | } | |
| 204 | - | if right == nil || !right.IsLeaf() || right.Pane.ID != "b" { | |
| 205 | - | t.Fatalf("right child should be expelled leaf b") | |
| 287 | + | if bottom == nil || !bottom.IsLeaf() || bottom.Pane.ID != "b" { | |
| 288 | + | t.Fatalf("bottom child should be expelled leaf b") | |
| 206 | 289 | } | |
| 207 | 290 | if ws.ActivePaneID != "b" { | |
| 208 | 291 | t.Fatalf("active=%s, want b", ws.ActivePaneID) | |
| @@ -281,6 +364,40 @@ func TestManagePanesUseCase_ConsumeOrExpel_OnlyOnePane(t *testing.T) { | |||
| 281 | 364 | } | |
| 282 | 365 | } | |
| 283 | 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 | + | ||
| 284 | 401 | func leaf(id string) *entity.PaneNode { | |
| 285 | 402 | return &entity.PaneNode{ID: id, Pane: &entity.Pane{ID: entity.PaneID(id)}} | |
| 286 | 403 | } | |
| @@ -292,8 +409,8 @@ func split(dir entity.SplitDirection, left, right *entity.PaneNode) *entity.Pane | |||
| 292 | 409 | return root | |
| 293 | 410 | } | |
| 294 | 411 | ||
| 295 | - | func stack(id string, panes ...*entity.PaneNode) *entity.PaneNode { | |
| 296 | - | n := &entity.PaneNode{ID: id, IsStacked: true, ActiveStackIndex: 0} | |
| 412 | + | func stack(panes ...*entity.PaneNode) *entity.PaneNode { | |
| 413 | + | n := &entity.PaneNode{ID: "stack", IsStacked: true, ActiveStackIndex: 0} | |
| 297 | 414 | children := make([]*entity.PaneNode, 0, len(panes)) | |
| 298 | 415 | for _, p := range panes { | |
| 299 | 416 | p.Parent = n | |
| @@ -117,7 +117,7 @@ func TestManagePanesUseCase_ConsumeOrExpel_ConsumeIntoExistingStack_AppendsToEnd | |||||
| 117 | uc := NewManagePanesUseCase(func() string { return "id" }) | 117 | uc := NewManagePanesUseCase(func() string { return "id" }) | ||
| 118 | ctx := context.Background() | 118 | ctx := context.Background() | ||
| 119 | 119 | ||||
| 120 | - | stackNode := stack("stack", leaf("a"), leaf("b")) | 120 | + | stackNode := stack(leaf("a"), leaf("b")) |
| 121 | c := leaf("c") | 121 | c := leaf("c") | ||
| 122 | root := split(entity.SplitHorizontal, stackNode, c) | 122 | root := split(entity.SplitHorizontal, stackNode, c) | ||
| 123 | ws := &entity.Workspace{Root: root, ActivePaneID: c.Pane.ID} | 123 | ws := &entity.Workspace{Root: root, ActivePaneID: c.Pane.ID} | ||
| @@ -140,11 +140,35 @@ func TestManagePanesUseCase_ConsumeOrExpel_ConsumeIntoExistingStack_AppendsToEnd | |||||
| 140 | } | 140 | } | ||
| 141 | } | 141 | } | ||
| 142 | 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 | + | ||||
| 143 | func TestManagePanesUseCase_ConsumeOrExpel_Expel_DissolvesTwoPaneStack(t *testing.T) { | 167 | func TestManagePanesUseCase_ConsumeOrExpel_Expel_DissolvesTwoPaneStack(t *testing.T) { | ||
| 144 | uc := NewManagePanesUseCase(func() string { return "id" }) | 168 | uc := NewManagePanesUseCase(func() string { return "id" }) | ||
| 145 | ctx := context.Background() | 169 | ctx := context.Background() | ||
| 146 | 170 | ||||
| 147 | - | stackNode := stack("stack", leaf("a"), leaf("b")) | 171 | + | stackNode := stack(leaf("a"), leaf("b")) |
| 148 | b := stackNode.Children[1] | 172 | b := stackNode.Children[1] | ||
| 149 | ws := &entity.Workspace{Root: stackNode, ActivePaneID: b.Pane.ID} | 173 | ws := &entity.Workspace{Root: stackNode, ActivePaneID: b.Pane.ID} | ||
| 150 | 174 | ||||
| @@ -155,31 +179,90 @@ func TestManagePanesUseCase_ConsumeOrExpel_Expel_DissolvesTwoPaneStack(t *testin | |||||
| 155 | if res == nil || res.Action != "expelled" { | 179 | if res == nil || res.Action != "expelled" { | ||
| 156 | t.Fatalf("result action=%v, want expelled", res) | 180 | t.Fatalf("result action=%v, want expelled", res) | ||
| 157 | } | 181 | } | ||
| 158 | - | if ws.Root == nil || !ws.Root.IsSplit() || ws.Root.SplitDir != entity.SplitHorizontal { | 182 | + | if ws.Root == nil || !ws.Root.IsSplit() || ws.Root.SplitDir != entity.SplitVertical { |
| 159 | - | t.Fatalf("root should be a horizontal split") | 183 | + | t.Fatalf("root should be a vertical split") |
| 160 | } | 184 | } | ||
| 161 | 185 | ||||
| 162 | - | left := ws.Root.Left() | 186 | + | top := ws.Root.Left() |
| 163 | - | right := ws.Root.Right() | 187 | + | bottom := ws.Root.Right() |
| 164 | - | if left == nil || right == nil { | 188 | + | if top == nil || bottom == nil { |
| 165 | t.Fatalf("split children should exist") | 189 | t.Fatalf("split children should exist") | ||
| 166 | } | 190 | } | ||
| 167 | - | if !left.IsLeaf() || left.Pane == nil || left.Pane.ID != "a" { | 191 | + | if !top.IsLeaf() || top.Pane == nil || top.Pane.ID != "a" { |
| 168 | - | t.Fatalf("left child should be leaf a") | 192 | + | t.Fatalf("top child should be leaf a") |
| 169 | } | 193 | } | ||
| 170 | - | if !right.IsLeaf() || right.Pane == nil || right.Pane.ID != "b" { | 194 | + | if !bottom.IsLeaf() || bottom.Pane == nil || bottom.Pane.ID != "b" { |
| 171 | - | t.Fatalf("right child should be leaf b") | 195 | + | t.Fatalf("bottom child should be leaf b") |
| 172 | } | 196 | } | ||
| 173 | if ws.ActivePaneID != "b" { | 197 | if ws.ActivePaneID != "b" { | ||
| 174 | t.Fatalf("active=%s, want b", ws.ActivePaneID) | 198 | t.Fatalf("active=%s, want b", ws.ActivePaneID) | ||
| 175 | } | 199 | } | ||
| 176 | } | 200 | } | ||
| 177 | 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 | + | ||||
| 178 | func TestManagePanesUseCase_ConsumeOrExpel_Expel_StackRemainsWhenMoreThanTwo(t *testing.T) { | 261 | func TestManagePanesUseCase_ConsumeOrExpel_Expel_StackRemainsWhenMoreThanTwo(t *testing.T) { | ||
| 179 | uc := NewManagePanesUseCase(func() string { return "id" }) | 262 | uc := NewManagePanesUseCase(func() string { return "id" }) | ||
| 180 | ctx := context.Background() | 263 | ctx := context.Background() | ||
| 181 | 264 | ||||
| 182 | - | stackNode := stack("stack", leaf("a"), leaf("b"), leaf("c")) | 265 | + | stackNode := stack(leaf("a"), leaf("b"), leaf("c")) |
| 183 | b := stackNode.Children[1] | 266 | b := stackNode.Children[1] | ||
| 184 | ws := &entity.Workspace{Root: stackNode, ActivePaneID: b.Pane.ID} | 267 | ws := &entity.Workspace{Root: stackNode, ActivePaneID: b.Pane.ID} | ||
| 185 | 268 | ||||
| @@ -190,19 +273,19 @@ func TestManagePanesUseCase_ConsumeOrExpel_Expel_StackRemainsWhenMoreThanTwo(t * | |||||
| 190 | if res == nil || res.Action != "expelled" { | 273 | if res == nil || res.Action != "expelled" { | ||
| 191 | t.Fatalf("result action=%v, want expelled", res) | 274 | t.Fatalf("result action=%v, want expelled", res) | ||
| 192 | } | 275 | } | ||
| 193 | - | if ws.Root == nil || !ws.Root.IsSplit() { | 276 | + | if ws.Root == nil || !ws.Root.IsSplit() || ws.Root.SplitDir != entity.SplitVertical { |
| 194 | - | t.Fatalf("root should be a split") | 277 | + | t.Fatalf("root should be a vertical split") |
| 195 | } | 278 | } | ||
| 196 | - | left := ws.Root.Left() | 279 | + | top := ws.Root.Left() |
| 197 | - | right := ws.Root.Right() | 280 | + | bottom := ws.Root.Right() |
| 198 | - | if left == nil || !left.IsStacked { | 281 | + | if top == nil || !top.IsStacked { |
| 199 | - | t.Fatalf("left child should be stack") | 282 | + | t.Fatalf("top child should be stack") |
| 200 | } | 283 | } | ||
| 201 | - | if panesInOrder(left) != "a,c" { | 284 | + | if panesInOrder(top) != "a,c" { |
| 202 | - | t.Fatalf("remaining stack panes=%s, want a,c", panesInOrder(left)) | 285 | + | t.Fatalf("remaining stack panes=%s, want a,c", panesInOrder(top)) |
| 203 | } | 286 | } | ||
| 204 | - | if right == nil || !right.IsLeaf() || right.Pane.ID != "b" { | 287 | + | if bottom == nil || !bottom.IsLeaf() || bottom.Pane.ID != "b" { |
| 205 | - | t.Fatalf("right child should be expelled leaf b") | 288 | + | t.Fatalf("bottom child should be expelled leaf b") |
| 206 | } | 289 | } | ||
| 207 | if ws.ActivePaneID != "b" { | 290 | if ws.ActivePaneID != "b" { | ||
| 208 | t.Fatalf("active=%s, want b", ws.ActivePaneID) | 291 | t.Fatalf("active=%s, want b", ws.ActivePaneID) | ||
| @@ -281,6 +364,40 @@ func TestManagePanesUseCase_ConsumeOrExpel_OnlyOnePane(t *testing.T) { | |||||
| 281 | } | 364 | } | ||
| 282 | } | 365 | } | ||
| 283 | 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 | + | ||||
| 284 | func leaf(id string) *entity.PaneNode { | 401 | func leaf(id string) *entity.PaneNode { | ||
| 285 | return &entity.PaneNode{ID: id, Pane: &entity.Pane{ID: entity.PaneID(id)}} | 402 | return &entity.PaneNode{ID: id, Pane: &entity.Pane{ID: entity.PaneID(id)}} | ||
| 286 | } | 403 | } | ||
| @@ -292,8 +409,8 @@ func split(dir entity.SplitDirection, left, right *entity.PaneNode) *entity.Pane | |||||
| 292 | return root | 409 | return root | ||
| 293 | } | 410 | } | ||
| 294 | 411 | ||||
| 295 | - | func stack(id string, panes ...*entity.PaneNode) *entity.PaneNode { | 412 | + | func stack(panes ...*entity.PaneNode) *entity.PaneNode { |
| 296 | - | n := &entity.PaneNode{ID: id, IsStacked: true, ActiveStackIndex: 0} | 413 | + | n := &entity.PaneNode{ID: "stack", IsStacked: true, ActiveStackIndex: 0} |
| 297 | children := make([]*entity.PaneNode, 0, len(panes)) | 414 | children := make([]*entity.PaneNode, 0, len(panes)) | ||
| 298 | for _, p := range panes { | 415 | for _, p := range panes { | ||
| 299 | p.Parent = n | 416 | p.Parent = n | ||
M internal/infrastructure/config/defaults.go +4 -4
| @@ -218,10 +218,10 @@ func DefaultConfig() *Config { | |||
| 218 | 218 | NextTab: "ctrl+tab", | |
| 219 | 219 | PreviousTab: "ctrl+shift+tab", | |
| 220 | 220 | ||
| 221 | - | ConsumeOrExpelLeft: "ctrl+bracketleft", | |
| 222 | - | ConsumeOrExpelRight: "ctrl+bracketright", | |
| 223 | - | ConsumeOrExpelUp: "ctrl+shift+bracketleft", | |
| 224 | - | ConsumeOrExpelDown: "ctrl+shift+bracketright", | |
| 221 | + | ConsumeOrExpelLeft: "alt+bracketleft", | |
| 222 | + | ConsumeOrExpelRight: "alt+bracketright", | |
| 223 | + | ConsumeOrExpelUp: "alt+shift+bracketleft", | |
| 224 | + | ConsumeOrExpelDown: "alt+shift+bracketright", | |
| 225 | 225 | }, | |
| 226 | 226 | TabBarPosition: defaultTabBarPosition, | |
| 227 | 227 | HideTabBarWhenSingleTab: true, | |
| @@ -218,10 +218,10 @@ func DefaultConfig() *Config { | |||||
| 218 | NextTab: "ctrl+tab", | 218 | NextTab: "ctrl+tab", | ||
| 219 | PreviousTab: "ctrl+shift+tab", | 219 | PreviousTab: "ctrl+shift+tab", | ||
| 220 | 220 | ||||
| 221 | - | ConsumeOrExpelLeft: "ctrl+bracketleft", | 221 | + | ConsumeOrExpelLeft: "alt+bracketleft", |
| 222 | - | ConsumeOrExpelRight: "ctrl+bracketright", | 222 | + | ConsumeOrExpelRight: "alt+bracketright", |
| 223 | - | ConsumeOrExpelUp: "ctrl+shift+bracketleft", | 223 | + | ConsumeOrExpelUp: "alt+shift+bracketleft", |
| 224 | - | ConsumeOrExpelDown: "ctrl+shift+bracketright", | 224 | + | ConsumeOrExpelDown: "alt+shift+bracketright", |
| 225 | }, | 225 | }, | ||
| 226 | TabBarPosition: defaultTabBarPosition, | 226 | TabBarPosition: defaultTabBarPosition, | ||
| 227 | HideTabBarWhenSingleTab: true, | 227 | HideTabBarWhenSingleTab: true, | ||
M internal/ui/input/keyboard.go +2 -2
| @@ -212,9 +212,9 @@ func (h *KeyboardHandler) lookupAction( | |||
| 212 | 212 | } | |
| 213 | 213 | } | |
| 214 | 214 | ||
| 215 | - | if !found && mode == ModeNormal && (modifiers == ModCtrl || modifiers == (ModCtrl|ModShift)) { | |
| 215 | + | if !found && mode == ModeNormal && (modifiers == ModAlt || modifiers == (ModAlt|ModShift)) { | |
| 216 | 216 | if bracketActions, ok := KeycodeToBracketAction[keycode]; ok { | |
| 217 | - | if modifiers == (ModCtrl | ModShift) { | |
| 217 | + | if modifiers == (ModAlt | ModShift) { | |
| 218 | 218 | action = bracketActions.WithShift | |
| 219 | 219 | } else { | |
| 220 | 220 | action = bracketActions.NoShift | |
| @@ -212,9 +212,9 @@ func (h *KeyboardHandler) lookupAction( | |||||
| 212 | } | 212 | } | ||
| 213 | } | 213 | } | ||
| 214 | 214 | ||||
| 215 | - | if !found && mode == ModeNormal && (modifiers == ModCtrl || modifiers == (ModCtrl|ModShift)) { | 215 | + | if !found && mode == ModeNormal && (modifiers == ModAlt || modifiers == (ModAlt|ModShift)) { |
| 216 | if bracketActions, ok := KeycodeToBracketAction[keycode]; ok { | 216 | if bracketActions, ok := KeycodeToBracketAction[keycode]; ok { | ||
| 217 | - | if modifiers == (ModCtrl | ModShift) { | 217 | + | if modifiers == (ModAlt | ModShift) { |
| 218 | action = bracketActions.WithShift | 218 | action = bracketActions.WithShift | ||
| 219 | } else { | 219 | } else { | ||
| 220 | action = bracketActions.NoShift | 220 | action = bracketActions.NoShift | ||