detached c9cbc6e

feat(ui): implement modal resize mode and shortcuts

commit
c9cbc6e3117f9f7f4994ad7562c5a5dcd5d47eef
author
brice · · 2025-12-26 07:03 UTC
parent
9631504
signature
unsigned
5 files · +267 -84
M internal/ui/coordinator/workspace.go +61 -4
@@ -2,6 +2,7 @@ package coordinator
22
33 import (
44 "context"
5+ "errors"
56 "fmt"
67
78 "github.com/bnema/dumber/internal/application/usecase"
@@ -326,7 +327,7 @@ func (c *WorkspaceCoordinator) doIncrementalStackSplit(
326327 wsView.SetRootWidgetDirect(splitView.Widget())
327328
328329 if tr != nil {
329- tr.RegisterWidget(output.ParentNode.ID, splitView.Widget())
330+ tr.RegisterSplit(output.ParentNode.ID, splitView.Widget(), layout.OrientationHorizontal)
330331 }
331332 } else {
332333 // Non-root split: stack is inside another split, need to replace in grandparent
@@ -377,7 +378,7 @@ func (c *WorkspaceCoordinator) doIncrementalStackSplit(
377378 panedWidget.SetEndChild(splitView.Widget())
378379 }
379380
380- tr.RegisterWidget(output.ParentNode.ID, splitView.Widget())
381+ tr.RegisterSplit(output.ParentNode.ID, splitView.Widget(), layout.OrientationHorizontal)
381382
382383 log.Debug().
383384 Bool("was_start_child", isStartChild).
@@ -585,7 +586,7 @@ func (c *WorkspaceCoordinator) replaceRootSplit(
585586 }
586587
587588 wsView.SetRootWidgetDirect(splitView.Widget())
588- tr.RegisterWidget(output.ParentNode.ID, splitView.Widget())
589+ tr.RegisterSplit(output.ParentNode.ID, splitView.Widget(), orientation)
589590
590591 logging.FromContext(ctx).Debug().Msg("root split: replaced root with new split view")
591592 return nil
@@ -649,7 +650,7 @@ func (c *WorkspaceCoordinator) replaceNonRootSplit(
649650 panedWidget.SetEndChild(splitView.Widget())
650651 }
651652
652- tr.RegisterWidget(output.ParentNode.ID, splitView.Widget())
653+ tr.RegisterSplit(output.ParentNode.ID, splitView.Widget(), orientation)
653654
654655 log.Debug().
655656 Bool("was_start_child", isStartChild).
@@ -1860,3 +1861,59 @@ func (c *WorkspaceCoordinator) ShowToastOnActivePane(ctx context.Context, messag
18601861 paneView.ShowToast(ctx, message, level)
18611862 }
18621863 }
1864+
1865+// Resize updates the active split ratio and applies it to GTK widgets.
1866+func (c *WorkspaceCoordinator) Resize(ctx context.Context, dir usecase.ResizeDirection) error {
1867+ log := logging.FromContext(ctx)
1868+
1869+ if c.panesUC == nil {
1870+ log.Warn().Msg("panes use case not available")
1871+ return nil
1872+ }
1873+
1874+ ws, wsView := c.getActiveWS()
1875+ if ws == nil {
1876+ log.Warn().Msg("no active workspace")
1877+ return nil
1878+ }
1879+
1880+ target := ws.ActivePane()
1881+ if target == nil {
1882+ return nil
1883+ }
1884+
1885+ cfg := config.Get()
1886+ err := c.panesUC.Resize(ctx, ws, target, dir, cfg.Workspace.ResizeMode.StepPercent, cfg.Workspace.ResizeMode.MinPanePercent)
1887+ if errors.Is(err, usecase.ErrNothingToResize) {
1888+ c.ShowToastOnActivePane(ctx, "Nothing to resize", component.ToastInfo)
1889+ return nil
1890+ }
1891+ if err != nil {
1892+ return err
1893+ }
1894+
1895+ if wsView != nil {
1896+ c.updateSplitPositions(wsView, ws)
1897+ }
1898+
1899+ c.notifyStateChanged()
1900+ return nil
1901+}
1902+
1903+func (c *WorkspaceCoordinator) updateSplitPositions(wsView *component.WorkspaceView, ws *entity.Workspace) {
1904+ if wsView == nil || ws == nil || ws.Root == nil {
1905+ return
1906+ }
1907+
1908+ tr := wsView.TreeRenderer()
1909+ if tr == nil {
1910+ return
1911+ }
1912+
1913+ ws.Root.Walk(func(node *entity.PaneNode) bool {
1914+ if node.IsSplit() {
1915+ _ = tr.UpdateSplitRatio(node.ID, node.SplitRatio)
1916+ }
1917+ return true
1918+ })
1919+}
@@ -2,6 +2,7 @@ package coordinator
2 2
3 import (3 import (
4 "context"4 "context"
5+ "errors"
5 "fmt"6 "fmt"
6 7
7 "github.com/bnema/dumber/internal/application/usecase"8 "github.com/bnema/dumber/internal/application/usecase"
@@ -326,7 +327,7 @@ func (c *WorkspaceCoordinator) doIncrementalStackSplit(
326 wsView.SetRootWidgetDirect(splitView.Widget())327 wsView.SetRootWidgetDirect(splitView.Widget())
327 328
328 if tr != nil {329 if tr != nil {
329- tr.RegisterWidget(output.ParentNode.ID, splitView.Widget())330+ tr.RegisterSplit(output.ParentNode.ID, splitView.Widget(), layout.OrientationHorizontal)
330 }331 }
331 } else {332 } else {
332 // Non-root split: stack is inside another split, need to replace in grandparent333 // Non-root split: stack is inside another split, need to replace in grandparent
@@ -377,7 +378,7 @@ func (c *WorkspaceCoordinator) doIncrementalStackSplit(
377 panedWidget.SetEndChild(splitView.Widget())378 panedWidget.SetEndChild(splitView.Widget())
378 }379 }
379 380
380- tr.RegisterWidget(output.ParentNode.ID, splitView.Widget())381+ tr.RegisterSplit(output.ParentNode.ID, splitView.Widget(), layout.OrientationHorizontal)
381 382
382 log.Debug().383 log.Debug().
383 Bool("was_start_child", isStartChild).384 Bool("was_start_child", isStartChild).
@@ -585,7 +586,7 @@ func (c *WorkspaceCoordinator) replaceRootSplit(
585 }586 }
586 587
587 wsView.SetRootWidgetDirect(splitView.Widget())588 wsView.SetRootWidgetDirect(splitView.Widget())
588- tr.RegisterWidget(output.ParentNode.ID, splitView.Widget())589+ tr.RegisterSplit(output.ParentNode.ID, splitView.Widget(), orientation)
589 590
590 logging.FromContext(ctx).Debug().Msg("root split: replaced root with new split view")591 logging.FromContext(ctx).Debug().Msg("root split: replaced root with new split view")
591 return nil592 return nil
@@ -649,7 +650,7 @@ func (c *WorkspaceCoordinator) replaceNonRootSplit(
649 panedWidget.SetEndChild(splitView.Widget())650 panedWidget.SetEndChild(splitView.Widget())
650 }651 }
651 652
652- tr.RegisterWidget(output.ParentNode.ID, splitView.Widget())653+ tr.RegisterSplit(output.ParentNode.ID, splitView.Widget(), orientation)
653 654
654 log.Debug().655 log.Debug().
655 Bool("was_start_child", isStartChild).656 Bool("was_start_child", isStartChild).
@@ -1860,3 +1861,59 @@ func (c *WorkspaceCoordinator) ShowToastOnActivePane(ctx context.Context, messag
1860 paneView.ShowToast(ctx, message, level)1861 paneView.ShowToast(ctx, message, level)
1861 }1862 }
1862 }1863 }
1864+
1865+// Resize updates the active split ratio and applies it to GTK widgets.
1866+func (c *WorkspaceCoordinator) Resize(ctx context.Context, dir usecase.ResizeDirection) error {
1867+ log := logging.FromContext(ctx)
1868+
1869+ if c.panesUC == nil {
1870+ log.Warn().Msg("panes use case not available")
1871+ return nil
1872+ }
1873+
1874+ ws, wsView := c.getActiveWS()
1875+ if ws == nil {
1876+ log.Warn().Msg("no active workspace")
1877+ return nil
1878+ }
1879+
1880+ target := ws.ActivePane()
1881+ if target == nil {
1882+ return nil
1883+ }
1884+
1885+ cfg := config.Get()
1886+ err := c.panesUC.Resize(ctx, ws, target, dir, cfg.Workspace.ResizeMode.StepPercent, cfg.Workspace.ResizeMode.MinPanePercent)
1887+ if errors.Is(err, usecase.ErrNothingToResize) {
1888+ c.ShowToastOnActivePane(ctx, "Nothing to resize", component.ToastInfo)
1889+ return nil
1890+ }
1891+ if err != nil {
1892+ return err
1893+ }
1894+
1895+ if wsView != nil {
1896+ c.updateSplitPositions(wsView, ws)
1897+ }
1898+
1899+ c.notifyStateChanged()
1900+ return nil
1901+}
1902+
1903+func (c *WorkspaceCoordinator) updateSplitPositions(wsView *component.WorkspaceView, ws *entity.Workspace) {
1904+ if wsView == nil || ws == nil || ws.Root == nil {
1905+ return
1906+ }
1907+
1908+ tr := wsView.TreeRenderer()
1909+ if tr == nil {
1910+ return
1911+ }
1912+
1913+ ws.Root.Walk(func(node *entity.PaneNode) bool {
1914+ if node.IsSplit() {
1915+ _ = tr.UpdateSplitRatio(node.ID, node.SplitRatio)
1916+ }
1917+ return true
1918+ })
1919+}
M internal/ui/dispatcher/keyboard.go +11 -0
@@ -126,6 +126,17 @@ func (d *KeyboardDispatcher) initActionHandlers() {
126126 input.ActionFocusLeft: func(ctx context.Context) error { return d.wsCoord.FocusPane(ctx, usecase.NavLeft) },
127127 input.ActionFocusUp: func(ctx context.Context) error { return d.wsCoord.FocusPane(ctx, usecase.NavUp) },
128128 input.ActionFocusDown: func(ctx context.Context) error { return d.wsCoord.FocusPane(ctx, usecase.NavDown) },
129+ // Resize actions
130+ input.ActionResizeIncreaseLeft: func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeIncreaseLeft) },
131+ input.ActionResizeIncreaseRight: func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeIncreaseRight) },
132+ input.ActionResizeIncreaseUp: func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeIncreaseUp) },
133+ input.ActionResizeIncreaseDown: func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeIncreaseDown) },
134+ input.ActionResizeDecreaseLeft: func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeDecreaseLeft) },
135+ input.ActionResizeDecreaseRight: func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeDecreaseRight) },
136+ input.ActionResizeDecreaseUp: func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeDecreaseUp) },
137+ input.ActionResizeDecreaseDown: func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeDecreaseDown) },
138+ input.ActionResizeIncrease: func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeIncrease) },
139+ input.ActionResizeDecrease: func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeDecrease) },
129140 // Stack navigation
130141 input.ActionStackNavUp: func(ctx context.Context) error { return d.wsCoord.NavigateStack(ctx, "up") },
131142 input.ActionStackNavDown: func(ctx context.Context) error { return d.wsCoord.NavigateStack(ctx, "down") },
@@ -126,6 +126,17 @@ func (d *KeyboardDispatcher) initActionHandlers() {
126 input.ActionFocusLeft: func(ctx context.Context) error { return d.wsCoord.FocusPane(ctx, usecase.NavLeft) },126 input.ActionFocusLeft: func(ctx context.Context) error { return d.wsCoord.FocusPane(ctx, usecase.NavLeft) },
127 input.ActionFocusUp: func(ctx context.Context) error { return d.wsCoord.FocusPane(ctx, usecase.NavUp) },127 input.ActionFocusUp: func(ctx context.Context) error { return d.wsCoord.FocusPane(ctx, usecase.NavUp) },
128 input.ActionFocusDown: func(ctx context.Context) error { return d.wsCoord.FocusPane(ctx, usecase.NavDown) },128 input.ActionFocusDown: func(ctx context.Context) error { return d.wsCoord.FocusPane(ctx, usecase.NavDown) },
129+ // Resize actions
130+ input.ActionResizeIncreaseLeft: func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeIncreaseLeft) },
131+ input.ActionResizeIncreaseRight: func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeIncreaseRight) },
132+ input.ActionResizeIncreaseUp: func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeIncreaseUp) },
133+ input.ActionResizeIncreaseDown: func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeIncreaseDown) },
134+ input.ActionResizeDecreaseLeft: func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeDecreaseLeft) },
135+ input.ActionResizeDecreaseRight: func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeDecreaseRight) },
136+ input.ActionResizeDecreaseUp: func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeDecreaseUp) },
137+ input.ActionResizeDecreaseDown: func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeDecreaseDown) },
138+ input.ActionResizeIncrease: func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeIncrease) },
139+ input.ActionResizeDecrease: func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeDecrease) },
129 // Stack navigation140 // Stack navigation
130 input.ActionStackNavUp: func(ctx context.Context) error { return d.wsCoord.NavigateStack(ctx, "up") },141 input.ActionStackNavUp: func(ctx context.Context) error { return d.wsCoord.NavigateStack(ctx, "up") },
131 input.ActionStackNavDown: func(ctx context.Context) error { return d.wsCoord.NavigateStack(ctx, "down") },142 input.ActionStackNavDown: func(ctx context.Context) error { return d.wsCoord.NavigateStack(ctx, "down") },
M internal/ui/input/keyboard.go +82 -35
@@ -9,6 +9,7 @@ import (
99 "github.com/bnema/dumber/internal/logging"
1010 "github.com/jwijenbergh/puregotk/v4/gdk"
1111 "github.com/jwijenbergh/puregotk/v4/gtk"
12+ "github.com/rs/zerolog"
1213 )
1314
1415 // ActionHandler is called when a keyboard action is triggered.
@@ -145,22 +146,7 @@ func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.Modifie
145146
146147 mode := h.modal.Mode()
147148
148- // Look up the action for this key binding
149- action, found := h.shortcuts.Lookup(binding, mode)
150-
151- // Fallback: check hardware keycode for Alt+number shortcuts on non-QWERTY layouts
152- // This enables Alt+1-9/0 to work on AZERTY, QWERTZ, and other Latin layouts
153- // where number keys produce different characters without Shift.
154- if !found && mode == ModeNormal && modifiers == ModAlt {
155- if keycodeAction, ok := KeycodeToTabAction[keycode]; ok {
156- action = keycodeAction
157- found = true
158- log.Debug().
159- Uint("keycode", keycode).
160- Str("action", string(action)).
161- Msg("tab switch via hardware keycode fallback (non-QWERTY layout)")
162- }
163- }
149+ action, found := h.lookupAction(log, binding, mode, modifiers, keycode)
164150
165151 if !found {
166152 if mode != ModeNormal {
@@ -171,25 +157,7 @@ func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.Modifie
171157 return false
172158 }
173159
174- // Handle special mode actions first
175- switch action {
176- case ActionEnterTabMode:
177- timeout := time.Duration(h.cfg.Workspace.TabMode.TimeoutMilliseconds) * time.Millisecond
178- h.modal.EnterTabMode(h.ctx, timeout)
179- return true
180-
181- case ActionEnterPaneMode:
182- timeout := time.Duration(h.cfg.Workspace.PaneMode.TimeoutMilliseconds) * time.Millisecond
183- h.modal.EnterPaneMode(h.ctx, timeout)
184- return true
185-
186- case ActionEnterSessionMode:
187- timeout := time.Duration(h.cfg.Session.SessionMode.TimeoutMilliseconds) * time.Millisecond
188- h.modal.EnterSessionMode(h.ctx, timeout)
189- return true
190-
191- case ActionExitMode:
192- h.modal.ExitMode(h.ctx)
160+ if h.handleModeAction(action) {
193161 return true
194162 }
195163
@@ -208,6 +176,11 @@ func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.Modifie
208176 }
209177 }
210178
179+ // In resize mode, any resize keypress should extend the timeout.
180+ if mode == ModeResize && isResizeAction(action) {
181+ h.modal.ResetTimeout(h.ctx)
182+ }
183+
211184 // Auto-exit modal mode after certain actions
212185 if mode != ModeNormal && ShouldAutoExitMode(action) {
213186 h.modal.ExitMode(h.ctx)
@@ -216,6 +189,80 @@ func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.Modifie
216189 return true // Consumed the key
217190 }
218191
192+func (h *KeyboardHandler) lookupAction(
193+ log *zerolog.Logger,
194+ binding KeyBinding,
195+ mode Mode,
196+ modifiers Modifier,
197+ keycode uint,
198+) (Action, bool) {
199+ action, found := h.shortcuts.Lookup(binding, mode)
200+
201+ // Fallback: check hardware keycode for Alt+number shortcuts on non-QWERTY layouts.
202+ if !found && mode == ModeNormal && modifiers == ModAlt {
203+ if keycodeAction, ok := KeycodeToTabAction[keycode]; ok {
204+ action = keycodeAction
205+ found = true
206+ if log != nil {
207+ log.Debug().
208+ Uint("keycode", keycode).
209+ Str("action", string(action)).
210+ Msg("tab switch via hardware keycode fallback (non-QWERTY layout)")
211+ }
212+ }
213+ }
214+
215+ return action, found
216+}
217+
218+func isResizeAction(action Action) bool {
219+ switch action {
220+ case ActionResizeIncreaseLeft,
221+ ActionResizeIncreaseRight,
222+ ActionResizeIncreaseUp,
223+ ActionResizeIncreaseDown,
224+ ActionResizeDecreaseLeft,
225+ ActionResizeDecreaseRight,
226+ ActionResizeDecreaseUp,
227+ ActionResizeDecreaseDown,
228+ ActionResizeIncrease,
229+ ActionResizeDecrease:
230+ return true
231+ default:
232+ return false
233+ }
234+}
235+
236+func (h *KeyboardHandler) handleModeAction(action Action) bool {
237+ switch action {
238+ case ActionEnterTabMode:
239+ timeout := time.Duration(h.cfg.Workspace.TabMode.TimeoutMilliseconds) * time.Millisecond
240+ h.modal.EnterTabMode(h.ctx, timeout)
241+ return true
242+ case ActionEnterPaneMode:
243+ timeout := time.Duration(h.cfg.Workspace.PaneMode.TimeoutMilliseconds) * time.Millisecond
244+ h.modal.EnterPaneMode(h.ctx, timeout)
245+ return true
246+ case ActionEnterSessionMode:
247+ timeout := time.Duration(h.cfg.Session.SessionMode.TimeoutMilliseconds) * time.Millisecond
248+ h.modal.EnterSessionMode(h.ctx, timeout)
249+ return true
250+ case ActionEnterResizeMode:
251+ if h.modal.Mode() == ModeResize {
252+ h.modal.ExitMode(h.ctx)
253+ return true
254+ }
255+ timeout := time.Duration(h.cfg.Workspace.ResizeMode.TimeoutMilliseconds) * time.Millisecond
256+ h.modal.EnterResizeMode(h.ctx, timeout)
257+ return true
258+ case ActionExitMode:
259+ h.modal.ExitMode(h.ctx)
260+ return true
261+ default:
262+ return false
263+ }
264+}
265+
219266 // EnterTabMode programmatically enters tab mode.
220267 // Useful for testing or programmatic mode changes.
221268 func (h *KeyboardHandler) EnterTabMode() {
@@ -9,6 +9,7 @@ import (
9 "github.com/bnema/dumber/internal/logging"9 "github.com/bnema/dumber/internal/logging"
10 "github.com/jwijenbergh/puregotk/v4/gdk"10 "github.com/jwijenbergh/puregotk/v4/gdk"
11 "github.com/jwijenbergh/puregotk/v4/gtk"11 "github.com/jwijenbergh/puregotk/v4/gtk"
12+ "github.com/rs/zerolog"
12 )13 )
13 14
14 // ActionHandler is called when a keyboard action is triggered.15 // ActionHandler is called when a keyboard action is triggered.
@@ -145,22 +146,7 @@ func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.Modifie
145 146
146 mode := h.modal.Mode()147 mode := h.modal.Mode()
147 148
148- // Look up the action for this key binding149+ action, found := h.lookupAction(log, binding, mode, modifiers, keycode)
149- action, found := h.shortcuts.Lookup(binding, mode)
150-
151- // Fallback: check hardware keycode for Alt+number shortcuts on non-QWERTY layouts
152- // This enables Alt+1-9/0 to work on AZERTY, QWERTZ, and other Latin layouts
153- // where number keys produce different characters without Shift.
154- if !found && mode == ModeNormal && modifiers == ModAlt {
155- if keycodeAction, ok := KeycodeToTabAction[keycode]; ok {
156- action = keycodeAction
157- found = true
158- log.Debug().
159- Uint("keycode", keycode).
160- Str("action", string(action)).
161- Msg("tab switch via hardware keycode fallback (non-QWERTY layout)")
162- }
163- }
164 150
165 if !found {151 if !found {
166 if mode != ModeNormal {152 if mode != ModeNormal {
@@ -171,25 +157,7 @@ func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.Modifie
171 return false157 return false
172 }158 }
173 159
174- // Handle special mode actions first160+ if h.handleModeAction(action) {
175- switch action {
176- case ActionEnterTabMode:
177- timeout := time.Duration(h.cfg.Workspace.TabMode.TimeoutMilliseconds) * time.Millisecond
178- h.modal.EnterTabMode(h.ctx, timeout)
179- return true
180-
181- case ActionEnterPaneMode:
182- timeout := time.Duration(h.cfg.Workspace.PaneMode.TimeoutMilliseconds) * time.Millisecond
183- h.modal.EnterPaneMode(h.ctx, timeout)
184- return true
185-
186- case ActionEnterSessionMode:
187- timeout := time.Duration(h.cfg.Session.SessionMode.TimeoutMilliseconds) * time.Millisecond
188- h.modal.EnterSessionMode(h.ctx, timeout)
189- return true
190-
191- case ActionExitMode:
192- h.modal.ExitMode(h.ctx)
193 return true161 return true
194 }162 }
195 163
@@ -208,6 +176,11 @@ func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.Modifie
208 }176 }
209 }177 }
210 178
179+ // In resize mode, any resize keypress should extend the timeout.
180+ if mode == ModeResize && isResizeAction(action) {
181+ h.modal.ResetTimeout(h.ctx)
182+ }
183+
211 // Auto-exit modal mode after certain actions184 // Auto-exit modal mode after certain actions
212 if mode != ModeNormal && ShouldAutoExitMode(action) {185 if mode != ModeNormal && ShouldAutoExitMode(action) {
213 h.modal.ExitMode(h.ctx)186 h.modal.ExitMode(h.ctx)
@@ -216,6 +189,80 @@ func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.Modifie
216 return true // Consumed the key189 return true // Consumed the key
217 }190 }
218 191
192+func (h *KeyboardHandler) lookupAction(
193+ log *zerolog.Logger,
194+ binding KeyBinding,
195+ mode Mode,
196+ modifiers Modifier,
197+ keycode uint,
198+) (Action, bool) {
199+ action, found := h.shortcuts.Lookup(binding, mode)
200+
201+ // Fallback: check hardware keycode for Alt+number shortcuts on non-QWERTY layouts.
202+ if !found && mode == ModeNormal && modifiers == ModAlt {
203+ if keycodeAction, ok := KeycodeToTabAction[keycode]; ok {
204+ action = keycodeAction
205+ found = true
206+ if log != nil {
207+ log.Debug().
208+ Uint("keycode", keycode).
209+ Str("action", string(action)).
210+ Msg("tab switch via hardware keycode fallback (non-QWERTY layout)")
211+ }
212+ }
213+ }
214+
215+ return action, found
216+}
217+
218+func isResizeAction(action Action) bool {
219+ switch action {
220+ case ActionResizeIncreaseLeft,
221+ ActionResizeIncreaseRight,
222+ ActionResizeIncreaseUp,
223+ ActionResizeIncreaseDown,
224+ ActionResizeDecreaseLeft,
225+ ActionResizeDecreaseRight,
226+ ActionResizeDecreaseUp,
227+ ActionResizeDecreaseDown,
228+ ActionResizeIncrease,
229+ ActionResizeDecrease:
230+ return true
231+ default:
232+ return false
233+ }
234+}
235+
236+func (h *KeyboardHandler) handleModeAction(action Action) bool {
237+ switch action {
238+ case ActionEnterTabMode:
239+ timeout := time.Duration(h.cfg.Workspace.TabMode.TimeoutMilliseconds) * time.Millisecond
240+ h.modal.EnterTabMode(h.ctx, timeout)
241+ return true
242+ case ActionEnterPaneMode:
243+ timeout := time.Duration(h.cfg.Workspace.PaneMode.TimeoutMilliseconds) * time.Millisecond
244+ h.modal.EnterPaneMode(h.ctx, timeout)
245+ return true
246+ case ActionEnterSessionMode:
247+ timeout := time.Duration(h.cfg.Session.SessionMode.TimeoutMilliseconds) * time.Millisecond
248+ h.modal.EnterSessionMode(h.ctx, timeout)
249+ return true
250+ case ActionEnterResizeMode:
251+ if h.modal.Mode() == ModeResize {
252+ h.modal.ExitMode(h.ctx)
253+ return true
254+ }
255+ timeout := time.Duration(h.cfg.Workspace.ResizeMode.TimeoutMilliseconds) * time.Millisecond
256+ h.modal.EnterResizeMode(h.ctx, timeout)
257+ return true
258+ case ActionExitMode:
259+ h.modal.ExitMode(h.ctx)
260+ return true
261+ default:
262+ return false
263+ }
264+}
265+
219 // EnterTabMode programmatically enters tab mode.266 // EnterTabMode programmatically enters tab mode.
220 // Useful for testing or programmatic mode changes.267 // Useful for testing or programmatic mode changes.
221 func (h *KeyboardHandler) EnterTabMode() {268 func (h *KeyboardHandler) EnterTabMode() {
M internal/ui/input/modal.go +39 -0
@@ -20,6 +20,8 @@ const (
2020 ModePane
2121 // ModeSession is the modal session management mode.
2222 ModeSession
23+ // ModeResize is the modal pane resizing mode.
24+ ModeResize
2325 )
2426
2527 // String returns a human-readable mode name.
@@ -33,6 +35,8 @@ func (m Mode) String() string {
3335 return "pane"
3436 case ModeSession:
3537 return "session"
38+ case ModeResize:
39+ return "resize"
3640 default:
3741 return "unknown"
3842 }
@@ -171,6 +175,41 @@ func (m *ModalState) EnterSessionMode(ctx context.Context, timeout time.Duration
171175 }
172176 }
173177
178+// EnterResizeMode switches to resize mode with an optional timeout.
179+// If timeout is 0, the mode stays until explicitly exited.
180+func (m *ModalState) EnterResizeMode(ctx context.Context, timeout time.Duration) {
181+ log := logging.FromContext(ctx)
182+ m.mu.Lock()
183+ defer m.mu.Unlock()
184+
185+ if m.mode == ModeResize {
186+ // Already in resize mode, just reset timeout
187+ m.resetTimeoutLocked(ctx, timeout)
188+ return
189+ }
190+
191+ m.cancelTimerLocked()
192+ oldMode := m.mode
193+ m.mode = ModeResize
194+ m.timeout = timeout
195+
196+ if timeout > 0 {
197+ m.timer = time.AfterFunc(timeout, func() {
198+ m.ExitMode(ctx)
199+ })
200+ }
201+
202+ log.Debug().
203+ Str("from", oldMode.String()).
204+ Str("to", m.mode.String()).
205+ Dur("timeout", timeout).
206+ Msg("entered resize mode")
207+
208+ if m.onModeChange != nil {
209+ m.onModeChange(oldMode, m.mode)
210+ }
211+}
212+
174213 // ExitMode returns to normal mode.
175214 func (m *ModalState) ExitMode(ctx context.Context) {
176215 log := logging.FromContext(ctx)
@@ -20,6 +20,8 @@ const (
20 ModePane20 ModePane
21 // ModeSession is the modal session management mode.21 // ModeSession is the modal session management mode.
22 ModeSession22 ModeSession
23+ // ModeResize is the modal pane resizing mode.
24+ ModeResize
23 )25 )
24 26
25 // String returns a human-readable mode name.27 // String returns a human-readable mode name.
@@ -33,6 +35,8 @@ func (m Mode) String() string {
33 return "pane"35 return "pane"
34 case ModeSession:36 case ModeSession:
35 return "session"37 return "session"
38+ case ModeResize:
39+ return "resize"
36 default:40 default:
37 return "unknown"41 return "unknown"
38 }42 }
@@ -171,6 +175,41 @@ func (m *ModalState) EnterSessionMode(ctx context.Context, timeout time.Duration
171 }175 }
172 }176 }
173 177
178+// EnterResizeMode switches to resize mode with an optional timeout.
179+// If timeout is 0, the mode stays until explicitly exited.
180+func (m *ModalState) EnterResizeMode(ctx context.Context, timeout time.Duration) {
181+ log := logging.FromContext(ctx)
182+ m.mu.Lock()
183+ defer m.mu.Unlock()
184+
185+ if m.mode == ModeResize {
186+ // Already in resize mode, just reset timeout
187+ m.resetTimeoutLocked(ctx, timeout)
188+ return
189+ }
190+
191+ m.cancelTimerLocked()
192+ oldMode := m.mode
193+ m.mode = ModeResize
194+ m.timeout = timeout
195+
196+ if timeout > 0 {
197+ m.timer = time.AfterFunc(timeout, func() {
198+ m.ExitMode(ctx)
199+ })
200+ }
201+
202+ log.Debug().
203+ Str("from", oldMode.String()).
204+ Str("to", m.mode.String()).
205+ Dur("timeout", timeout).
206+ Msg("entered resize mode")
207+
208+ if m.onModeChange != nil {
209+ m.onModeChange(oldMode, m.mode)
210+ }
211+}
212+
174 // ExitMode returns to normal mode.213 // ExitMode returns to normal mode.
175 func (m *ModalState) ExitMode(ctx context.Context) {214 func (m *ModalState) ExitMode(ctx context.Context) {
176 log := logging.FromContext(ctx)215 log := logging.FromContext(ctx)
M internal/ui/input/shortcuts.go +74 -45
@@ -96,6 +96,7 @@ const (
9696 ActionEnterTabMode Action = "enter_tab_mode"
9797 ActionEnterPaneMode Action = "enter_pane_mode"
9898 ActionEnterSessionMode Action = "enter_session_mode"
99+ ActionEnterResizeMode Action = "enter_resize_mode"
99100 ActionExitMode Action = "exit_mode"
100101
101102 // Tab actions (global and modal)
@@ -130,6 +131,18 @@ const (
130131 ActionFocusUp Action = "focus_up"
131132 ActionFocusDown Action = "focus_down"
132133
134+ // Resize actions (modal)
135+ ActionResizeIncreaseLeft Action = "resize_increase_left"
136+ ActionResizeIncreaseRight Action = "resize_increase_right"
137+ ActionResizeIncreaseUp Action = "resize_increase_up"
138+ ActionResizeIncreaseDown Action = "resize_increase_down"
139+ ActionResizeDecreaseLeft Action = "resize_decrease_left"
140+ ActionResizeDecreaseRight Action = "resize_decrease_right"
141+ ActionResizeDecreaseUp Action = "resize_decrease_up"
142+ ActionResizeDecreaseDown Action = "resize_decrease_down"
143+ ActionResizeIncrease Action = "resize_increase"
144+ ActionResizeDecrease Action = "resize_decrease"
145+
133146 // Stack navigation (within stacked panes)
134147 ActionStackNavUp Action = "stack_nav_up"
135148 ActionStackNavDown Action = "stack_nav_down"
@@ -178,6 +191,8 @@ type ShortcutSet struct {
178191 PaneMode ShortcutTable
179192 // SessionMode shortcuts are only active in session mode.
180193 SessionMode ShortcutTable
194+ // ResizeMode shortcuts are only active in resize mode.
195+ ResizeMode ShortcutTable
181196 }
182197
183198 // NewShortcutSet creates a ShortcutSet from the configuration.
@@ -188,17 +203,20 @@ func NewShortcutSet(ctx context.Context, cfg *config.Config) *ShortcutSet {
188203 TabMode: make(ShortcutTable),
189204 PaneMode: make(ShortcutTable),
190205 SessionMode: make(ShortcutTable),
206+ ResizeMode: make(ShortcutTable),
191207 }
192208
193209 set.buildGlobalShortcuts(ctx, cfg)
194210 set.buildTabModeShortcuts(ctx, &cfg.Workspace)
195211 set.buildPaneModeShortcuts(ctx, &cfg.Workspace)
212+ set.buildResizeModeShortcuts(ctx, &cfg.Workspace)
196213 set.buildSessionModeShortcuts(ctx, &cfg.Session)
197214
198215 log.Debug().
199216 Int("global", len(set.Global)).
200217 Int("tab", len(set.TabMode)).
201218 Int("pane", len(set.PaneMode)).
219+ Int("resize", len(set.ResizeMode)).
202220 Int("session", len(set.SessionMode)).
203221 Msg("shortcuts registered")
204222
@@ -229,6 +247,11 @@ func (s *ShortcutSet) buildSessionModeShortcuts(ctx context.Context, cfg *config
229247 s.buildModeShortcuts(ctx, cfg.SessionMode.GetKeyBindings(), s.SessionMode, "session")
230248 }
231249
250+// buildResizeModeShortcuts populates resize mode shortcuts from config.
251+func (s *ShortcutSet) buildResizeModeShortcuts(ctx context.Context, cfg *config.WorkspaceConfig) {
252+ s.buildModeShortcuts(ctx, cfg.ResizeMode.GetKeyBindings(), s.ResizeMode, "resize")
253+}
254+
232255 func (s *ShortcutSet) registerActivationShortcuts(ctx context.Context, cfg *config.Config) {
233256 log := logging.FromContext(ctx)
234257 if binding, ok := ParseKeyString(cfg.Workspace.TabMode.ActivationShortcut); ok {
@@ -261,6 +284,16 @@ func (s *ShortcutSet) registerActivationShortcuts(ctx context.Context, cfg *conf
261284 } else {
262285 log.Warn().Str("shortcut", cfg.Session.SessionMode.ActivationShortcut).Msg("failed to parse session mode activation shortcut")
263286 }
287+ if binding, ok := ParseKeyString(cfg.Workspace.ResizeMode.ActivationShortcut); ok {
288+ s.Global[binding] = ActionEnterResizeMode
289+ log.Trace().
290+ Str("shortcut", cfg.Workspace.ResizeMode.ActivationShortcut).
291+ Uint("keyval", binding.Keyval).
292+ Uint("mod", uint(binding.Modifiers)).
293+ Msg("resize mode activation registered")
294+ } else {
295+ log.Warn().Str("shortcut", cfg.Workspace.ResizeMode.ActivationShortcut).Msg("failed to parse resize mode activation shortcut")
296+ }
264297 }
265298
266299 func (s *ShortcutSet) registerConfiguredShortcuts(cfg *config.WorkspaceConfig) {
@@ -360,62 +393,56 @@ func (s *ShortcutSet) buildModeShortcuts(ctx context.Context, bindings map[strin
360393 Msg(mode + " mode shortcuts built")
361394 }
362395
363-// mapConfigAction maps config action names to Action constants.
364-func mapConfigAction(configAction string) Action {
365- switch configAction {
366- // Mode management
367- case "cancel", "confirm":
368- return ActionExitMode
369-
396+var configActionToAction = map[string]Action{
370397 // Tab actions
371- case "new-tab":
372- return ActionNewTab
373- case "close-tab":
374- return ActionCloseTab
375- case "next-tab":
376- return ActionNextTab
377- case "previous-tab":
378- return ActionPreviousTab
379- case "rename-tab":
380- return ActionRenameTab
398+ "new-tab": ActionNewTab,
399+ "close-tab": ActionCloseTab,
400+ "next-tab": ActionNextTab,
401+ "previous-tab": ActionPreviousTab,
402+ "rename-tab": ActionRenameTab,
381403
382404 // Pane actions
383- case "split-right":
384- return ActionSplitRight
385- case "split-left":
386- return ActionSplitLeft
387- case "split-up":
388- return ActionSplitUp
389- case "split-down":
390- return ActionSplitDown
391- case "close-pane":
392- return ActionClosePane
393- case "stack-pane":
394- return ActionStackPane
405+ "split-right": ActionSplitRight,
406+ "split-left": ActionSplitLeft,
407+ "split-up": ActionSplitUp,
408+ "split-down": ActionSplitDown,
409+ "close-pane": ActionClosePane,
410+ "stack-pane": ActionStackPane,
395411
396412 // Focus navigation
397- case "focus-right":
398- return ActionFocusRight
399- case "focus-left":
400- return ActionFocusLeft
401- case "focus-up":
402- return ActionFocusUp
403- case "focus-down":
404- return ActionFocusDown
413+ "focus-right": ActionFocusRight,
414+ "focus-left": ActionFocusLeft,
415+ "focus-up": ActionFocusUp,
416+ "focus-down": ActionFocusDown,
405417
406418 // Stack navigation
407- case "stack-nav-up", "stack-up":
408- return ActionStackNavUp
409- case "stack-nav-down", "stack-down":
410- return ActionStackNavDown
419+ "stack-nav-up": ActionStackNavUp,
420+ "stack-up": ActionStackNavUp,
421+ "stack-nav-down": ActionStackNavDown,
422+ "stack-down": ActionStackNavDown,
423+
424+ // Resize actions
425+ "resize-increase-left": ActionResizeIncreaseLeft,
426+ "resize-increase-right": ActionResizeIncreaseRight,
427+ "resize-increase-up": ActionResizeIncreaseUp,
428+ "resize-increase-down": ActionResizeIncreaseDown,
429+ "resize-decrease-left": ActionResizeDecreaseLeft,
430+ "resize-decrease-right": ActionResizeDecreaseRight,
431+ "resize-decrease-up": ActionResizeDecreaseUp,
432+ "resize-decrease-down": ActionResizeDecreaseDown,
433+ "resize-increase": ActionResizeIncrease,
434+ "resize-decrease": ActionResizeDecrease,
411435
412436 // Session actions
413- case "session-manager":
414- return ActionOpenSessionManager
437+ "session-manager": ActionOpenSessionManager,
438+}
415439
416- default:
417- return ""
440+// mapConfigAction maps config action names to Action constants.
441+func mapConfigAction(configAction string) Action {
442+ if configAction == "cancel" || configAction == "confirm" {
443+ return ActionExitMode
418444 }
445+ return configActionToAction[configAction]
419446 }
420447
421448 // ParseKeyString converts a config key string like "ctrl+t" to a KeyBinding.
@@ -488,6 +515,8 @@ func (s *ShortcutSet) Lookup(binding KeyBinding, mode Mode) (Action, bool) {
488515 modeTable = s.TabMode
489516 case ModePane:
490517 modeTable = s.PaneMode
518+ case ModeResize:
519+ modeTable = s.ResizeMode
491520 case ModeSession:
492521 modeTable = s.SessionMode
493522 }
@@ -96,6 +96,7 @@ const (
96 ActionEnterTabMode Action = "enter_tab_mode"96 ActionEnterTabMode Action = "enter_tab_mode"
97 ActionEnterPaneMode Action = "enter_pane_mode"97 ActionEnterPaneMode Action = "enter_pane_mode"
98 ActionEnterSessionMode Action = "enter_session_mode"98 ActionEnterSessionMode Action = "enter_session_mode"
99+ ActionEnterResizeMode Action = "enter_resize_mode"
99 ActionExitMode Action = "exit_mode"100 ActionExitMode Action = "exit_mode"
100 101
101 // Tab actions (global and modal)102 // Tab actions (global and modal)
@@ -130,6 +131,18 @@ const (
130 ActionFocusUp Action = "focus_up"131 ActionFocusUp Action = "focus_up"
131 ActionFocusDown Action = "focus_down"132 ActionFocusDown Action = "focus_down"
132 133
134+ // Resize actions (modal)
135+ ActionResizeIncreaseLeft Action = "resize_increase_left"
136+ ActionResizeIncreaseRight Action = "resize_increase_right"
137+ ActionResizeIncreaseUp Action = "resize_increase_up"
138+ ActionResizeIncreaseDown Action = "resize_increase_down"
139+ ActionResizeDecreaseLeft Action = "resize_decrease_left"
140+ ActionResizeDecreaseRight Action = "resize_decrease_right"
141+ ActionResizeDecreaseUp Action = "resize_decrease_up"
142+ ActionResizeDecreaseDown Action = "resize_decrease_down"
143+ ActionResizeIncrease Action = "resize_increase"
144+ ActionResizeDecrease Action = "resize_decrease"
145+
133 // Stack navigation (within stacked panes)146 // Stack navigation (within stacked panes)
134 ActionStackNavUp Action = "stack_nav_up"147 ActionStackNavUp Action = "stack_nav_up"
135 ActionStackNavDown Action = "stack_nav_down"148 ActionStackNavDown Action = "stack_nav_down"
@@ -178,6 +191,8 @@ type ShortcutSet struct {
178 PaneMode ShortcutTable191 PaneMode ShortcutTable
179 // SessionMode shortcuts are only active in session mode.192 // SessionMode shortcuts are only active in session mode.
180 SessionMode ShortcutTable193 SessionMode ShortcutTable
194+ // ResizeMode shortcuts are only active in resize mode.
195+ ResizeMode ShortcutTable
181 }196 }
182 197
183 // NewShortcutSet creates a ShortcutSet from the configuration.198 // NewShortcutSet creates a ShortcutSet from the configuration.
@@ -188,17 +203,20 @@ func NewShortcutSet(ctx context.Context, cfg *config.Config) *ShortcutSet {
188 TabMode: make(ShortcutTable),203 TabMode: make(ShortcutTable),
189 PaneMode: make(ShortcutTable),204 PaneMode: make(ShortcutTable),
190 SessionMode: make(ShortcutTable),205 SessionMode: make(ShortcutTable),
206+ ResizeMode: make(ShortcutTable),
191 }207 }
192 208
193 set.buildGlobalShortcuts(ctx, cfg)209 set.buildGlobalShortcuts(ctx, cfg)
194 set.buildTabModeShortcuts(ctx, &cfg.Workspace)210 set.buildTabModeShortcuts(ctx, &cfg.Workspace)
195 set.buildPaneModeShortcuts(ctx, &cfg.Workspace)211 set.buildPaneModeShortcuts(ctx, &cfg.Workspace)
212+ set.buildResizeModeShortcuts(ctx, &cfg.Workspace)
196 set.buildSessionModeShortcuts(ctx, &cfg.Session)213 set.buildSessionModeShortcuts(ctx, &cfg.Session)
197 214
198 log.Debug().215 log.Debug().
199 Int("global", len(set.Global)).216 Int("global", len(set.Global)).
200 Int("tab", len(set.TabMode)).217 Int("tab", len(set.TabMode)).
201 Int("pane", len(set.PaneMode)).218 Int("pane", len(set.PaneMode)).
219+ Int("resize", len(set.ResizeMode)).
202 Int("session", len(set.SessionMode)).220 Int("session", len(set.SessionMode)).
203 Msg("shortcuts registered")221 Msg("shortcuts registered")
204 222
@@ -229,6 +247,11 @@ func (s *ShortcutSet) buildSessionModeShortcuts(ctx context.Context, cfg *config
229 s.buildModeShortcuts(ctx, cfg.SessionMode.GetKeyBindings(), s.SessionMode, "session")247 s.buildModeShortcuts(ctx, cfg.SessionMode.GetKeyBindings(), s.SessionMode, "session")
230 }248 }
231 249
250+// buildResizeModeShortcuts populates resize mode shortcuts from config.
251+func (s *ShortcutSet) buildResizeModeShortcuts(ctx context.Context, cfg *config.WorkspaceConfig) {
252+ s.buildModeShortcuts(ctx, cfg.ResizeMode.GetKeyBindings(), s.ResizeMode, "resize")
253+}
254+
232 func (s *ShortcutSet) registerActivationShortcuts(ctx context.Context, cfg *config.Config) {255 func (s *ShortcutSet) registerActivationShortcuts(ctx context.Context, cfg *config.Config) {
233 log := logging.FromContext(ctx)256 log := logging.FromContext(ctx)
234 if binding, ok := ParseKeyString(cfg.Workspace.TabMode.ActivationShortcut); ok {257 if binding, ok := ParseKeyString(cfg.Workspace.TabMode.ActivationShortcut); ok {
@@ -261,6 +284,16 @@ func (s *ShortcutSet) registerActivationShortcuts(ctx context.Context, cfg *conf
261 } else {284 } else {
262 log.Warn().Str("shortcut", cfg.Session.SessionMode.ActivationShortcut).Msg("failed to parse session mode activation shortcut")285 log.Warn().Str("shortcut", cfg.Session.SessionMode.ActivationShortcut).Msg("failed to parse session mode activation shortcut")
263 }286 }
287+ if binding, ok := ParseKeyString(cfg.Workspace.ResizeMode.ActivationShortcut); ok {
288+ s.Global[binding] = ActionEnterResizeMode
289+ log.Trace().
290+ Str("shortcut", cfg.Workspace.ResizeMode.ActivationShortcut).
291+ Uint("keyval", binding.Keyval).
292+ Uint("mod", uint(binding.Modifiers)).
293+ Msg("resize mode activation registered")
294+ } else {
295+ log.Warn().Str("shortcut", cfg.Workspace.ResizeMode.ActivationShortcut).Msg("failed to parse resize mode activation shortcut")
296+ }
264 }297 }
265 298
266 func (s *ShortcutSet) registerConfiguredShortcuts(cfg *config.WorkspaceConfig) {299 func (s *ShortcutSet) registerConfiguredShortcuts(cfg *config.WorkspaceConfig) {
@@ -360,62 +393,56 @@ func (s *ShortcutSet) buildModeShortcuts(ctx context.Context, bindings map[strin
360 Msg(mode + " mode shortcuts built")393 Msg(mode + " mode shortcuts built")
361 }394 }
362 395
363-// mapConfigAction maps config action names to Action constants.396+var configActionToAction = map[string]Action{
364-func mapConfigAction(configAction string) Action {
365- switch configAction {
366- // Mode management
367- case "cancel", "confirm":
368- return ActionExitMode
369-
370 // Tab actions397 // Tab actions
371- case "new-tab":398+ "new-tab": ActionNewTab,
372- return ActionNewTab399+ "close-tab": ActionCloseTab,
373- case "close-tab":400+ "next-tab": ActionNextTab,
374- return ActionCloseTab401+ "previous-tab": ActionPreviousTab,
375- case "next-tab":402+ "rename-tab": ActionRenameTab,
376- return ActionNextTab
377- case "previous-tab":
378- return ActionPreviousTab
379- case "rename-tab":
380- return ActionRenameTab
381 403
382 // Pane actions404 // Pane actions
383- case "split-right":405+ "split-right": ActionSplitRight,
384- return ActionSplitRight406+ "split-left": ActionSplitLeft,
385- case "split-left":407+ "split-up": ActionSplitUp,
386- return ActionSplitLeft408+ "split-down": ActionSplitDown,
387- case "split-up":409+ "close-pane": ActionClosePane,
388- return ActionSplitUp410+ "stack-pane": ActionStackPane,
389- case "split-down":
390- return ActionSplitDown
391- case "close-pane":
392- return ActionClosePane
393- case "stack-pane":
394- return ActionStackPane
395 411
396 // Focus navigation412 // Focus navigation
397- case "focus-right":413+ "focus-right": ActionFocusRight,
398- return ActionFocusRight414+ "focus-left": ActionFocusLeft,
399- case "focus-left":415+ "focus-up": ActionFocusUp,
400- return ActionFocusLeft416+ "focus-down": ActionFocusDown,
401- case "focus-up":
402- return ActionFocusUp
403- case "focus-down":
404- return ActionFocusDown
405 417
406 // Stack navigation418 // Stack navigation
407- case "stack-nav-up", "stack-up":419+ "stack-nav-up": ActionStackNavUp,
408- return ActionStackNavUp420+ "stack-up": ActionStackNavUp,
409- case "stack-nav-down", "stack-down":421+ "stack-nav-down": ActionStackNavDown,
410- return ActionStackNavDown422+ "stack-down": ActionStackNavDown,
423+
424+ // Resize actions
425+ "resize-increase-left": ActionResizeIncreaseLeft,
426+ "resize-increase-right": ActionResizeIncreaseRight,
427+ "resize-increase-up": ActionResizeIncreaseUp,
428+ "resize-increase-down": ActionResizeIncreaseDown,
429+ "resize-decrease-left": ActionResizeDecreaseLeft,
430+ "resize-decrease-right": ActionResizeDecreaseRight,
431+ "resize-decrease-up": ActionResizeDecreaseUp,
432+ "resize-decrease-down": ActionResizeDecreaseDown,
433+ "resize-increase": ActionResizeIncrease,
434+ "resize-decrease": ActionResizeDecrease,
411 435
412 // Session actions436 // Session actions
413- case "session-manager":437+ "session-manager": ActionOpenSessionManager,
414- return ActionOpenSessionManager438+}
415 439
416- default:440+// mapConfigAction maps config action names to Action constants.
417- return ""441+func mapConfigAction(configAction string) Action {
442+ if configAction == "cancel" || configAction == "confirm" {
443+ return ActionExitMode
418 }444 }
445+ return configActionToAction[configAction]
419 }446 }
420 447
421 // ParseKeyString converts a config key string like "ctrl+t" to a KeyBinding.448 // ParseKeyString converts a config key string like "ctrl+t" to a KeyBinding.
@@ -488,6 +515,8 @@ func (s *ShortcutSet) Lookup(binding KeyBinding, mode Mode) (Action, bool) {
488 modeTable = s.TabMode515 modeTable = s.TabMode
489 case ModePane:516 case ModePane:
490 modeTable = s.PaneMode517 modeTable = s.PaneMode
518+ case ModeResize:
519+ modeTable = s.ResizeMode
491 case ModeSession:520 case ModeSession:
492 modeTable = s.SessionMode521 modeTable = s.SessionMode
493 }522 }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close