feat(keys): add configurable pane resize actions
- commit
- 563179c7542b1227ffe93496c283fd6641528a50
- author
- brice · · 2026-07-25 04:58 UTC
- parent
- c54ef32
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
5 files · +193 -2
M internal/usecase/daemon/input.go +27 -2
| @@ -266,8 +266,9 @@ func sgrOffset(raw []byte, colDelta, rowDelta int) []byte { | |||
| 266 | 266 | } | |
| 267 | 267 | ||
| 268 | 268 | type daemonKeyHandler struct { | |
| 269 | - | d *Daemon | |
| 270 | - | ac *attachedClient | |
| 269 | + | d *Daemon | |
| 270 | + | ac *attachedClient | |
| 271 | + | actions daemonActionRunner | |
| 271 | 272 | } | |
| 272 | 273 | ||
| 273 | 274 | func (h daemonKeyHandler) Forward(data []byte) { | |
| @@ -290,6 +291,20 @@ func (h daemonKeyHandler) Action(action keys.Action) { | |||
| 290 | 291 | if sess == nil { | |
| 291 | 292 | return | |
| 292 | 293 | } | |
| 294 | + | runResizeAction := func(request daemonActionRequest) { | |
| 295 | + | request.target = resolveDaemonActionTarget(sess) | |
| 296 | + | runner := h.actions | |
| 297 | + | if runner == nil { | |
| 298 | + | runner = daemonActions{d: h.d} | |
| 299 | + | } | |
| 300 | + | if err := runner.Run(request); err != nil { | |
| 301 | + | h.d.reportError(sess, resizeUserError(err)) | |
| 302 | + | return | |
| 303 | + | } | |
| 304 | + | if h.actions == nil { | |
| 305 | + | finishDaemonActionForClient(h.d, request, h.ac, "input.go") | |
| 306 | + | } | |
| 307 | + | } | |
| 293 | 308 | switch action { | |
| 294 | 309 | case keys.ActionOpenPalette: | |
| 295 | 310 | h.d.enterPalette(sess, h.ac) | |
| @@ -318,6 +333,16 @@ func (h daemonKeyHandler) Action(action keys.Action) { | |||
| 318 | 333 | if err := h.d.focusDir(sess, h.ac, layout.Down); err != nil { | |
| 319 | 334 | h.d.reportError(sess, err) | |
| 320 | 335 | } | |
| 336 | + | case keys.ActionGrowPaneWidth: | |
| 337 | + | runResizeAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Width, delta: resizeStepCols}) | |
| 338 | + | case keys.ActionShrinkPaneWidth: | |
| 339 | + | runResizeAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Width, delta: -resizeStepCols}) | |
| 340 | + | case keys.ActionGrowPaneHeight: | |
| 341 | + | runResizeAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Height, delta: resizeStepRows}) | |
| 342 | + | case keys.ActionShrinkPaneHeight: | |
| 343 | + | runResizeAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Height, delta: -resizeStepRows}) | |
| 344 | + | case keys.ActionEqualizePanes: | |
| 345 | + | runResizeAction(daemonActionRequest{kind: daemonActionEqualizePanes}) | |
| 321 | 346 | case keys.ActionSwitchTab1, keys.ActionSwitchTab2, keys.ActionSwitchTab3, | |
| 322 | 347 | keys.ActionSwitchTab4, keys.ActionSwitchTab5, keys.ActionSwitchTab6, | |
| 323 | 348 | keys.ActionSwitchTab7, keys.ActionSwitchTab8, keys.ActionSwitchTab9: | |
| @@ -266,8 +266,9 @@ func sgrOffset(raw []byte, colDelta, rowDelta int) []byte { | |||||
| 266 | } | 266 | } | ||
| 267 | 267 | ||||
| 268 | type daemonKeyHandler struct { | 268 | type daemonKeyHandler struct { | ||
| 269 | - | d *Daemon | 269 | + | d *Daemon |
| 270 | - | ac *attachedClient | 270 | + | ac *attachedClient |
| 271 | + | actions daemonActionRunner | |||
| 271 | } | 272 | } | ||
| 272 | 273 | ||||
| 273 | func (h daemonKeyHandler) Forward(data []byte) { | 274 | func (h daemonKeyHandler) Forward(data []byte) { | ||
| @@ -290,6 +291,20 @@ func (h daemonKeyHandler) Action(action keys.Action) { | |||||
| 290 | if sess == nil { | 291 | if sess == nil { | ||
| 291 | return | 292 | return | ||
| 292 | } | 293 | } | ||
| 294 | + | runResizeAction := func(request daemonActionRequest) { | |||
| 295 | + | request.target = resolveDaemonActionTarget(sess) | |||
| 296 | + | runner := h.actions | |||
| 297 | + | if runner == nil { | |||
| 298 | + | runner = daemonActions{d: h.d} | |||
| 299 | + | } | |||
| 300 | + | if err := runner.Run(request); err != nil { | |||
| 301 | + | h.d.reportError(sess, resizeUserError(err)) | |||
| 302 | + | return | |||
| 303 | + | } | |||
| 304 | + | if h.actions == nil { | |||
| 305 | + | finishDaemonActionForClient(h.d, request, h.ac, "input.go") | |||
| 306 | + | } | |||
| 307 | + | } | |||
| 293 | switch action { | 308 | switch action { | ||
| 294 | case keys.ActionOpenPalette: | 309 | case keys.ActionOpenPalette: | ||
| 295 | h.d.enterPalette(sess, h.ac) | 310 | h.d.enterPalette(sess, h.ac) | ||
| @@ -318,6 +333,16 @@ func (h daemonKeyHandler) Action(action keys.Action) { | |||||
| 318 | if err := h.d.focusDir(sess, h.ac, layout.Down); err != nil { | 333 | if err := h.d.focusDir(sess, h.ac, layout.Down); err != nil { | ||
| 319 | h.d.reportError(sess, err) | 334 | h.d.reportError(sess, err) | ||
| 320 | } | 335 | } | ||
| 336 | + | case keys.ActionGrowPaneWidth: | |||
| 337 | + | runResizeAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Width, delta: resizeStepCols}) | |||
| 338 | + | case keys.ActionShrinkPaneWidth: | |||
| 339 | + | runResizeAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Width, delta: -resizeStepCols}) | |||
| 340 | + | case keys.ActionGrowPaneHeight: | |||
| 341 | + | runResizeAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Height, delta: resizeStepRows}) | |||
| 342 | + | case keys.ActionShrinkPaneHeight: | |||
| 343 | + | runResizeAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Height, delta: -resizeStepRows}) | |||
| 344 | + | case keys.ActionEqualizePanes: | |||
| 345 | + | runResizeAction(daemonActionRequest{kind: daemonActionEqualizePanes}) | |||
| 321 | case keys.ActionSwitchTab1, keys.ActionSwitchTab2, keys.ActionSwitchTab3, | 346 | case keys.ActionSwitchTab1, keys.ActionSwitchTab2, keys.ActionSwitchTab3, | ||
| 322 | keys.ActionSwitchTab4, keys.ActionSwitchTab5, keys.ActionSwitchTab6, | 347 | keys.ActionSwitchTab4, keys.ActionSwitchTab5, keys.ActionSwitchTab6, | ||
| 323 | keys.ActionSwitchTab7, keys.ActionSwitchTab8, keys.ActionSwitchTab9: | 348 | keys.ActionSwitchTab7, keys.ActionSwitchTab8, keys.ActionSwitchTab9: | ||
M internal/usecase/daemon/input_test.go +121 -0
| @@ -26,6 +26,127 @@ import ( | |||
| 26 | 26 | ||
| 27 | 27 | // --- test doubles ----------------------------------------------------------- | |
| 28 | 28 | ||
| 29 | + | func TestResizeActionAdaptersSubmitEquivalentRequests(t *testing.T) { | |
| 30 | + | tests := []struct { | |
| 31 | + | name string | |
| 32 | + | action keys.Action | |
| 33 | + | palette func(paletteExec) error | |
| 34 | + | control func(controlExec) error | |
| 35 | + | }{ | |
| 36 | + | {"grow width", keys.ActionGrowPaneWidth, func(e paletteExec) error { return e.GrowPaneWidth() }, func(e controlExec) error { return e.GrowPaneWidth() }}, | |
| 37 | + | {"shrink width", keys.ActionShrinkPaneWidth, func(e paletteExec) error { return e.ShrinkPaneWidth() }, func(e controlExec) error { return e.ShrinkPaneWidth() }}, | |
| 38 | + | {"grow height", keys.ActionGrowPaneHeight, func(e paletteExec) error { return e.GrowPaneHeight() }, func(e controlExec) error { return e.GrowPaneHeight() }}, | |
| 39 | + | {"shrink height", keys.ActionShrinkPaneHeight, func(e paletteExec) error { return e.ShrinkPaneHeight() }, func(e controlExec) error { return e.ShrinkPaneHeight() }}, | |
| 40 | + | {"equalize", keys.ActionEqualizePanes, func(e paletteExec) error { return e.EqualizePanes() }, func(e controlExec) error { return e.EqualizePanes() }}, | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | for _, tt := range tests { | |
| 44 | + | t.Run(tt.name, func(t *testing.T) { | |
| 45 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 46 | + | sess := addControlSession(d, "work", "t_work", "p_work") | |
| 47 | + | ac := &attachedClient{} | |
| 48 | + | ac.setSession(sess) | |
| 49 | + | target := resolveDaemonActionTarget(sess) | |
| 50 | + | keySpy := &actionRunnerSpy{} | |
| 51 | + | paletteSpy := &actionRunnerSpy{} | |
| 52 | + | controlSpy := &actionRunnerSpy{} | |
| 53 | + | ||
| 54 | + | daemonKeyHandler{d: d, ac: ac, actions: keySpy}.Action(tt.action) | |
| 55 | + | require.NoError(t, tt.palette(paletteExec{d: d, sess: sess, actions: paletteSpy})) | |
| 56 | + | require.NoError(t, tt.control(controlExec{d: d, sess: sess, target: target, actions: controlSpy})) | |
| 57 | + | ||
| 58 | + | require.Len(t, keySpy.requests, 1) | |
| 59 | + | require.Equal(t, keySpy.requests, paletteSpy.requests) | |
| 60 | + | require.Equal(t, keySpy.requests, controlSpy.requests) | |
| 61 | + | require.Same(t, sess, keySpy.requests[0].target.session) | |
| 62 | + | require.Same(t, target.tab, keySpy.requests[0].target.tab) | |
| 63 | + | require.Same(t, target.pane, keySpy.requests[0].target.pane) | |
| 64 | + | }) | |
| 65 | + | } | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | func TestResizeActionAdaptersProduceEquivalentGeometry(t *testing.T) { | |
| 69 | + | type adapterState struct { | |
| 70 | + | tree *layout.Tree | |
| 71 | + | placements []layout.Placement | |
| 72 | + | generationDelta uint64 | |
| 73 | + | snapshotDirtyDelta uint64 | |
| 74 | + | } | |
| 75 | + | tests := []struct { | |
| 76 | + | name string | |
| 77 | + | action keys.Action | |
| 78 | + | palette func(paletteExec) error | |
| 79 | + | control func(controlExec) error | |
| 80 | + | }{ | |
| 81 | + | {"grow width", keys.ActionGrowPaneWidth, func(e paletteExec) error { return e.GrowPaneWidth() }, func(e controlExec) error { return e.GrowPaneWidth() }}, | |
| 82 | + | {"shrink width", keys.ActionShrinkPaneWidth, func(e paletteExec) error { return e.ShrinkPaneWidth() }, func(e controlExec) error { return e.ShrinkPaneWidth() }}, | |
| 83 | + | {"grow height", keys.ActionGrowPaneHeight, func(e paletteExec) error { return e.GrowPaneHeight() }, func(e controlExec) error { return e.GrowPaneHeight() }}, | |
| 84 | + | {"shrink height", keys.ActionShrinkPaneHeight, func(e paletteExec) error { return e.ShrinkPaneHeight() }, func(e controlExec) error { return e.ShrinkPaneHeight() }}, | |
| 85 | + | {"equalize", keys.ActionEqualizePanes, func(e paletteExec) error { return e.EqualizePanes() }, func(e controlExec) error { return e.EqualizePanes() }}, | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | for _, tt := range tests { | |
| 89 | + | t.Run(tt.name, func(t *testing.T) { | |
| 90 | + | run := func(t *testing.T, adapter string) adapterState { | |
| 91 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, nil) | |
| 92 | + | tb := sess.activeTab() | |
| 93 | + | p2 := newPane("pane-2", nil, tb.size) | |
| 94 | + | p3 := newPane("pane-3", nil, tb.size) | |
| 95 | + | tb.mu.Lock() | |
| 96 | + | tb.panes[p2.id] = p2 | |
| 97 | + | tb.panes[p3.id] = p3 | |
| 98 | + | tb.tree = &layout.Tree{ | |
| 99 | + | Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{ | |
| 100 | + | layout.NewLeaf("pane-1"), | |
| 101 | + | {Kind: layout.Split, Dir: layout.Vertical, Weight: 2, Children: []*layout.Node{ | |
| 102 | + | layout.NewLeaf("pane-2"), layout.NewLeaf("pane-3"), | |
| 103 | + | }}, | |
| 104 | + | }}, | |
| 105 | + | Focus: "pane-2", | |
| 106 | + | } | |
| 107 | + | tb.tree.Root.Children[0].Weight = 1 | |
| 108 | + | tb.tree.Root.Children[1].Children[0].Weight = 1 | |
| 109 | + | tb.tree.Root.Children[1].Children[1].Weight = 2 | |
| 110 | + | generation := tb.layoutGeneration | |
| 111 | + | tb.mu.Unlock() | |
| 112 | + | sess.snapEligible.Store(true) | |
| 113 | + | sess.snapshotMu.Lock() | |
| 114 | + | dirty := sess.snapshotGeneration | |
| 115 | + | sess.snapshotMu.Unlock() | |
| 116 | + | ||
| 117 | + | switch adapter { | |
| 118 | + | case "key": | |
| 119 | + | daemonKeyHandler{d: d, ac: ac}.Action(tt.action) | |
| 120 | + | case "palette": | |
| 121 | + | require.NoError(t, tt.palette(paletteExec{d: d, sess: sess, ac: ac})) | |
| 122 | + | case "control": | |
| 123 | + | target := resolveDaemonActionTarget(sess) | |
| 124 | + | require.NoError(t, tt.control(controlExec{d: d, sess: sess, target: target})) | |
| 125 | + | default: | |
| 126 | + | t.Fatalf("unknown adapter %q", adapter) | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | tb.mu.Lock() | |
| 130 | + | tree := tb.tree.Clone() | |
| 131 | + | gotGeneration := tb.layoutGeneration | |
| 132 | + | placements, ok := layout.Solve(tb.tree.Root, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}) | |
| 133 | + | tb.mu.Unlock() | |
| 134 | + | require.True(t, ok) | |
| 135 | + | sess.snapshotMu.Lock() | |
| 136 | + | gotDirty := sess.snapshotGeneration | |
| 137 | + | sess.snapshotMu.Unlock() | |
| 138 | + | return adapterState{tree: tree, placements: placements, generationDelta: gotGeneration - generation, snapshotDirtyDelta: gotDirty - dirty} | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | keyState := run(t, "key") | |
| 142 | + | require.Equal(t, uint64(1), keyState.generationDelta) | |
| 143 | + | require.Equal(t, uint64(1), keyState.snapshotDirtyDelta) | |
| 144 | + | require.Equal(t, keyState, run(t, "palette")) | |
| 145 | + | require.Equal(t, keyState, run(t, "control")) | |
| 146 | + | }) | |
| 147 | + | } | |
| 148 | + | } | |
| 149 | + | ||
| 29 | 150 | // stubClock returns timers whose channel never fires, so a scheduler under it | |
| 30 | 151 | // blocks in its debounce loop until the session context is cancelled. Used by | |
| 31 | 152 | ||
| @@ -26,6 +26,127 @@ import ( | |||||
| 26 | 26 | ||||
| 27 | // --- test doubles ----------------------------------------------------------- | 27 | // --- test doubles ----------------------------------------------------------- | ||
| 28 | 28 | ||||
| 29 | + | func TestResizeActionAdaptersSubmitEquivalentRequests(t *testing.T) { | |||
| 30 | + | tests := []struct { | |||
| 31 | + | name string | |||
| 32 | + | action keys.Action | |||
| 33 | + | palette func(paletteExec) error | |||
| 34 | + | control func(controlExec) error | |||
| 35 | + | }{ | |||
| 36 | + | {"grow width", keys.ActionGrowPaneWidth, func(e paletteExec) error { return e.GrowPaneWidth() }, func(e controlExec) error { return e.GrowPaneWidth() }}, | |||
| 37 | + | {"shrink width", keys.ActionShrinkPaneWidth, func(e paletteExec) error { return e.ShrinkPaneWidth() }, func(e controlExec) error { return e.ShrinkPaneWidth() }}, | |||
| 38 | + | {"grow height", keys.ActionGrowPaneHeight, func(e paletteExec) error { return e.GrowPaneHeight() }, func(e controlExec) error { return e.GrowPaneHeight() }}, | |||
| 39 | + | {"shrink height", keys.ActionShrinkPaneHeight, func(e paletteExec) error { return e.ShrinkPaneHeight() }, func(e controlExec) error { return e.ShrinkPaneHeight() }}, | |||
| 40 | + | {"equalize", keys.ActionEqualizePanes, func(e paletteExec) error { return e.EqualizePanes() }, func(e controlExec) error { return e.EqualizePanes() }}, | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | for _, tt := range tests { | |||
| 44 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 45 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 46 | + | sess := addControlSession(d, "work", "t_work", "p_work") | |||
| 47 | + | ac := &attachedClient{} | |||
| 48 | + | ac.setSession(sess) | |||
| 49 | + | target := resolveDaemonActionTarget(sess) | |||
| 50 | + | keySpy := &actionRunnerSpy{} | |||
| 51 | + | paletteSpy := &actionRunnerSpy{} | |||
| 52 | + | controlSpy := &actionRunnerSpy{} | |||
| 53 | + | ||||
| 54 | + | daemonKeyHandler{d: d, ac: ac, actions: keySpy}.Action(tt.action) | |||
| 55 | + | require.NoError(t, tt.palette(paletteExec{d: d, sess: sess, actions: paletteSpy})) | |||
| 56 | + | require.NoError(t, tt.control(controlExec{d: d, sess: sess, target: target, actions: controlSpy})) | |||
| 57 | + | ||||
| 58 | + | require.Len(t, keySpy.requests, 1) | |||
| 59 | + | require.Equal(t, keySpy.requests, paletteSpy.requests) | |||
| 60 | + | require.Equal(t, keySpy.requests, controlSpy.requests) | |||
| 61 | + | require.Same(t, sess, keySpy.requests[0].target.session) | |||
| 62 | + | require.Same(t, target.tab, keySpy.requests[0].target.tab) | |||
| 63 | + | require.Same(t, target.pane, keySpy.requests[0].target.pane) | |||
| 64 | + | }) | |||
| 65 | + | } | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | func TestResizeActionAdaptersProduceEquivalentGeometry(t *testing.T) { | |||
| 69 | + | type adapterState struct { | |||
| 70 | + | tree *layout.Tree | |||
| 71 | + | placements []layout.Placement | |||
| 72 | + | generationDelta uint64 | |||
| 73 | + | snapshotDirtyDelta uint64 | |||
| 74 | + | } | |||
| 75 | + | tests := []struct { | |||
| 76 | + | name string | |||
| 77 | + | action keys.Action | |||
| 78 | + | palette func(paletteExec) error | |||
| 79 | + | control func(controlExec) error | |||
| 80 | + | }{ | |||
| 81 | + | {"grow width", keys.ActionGrowPaneWidth, func(e paletteExec) error { return e.GrowPaneWidth() }, func(e controlExec) error { return e.GrowPaneWidth() }}, | |||
| 82 | + | {"shrink width", keys.ActionShrinkPaneWidth, func(e paletteExec) error { return e.ShrinkPaneWidth() }, func(e controlExec) error { return e.ShrinkPaneWidth() }}, | |||
| 83 | + | {"grow height", keys.ActionGrowPaneHeight, func(e paletteExec) error { return e.GrowPaneHeight() }, func(e controlExec) error { return e.GrowPaneHeight() }}, | |||
| 84 | + | {"shrink height", keys.ActionShrinkPaneHeight, func(e paletteExec) error { return e.ShrinkPaneHeight() }, func(e controlExec) error { return e.ShrinkPaneHeight() }}, | |||
| 85 | + | {"equalize", keys.ActionEqualizePanes, func(e paletteExec) error { return e.EqualizePanes() }, func(e controlExec) error { return e.EqualizePanes() }}, | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | for _, tt := range tests { | |||
| 89 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 90 | + | run := func(t *testing.T, adapter string) adapterState { | |||
| 91 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, nil) | |||
| 92 | + | tb := sess.activeTab() | |||
| 93 | + | p2 := newPane("pane-2", nil, tb.size) | |||
| 94 | + | p3 := newPane("pane-3", nil, tb.size) | |||
| 95 | + | tb.mu.Lock() | |||
| 96 | + | tb.panes[p2.id] = p2 | |||
| 97 | + | tb.panes[p3.id] = p3 | |||
| 98 | + | tb.tree = &layout.Tree{ | |||
| 99 | + | Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{ | |||
| 100 | + | layout.NewLeaf("pane-1"), | |||
| 101 | + | {Kind: layout.Split, Dir: layout.Vertical, Weight: 2, Children: []*layout.Node{ | |||
| 102 | + | layout.NewLeaf("pane-2"), layout.NewLeaf("pane-3"), | |||
| 103 | + | }}, | |||
| 104 | + | }}, | |||
| 105 | + | Focus: "pane-2", | |||
| 106 | + | } | |||
| 107 | + | tb.tree.Root.Children[0].Weight = 1 | |||
| 108 | + | tb.tree.Root.Children[1].Children[0].Weight = 1 | |||
| 109 | + | tb.tree.Root.Children[1].Children[1].Weight = 2 | |||
| 110 | + | generation := tb.layoutGeneration | |||
| 111 | + | tb.mu.Unlock() | |||
| 112 | + | sess.snapEligible.Store(true) | |||
| 113 | + | sess.snapshotMu.Lock() | |||
| 114 | + | dirty := sess.snapshotGeneration | |||
| 115 | + | sess.snapshotMu.Unlock() | |||
| 116 | + | ||||
| 117 | + | switch adapter { | |||
| 118 | + | case "key": | |||
| 119 | + | daemonKeyHandler{d: d, ac: ac}.Action(tt.action) | |||
| 120 | + | case "palette": | |||
| 121 | + | require.NoError(t, tt.palette(paletteExec{d: d, sess: sess, ac: ac})) | |||
| 122 | + | case "control": | |||
| 123 | + | target := resolveDaemonActionTarget(sess) | |||
| 124 | + | require.NoError(t, tt.control(controlExec{d: d, sess: sess, target: target})) | |||
| 125 | + | default: | |||
| 126 | + | t.Fatalf("unknown adapter %q", adapter) | |||
| 127 | + | } | |||
| 128 | + | ||||
| 129 | + | tb.mu.Lock() | |||
| 130 | + | tree := tb.tree.Clone() | |||
| 131 | + | gotGeneration := tb.layoutGeneration | |||
| 132 | + | placements, ok := layout.Solve(tb.tree.Root, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}) | |||
| 133 | + | tb.mu.Unlock() | |||
| 134 | + | require.True(t, ok) | |||
| 135 | + | sess.snapshotMu.Lock() | |||
| 136 | + | gotDirty := sess.snapshotGeneration | |||
| 137 | + | sess.snapshotMu.Unlock() | |||
| 138 | + | return adapterState{tree: tree, placements: placements, generationDelta: gotGeneration - generation, snapshotDirtyDelta: gotDirty - dirty} | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | keyState := run(t, "key") | |||
| 142 | + | require.Equal(t, uint64(1), keyState.generationDelta) | |||
| 143 | + | require.Equal(t, uint64(1), keyState.snapshotDirtyDelta) | |||
| 144 | + | require.Equal(t, keyState, run(t, "palette")) | |||
| 145 | + | require.Equal(t, keyState, run(t, "control")) | |||
| 146 | + | }) | |||
| 147 | + | } | |||
| 148 | + | } | |||
| 149 | + | ||||
| 29 | // stubClock returns timers whose channel never fires, so a scheduler under it | 150 | // stubClock returns timers whose channel never fires, so a scheduler under it | ||
| 30 | // blocks in its debounce loop until the session context is cancelled. Used by | 151 | // blocks in its debounce loop until the session context is cancelled. Used by | ||
| 31 | 152 | ||||
M internal/usecase/keys/keyspec.go +5 -0
| @@ -65,6 +65,11 @@ var actionNames = map[Action]string{ | |||
| 65 | 65 | ActionSwitchTab7: "switch-tab-7", | |
| 66 | 66 | ActionSwitchTab8: "switch-tab-8", | |
| 67 | 67 | ActionSwitchTab9: "switch-tab-9", | |
| 68 | + | ActionGrowPaneWidth: "grow-pane-width", | |
| 69 | + | ActionShrinkPaneWidth: "shrink-pane-width", | |
| 70 | + | ActionGrowPaneHeight: "grow-pane-height", | |
| 71 | + | ActionShrinkPaneHeight: "shrink-pane-height", | |
| 72 | + | ActionEqualizePanes: "equalize-panes", | |
| 68 | 73 | } | |
| 69 | 74 | ||
| 70 | 75 | var actionsByName = func() map[string]Action { | |
| @@ -65,6 +65,11 @@ var actionNames = map[Action]string{ | |||||
| 65 | ActionSwitchTab7: "switch-tab-7", | 65 | ActionSwitchTab7: "switch-tab-7", | ||
| 66 | ActionSwitchTab8: "switch-tab-8", | 66 | ActionSwitchTab8: "switch-tab-8", | ||
| 67 | ActionSwitchTab9: "switch-tab-9", | 67 | ActionSwitchTab9: "switch-tab-9", | ||
| 68 | + | ActionGrowPaneWidth: "grow-pane-width", | |||
| 69 | + | ActionShrinkPaneWidth: "shrink-pane-width", | |||
| 70 | + | ActionGrowPaneHeight: "grow-pane-height", | |||
| 71 | + | ActionShrinkPaneHeight: "shrink-pane-height", | |||
| 72 | + | ActionEqualizePanes: "equalize-panes", | |||
| 68 | } | 73 | } | ||
| 69 | 74 | ||||
| 70 | var actionsByName = func() map[string]Action { | 75 | var actionsByName = func() map[string]Action { | ||
M internal/usecase/keys/keyspec_test.go +35 -0
| @@ -79,6 +79,11 @@ func TestActionNamesAreCanonical(t *testing.T) { | |||
| 79 | 79 | {ActionSwitchTab7, "switch-tab-7"}, | |
| 80 | 80 | {ActionSwitchTab8, "switch-tab-8"}, | |
| 81 | 81 | {ActionSwitchTab9, "switch-tab-9"}, | |
| 82 | + | {ActionGrowPaneWidth, "grow-pane-width"}, | |
| 83 | + | {ActionShrinkPaneWidth, "shrink-pane-width"}, | |
| 84 | + | {ActionGrowPaneHeight, "grow-pane-height"}, | |
| 85 | + | {ActionShrinkPaneHeight, "shrink-pane-height"}, | |
| 86 | + | {ActionEqualizePanes, "equalize-panes"}, | |
| 82 | 87 | } | |
| 83 | 88 | ||
| 84 | 89 | for _, tc := range cases { | |
| @@ -114,6 +119,36 @@ func TestBuildBindingsAppliesOverrides(t *testing.T) { | |||
| 114 | 119 | assertAltRuneAction(t, bindings, 'é', ActionSwitchTab2) | |
| 115 | 120 | } | |
| 116 | 121 | ||
| 122 | + | func TestResizeActionsBindWithoutDefaults(t *testing.T) { | |
| 123 | + | tests := []struct { | |
| 124 | + | name string | |
| 125 | + | action Action | |
| 126 | + | key rune | |
| 127 | + | }{ | |
| 128 | + | {"grow-pane-width", ActionGrowPaneWidth, 'w'}, | |
| 129 | + | {"shrink-pane-width", ActionShrinkPaneWidth, 's'}, | |
| 130 | + | {"grow-pane-height", ActionGrowPaneHeight, 'g'}, | |
| 131 | + | {"shrink-pane-height", ActionShrinkPaneHeight, 'r'}, | |
| 132 | + | {"equalize-panes", ActionEqualizePanes, 'e'}, | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | defaults := DefaultBindings() | |
| 136 | + | for _, tt := range tests { | |
| 137 | + | t.Run(tt.name, func(t *testing.T) { | |
| 138 | + | for _, bound := range defaults.altRunes { | |
| 139 | + | require.NotEqual(t, tt.action, bound) | |
| 140 | + | } | |
| 141 | + | for _, bound := range defaults.altArrows { | |
| 142 | + | require.NotEqual(t, tt.action, bound) | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | bindings, warnings := BuildBindings(map[string]string{tt.name: "alt+" + string(tt.key)}) | |
| 146 | + | require.Empty(t, warnings) | |
| 147 | + | assertAltRuneAction(t, bindings, tt.key, tt.action) | |
| 148 | + | }) | |
| 149 | + | } | |
| 150 | + | } | |
| 151 | + | ||
| 117 | 152 | func TestBuildBindingsWarnsAndSkipsInvalidEntries(t *testing.T) { | |
| 118 | 153 | bindings, warnings := BuildBindings(map[string]string{ | |
| 119 | 154 | "missing": "alt+m", | |
| @@ -79,6 +79,11 @@ func TestActionNamesAreCanonical(t *testing.T) { | |||||
| 79 | {ActionSwitchTab7, "switch-tab-7"}, | 79 | {ActionSwitchTab7, "switch-tab-7"}, | ||
| 80 | {ActionSwitchTab8, "switch-tab-8"}, | 80 | {ActionSwitchTab8, "switch-tab-8"}, | ||
| 81 | {ActionSwitchTab9, "switch-tab-9"}, | 81 | {ActionSwitchTab9, "switch-tab-9"}, | ||
| 82 | + | {ActionGrowPaneWidth, "grow-pane-width"}, | |||
| 83 | + | {ActionShrinkPaneWidth, "shrink-pane-width"}, | |||
| 84 | + | {ActionGrowPaneHeight, "grow-pane-height"}, | |||
| 85 | + | {ActionShrinkPaneHeight, "shrink-pane-height"}, | |||
| 86 | + | {ActionEqualizePanes, "equalize-panes"}, | |||
| 82 | } | 87 | } | ||
| 83 | 88 | ||||
| 84 | for _, tc := range cases { | 89 | for _, tc := range cases { | ||
| @@ -114,6 +119,36 @@ func TestBuildBindingsAppliesOverrides(t *testing.T) { | |||||
| 114 | assertAltRuneAction(t, bindings, 'é', ActionSwitchTab2) | 119 | assertAltRuneAction(t, bindings, 'é', ActionSwitchTab2) | ||
| 115 | } | 120 | } | ||
| 116 | 121 | ||||
| 122 | + | func TestResizeActionsBindWithoutDefaults(t *testing.T) { | |||
| 123 | + | tests := []struct { | |||
| 124 | + | name string | |||
| 125 | + | action Action | |||
| 126 | + | key rune | |||
| 127 | + | }{ | |||
| 128 | + | {"grow-pane-width", ActionGrowPaneWidth, 'w'}, | |||
| 129 | + | {"shrink-pane-width", ActionShrinkPaneWidth, 's'}, | |||
| 130 | + | {"grow-pane-height", ActionGrowPaneHeight, 'g'}, | |||
| 131 | + | {"shrink-pane-height", ActionShrinkPaneHeight, 'r'}, | |||
| 132 | + | {"equalize-panes", ActionEqualizePanes, 'e'}, | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | defaults := DefaultBindings() | |||
| 136 | + | for _, tt := range tests { | |||
| 137 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 138 | + | for _, bound := range defaults.altRunes { | |||
| 139 | + | require.NotEqual(t, tt.action, bound) | |||
| 140 | + | } | |||
| 141 | + | for _, bound := range defaults.altArrows { | |||
| 142 | + | require.NotEqual(t, tt.action, bound) | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | bindings, warnings := BuildBindings(map[string]string{tt.name: "alt+" + string(tt.key)}) | |||
| 146 | + | require.Empty(t, warnings) | |||
| 147 | + | assertAltRuneAction(t, bindings, tt.key, tt.action) | |||
| 148 | + | }) | |||
| 149 | + | } | |||
| 150 | + | } | |||
| 151 | + | ||||
| 117 | func TestBuildBindingsWarnsAndSkipsInvalidEntries(t *testing.T) { | 152 | func TestBuildBindingsWarnsAndSkipsInvalidEntries(t *testing.T) { | ||
| 118 | bindings, warnings := BuildBindings(map[string]string{ | 153 | bindings, warnings := BuildBindings(map[string]string{ | ||
| 119 | "missing": "alt+m", | 154 | "missing": "alt+m", | ||
M internal/usecase/keys/router.go +5 -0
| @@ -38,6 +38,11 @@ const ( | |||
| 38 | 38 | ActionFocusPaneUp | |
| 39 | 39 | ActionFocusPaneDown | |
| 40 | 40 | ActionToggleFloatingPane | |
| 41 | + | ActionGrowPaneWidth | |
| 42 | + | ActionShrinkPaneWidth | |
| 43 | + | ActionGrowPaneHeight | |
| 44 | + | ActionShrinkPaneHeight | |
| 45 | + | ActionEqualizePanes | |
| 41 | 46 | ) | |
| 42 | 47 | ||
| 43 | 48 | // Handler receives router outputs. Forward is called only for bytes that should | |
| @@ -38,6 +38,11 @@ const ( | |||||
| 38 | ActionFocusPaneUp | 38 | ActionFocusPaneUp | ||
| 39 | ActionFocusPaneDown | 39 | ActionFocusPaneDown | ||
| 40 | ActionToggleFloatingPane | 40 | ActionToggleFloatingPane | ||
| 41 | + | ActionGrowPaneWidth | |||
| 42 | + | ActionShrinkPaneWidth | |||
| 43 | + | ActionGrowPaneHeight | |||
| 44 | + | ActionShrinkPaneHeight | |||
| 45 | + | ActionEqualizePanes | |||
| 41 | ) | 46 | ) | ||
| 42 | 47 | ||||
| 43 | // Handler receives router outputs. Forward is called only for bytes that should | 48 | // Handler receives router outputs. Forward is called only for bytes that should | ||