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 | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "errors" | |
| 5 | 6 | "fmt" | |
| 6 | 7 | ||
| 7 | 8 | "github.com/bnema/dumber/internal/application/usecase" | |
| @@ -326,7 +327,7 @@ func (c *WorkspaceCoordinator) doIncrementalStackSplit( | |||
| 326 | 327 | wsView.SetRootWidgetDirect(splitView.Widget()) | |
| 327 | 328 | ||
| 328 | 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 | 332 | } else { | |
| 332 | 333 | // Non-root split: stack is inside another split, need to replace in grandparent | |
| @@ -377,7 +378,7 @@ func (c *WorkspaceCoordinator) doIncrementalStackSplit( | |||
| 377 | 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 | 383 | log.Debug(). | |
| 383 | 384 | Bool("was_start_child", isStartChild). | |
| @@ -585,7 +586,7 @@ func (c *WorkspaceCoordinator) replaceRootSplit( | |||
| 585 | 586 | } | |
| 586 | 587 | ||
| 587 | 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 | 591 | logging.FromContext(ctx).Debug().Msg("root split: replaced root with new split view") | |
| 591 | 592 | return nil | |
| @@ -649,7 +650,7 @@ func (c *WorkspaceCoordinator) replaceNonRootSplit( | |||
| 649 | 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 | 655 | log.Debug(). | |
| 655 | 656 | Bool("was_start_child", isStartChild). | |
| @@ -1860,3 +1861,59 @@ func (c *WorkspaceCoordinator) ShowToastOnActivePane(ctx context.Context, messag | |||
| 1860 | 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 | + | } | |
| @@ -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 grandparent | 333 | // 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 nil | 592 | 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() { | |||
| 126 | 126 | input.ActionFocusLeft: func(ctx context.Context) error { return d.wsCoord.FocusPane(ctx, usecase.NavLeft) }, | |
| 127 | 127 | input.ActionFocusUp: func(ctx context.Context) error { return d.wsCoord.FocusPane(ctx, usecase.NavUp) }, | |
| 128 | 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 | 140 | // Stack navigation | |
| 130 | 141 | input.ActionStackNavUp: func(ctx context.Context) error { return d.wsCoord.NavigateStack(ctx, "up") }, | |
| 131 | 142 | 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 navigation | 140 | // 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 ( | |||
| 9 | 9 | "github.com/bnema/dumber/internal/logging" | |
| 10 | 10 | "github.com/jwijenbergh/puregotk/v4/gdk" | |
| 11 | 11 | "github.com/jwijenbergh/puregotk/v4/gtk" | |
| 12 | + | "github.com/rs/zerolog" | |
| 12 | 13 | ) | |
| 13 | 14 | ||
| 14 | 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 | 147 | mode := h.modal.Mode() | |
| 147 | 148 | ||
| 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) | |
| 164 | 150 | ||
| 165 | 151 | if !found { | |
| 166 | 152 | if mode != ModeNormal { | |
| @@ -171,25 +157,7 @@ func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.Modifie | |||
| 171 | 157 | return false | |
| 172 | 158 | } | |
| 173 | 159 | ||
| 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) { | |
| 193 | 161 | 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 | 184 | // Auto-exit modal mode after certain actions | |
| 212 | 185 | if mode != ModeNormal && ShouldAutoExitMode(action) { | |
| 213 | 186 | h.modal.ExitMode(h.ctx) | |
| @@ -216,6 +189,80 @@ func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.Modifie | |||
| 216 | 189 | 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 | 266 | // EnterTabMode programmatically enters tab mode. | |
| 220 | 267 | // Useful for testing or programmatic mode changes. | |
| 221 | 268 | 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 binding | 149 | + | 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 false | 157 | return false | ||
| 172 | } | 158 | } | ||
| 173 | 159 | ||||
| 174 | - | // Handle special mode actions first | 160 | + | 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 true | 161 | 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 actions | 184 | // 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 key | 189 | 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 ( | |||
| 20 | 20 | ModePane | |
| 21 | 21 | // ModeSession is the modal session management mode. | |
| 22 | 22 | ModeSession | |
| 23 | + | // ModeResize is the modal pane resizing mode. | |
| 24 | + | ModeResize | |
| 23 | 25 | ) | |
| 24 | 26 | ||
| 25 | 27 | // String returns a human-readable mode name. | |
| @@ -33,6 +35,8 @@ func (m Mode) String() string { | |||
| 33 | 35 | return "pane" | |
| 34 | 36 | case ModeSession: | |
| 35 | 37 | return "session" | |
| 38 | + | case ModeResize: | |
| 39 | + | return "resize" | |
| 36 | 40 | default: | |
| 37 | 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 | 213 | // ExitMode returns to normal mode. | |
| 175 | 214 | func (m *ModalState) ExitMode(ctx context.Context) { | |
| 176 | 215 | log := logging.FromContext(ctx) | |
| @@ -20,6 +20,8 @@ const ( | |||||
| 20 | ModePane | 20 | ModePane | ||
| 21 | // ModeSession is the modal session management mode. | 21 | // ModeSession is the modal session management mode. | ||
| 22 | ModeSession | 22 | 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 ( | |||
| 96 | 96 | ActionEnterTabMode Action = "enter_tab_mode" | |
| 97 | 97 | ActionEnterPaneMode Action = "enter_pane_mode" | |
| 98 | 98 | ActionEnterSessionMode Action = "enter_session_mode" | |
| 99 | + | ActionEnterResizeMode Action = "enter_resize_mode" | |
| 99 | 100 | ActionExitMode Action = "exit_mode" | |
| 100 | 101 | ||
| 101 | 102 | // Tab actions (global and modal) | |
| @@ -130,6 +131,18 @@ const ( | |||
| 130 | 131 | ActionFocusUp Action = "focus_up" | |
| 131 | 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 | 146 | // Stack navigation (within stacked panes) | |
| 134 | 147 | ActionStackNavUp Action = "stack_nav_up" | |
| 135 | 148 | ActionStackNavDown Action = "stack_nav_down" | |
| @@ -178,6 +191,8 @@ type ShortcutSet struct { | |||
| 178 | 191 | PaneMode ShortcutTable | |
| 179 | 192 | // SessionMode shortcuts are only active in session mode. | |
| 180 | 193 | SessionMode ShortcutTable | |
| 194 | + | // ResizeMode shortcuts are only active in resize mode. | |
| 195 | + | ResizeMode ShortcutTable | |
| 181 | 196 | } | |
| 182 | 197 | ||
| 183 | 198 | // NewShortcutSet creates a ShortcutSet from the configuration. | |
| @@ -188,17 +203,20 @@ func NewShortcutSet(ctx context.Context, cfg *config.Config) *ShortcutSet { | |||
| 188 | 203 | TabMode: make(ShortcutTable), | |
| 189 | 204 | PaneMode: make(ShortcutTable), | |
| 190 | 205 | SessionMode: make(ShortcutTable), | |
| 206 | + | ResizeMode: make(ShortcutTable), | |
| 191 | 207 | } | |
| 192 | 208 | ||
| 193 | 209 | set.buildGlobalShortcuts(ctx, cfg) | |
| 194 | 210 | set.buildTabModeShortcuts(ctx, &cfg.Workspace) | |
| 195 | 211 | set.buildPaneModeShortcuts(ctx, &cfg.Workspace) | |
| 212 | + | set.buildResizeModeShortcuts(ctx, &cfg.Workspace) | |
| 196 | 213 | set.buildSessionModeShortcuts(ctx, &cfg.Session) | |
| 197 | 214 | ||
| 198 | 215 | log.Debug(). | |
| 199 | 216 | Int("global", len(set.Global)). | |
| 200 | 217 | Int("tab", len(set.TabMode)). | |
| 201 | 218 | Int("pane", len(set.PaneMode)). | |
| 219 | + | Int("resize", len(set.ResizeMode)). | |
| 202 | 220 | Int("session", len(set.SessionMode)). | |
| 203 | 221 | Msg("shortcuts registered") | |
| 204 | 222 | ||
| @@ -229,6 +247,11 @@ func (s *ShortcutSet) buildSessionModeShortcuts(ctx context.Context, cfg *config | |||
| 229 | 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 | 255 | func (s *ShortcutSet) registerActivationShortcuts(ctx context.Context, cfg *config.Config) { | |
| 233 | 256 | log := logging.FromContext(ctx) | |
| 234 | 257 | if binding, ok := ParseKeyString(cfg.Workspace.TabMode.ActivationShortcut); ok { | |
| @@ -261,6 +284,16 @@ func (s *ShortcutSet) registerActivationShortcuts(ctx context.Context, cfg *conf | |||
| 261 | 284 | } else { | |
| 262 | 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 | 299 | func (s *ShortcutSet) registerConfiguredShortcuts(cfg *config.WorkspaceConfig) { | |
| @@ -360,62 +393,56 @@ func (s *ShortcutSet) buildModeShortcuts(ctx context.Context, bindings map[strin | |||
| 360 | 393 | Msg(mode + " mode shortcuts built") | |
| 361 | 394 | } | |
| 362 | 395 | ||
| 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{ | |
| 370 | 397 | // 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, | |
| 381 | 403 | ||
| 382 | 404 | // 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, | |
| 395 | 411 | ||
| 396 | 412 | // 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, | |
| 405 | 417 | ||
| 406 | 418 | // 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, | |
| 411 | 435 | ||
| 412 | 436 | // Session actions | |
| 413 | - | case "session-manager": | |
| 414 | - | return ActionOpenSessionManager | |
| 437 | + | "session-manager": ActionOpenSessionManager, | |
| 438 | + | } | |
| 415 | 439 | ||
| 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 | |
| 418 | 444 | } | |
| 445 | + | return configActionToAction[configAction] | |
| 419 | 446 | } | |
| 420 | 447 | ||
| 421 | 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 | 515 | modeTable = s.TabMode | |
| 489 | 516 | case ModePane: | |
| 490 | 517 | modeTable = s.PaneMode | |
| 518 | + | case ModeResize: | |
| 519 | + | modeTable = s.ResizeMode | |
| 491 | 520 | case ModeSession: | |
| 492 | 521 | modeTable = s.SessionMode | |
| 493 | 522 | } | |
| @@ -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 ShortcutTable | 191 | PaneMode ShortcutTable | ||
| 179 | // SessionMode shortcuts are only active in session mode. | 192 | // SessionMode shortcuts are only active in session mode. | ||
| 180 | SessionMode ShortcutTable | 193 | 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 actions | 397 | // Tab actions | ||
| 371 | - | case "new-tab": | 398 | + | "new-tab": ActionNewTab, |
| 372 | - | return ActionNewTab | 399 | + | "close-tab": ActionCloseTab, |
| 373 | - | case "close-tab": | 400 | + | "next-tab": ActionNextTab, |
| 374 | - | return ActionCloseTab | 401 | + | "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 actions | 404 | // Pane actions | ||
| 383 | - | case "split-right": | 405 | + | "split-right": ActionSplitRight, |
| 384 | - | return ActionSplitRight | 406 | + | "split-left": ActionSplitLeft, |
| 385 | - | case "split-left": | 407 | + | "split-up": ActionSplitUp, |
| 386 | - | return ActionSplitLeft | 408 | + | "split-down": ActionSplitDown, |
| 387 | - | case "split-up": | 409 | + | "close-pane": ActionClosePane, |
| 388 | - | return ActionSplitUp | 410 | + | "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 navigation | 412 | // Focus navigation | ||
| 397 | - | case "focus-right": | 413 | + | "focus-right": ActionFocusRight, |
| 398 | - | return ActionFocusRight | 414 | + | "focus-left": ActionFocusLeft, |
| 399 | - | case "focus-left": | 415 | + | "focus-up": ActionFocusUp, |
| 400 | - | return ActionFocusLeft | 416 | + | "focus-down": ActionFocusDown, |
| 401 | - | case "focus-up": | |||
| 402 | - | return ActionFocusUp | |||
| 403 | - | case "focus-down": | |||
| 404 | - | return ActionFocusDown | |||
| 405 | 417 | ||||
| 406 | // Stack navigation | 418 | // Stack navigation | ||
| 407 | - | case "stack-nav-up", "stack-up": | 419 | + | "stack-nav-up": ActionStackNavUp, |
| 408 | - | return ActionStackNavUp | 420 | + | "stack-up": ActionStackNavUp, |
| 409 | - | case "stack-nav-down", "stack-down": | 421 | + | "stack-nav-down": ActionStackNavDown, |
| 410 | - | return 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, | |||
| 411 | 435 | ||||
| 412 | // Session actions | 436 | // Session actions | ||
| 413 | - | case "session-manager": | 437 | + | "session-manager": ActionOpenSessionManager, |
| 414 | - | return ActionOpenSessionManager | 438 | + | } |
| 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.TabMode | 515 | modeTable = s.TabMode | ||
| 489 | case ModePane: | 516 | case ModePane: | ||
| 490 | modeTable = s.PaneMode | 517 | modeTable = s.PaneMode | ||
| 518 | + | case ModeResize: | |||
| 519 | + | modeTable = s.ResizeMode | |||
| 491 | case ModeSession: | 520 | case ModeSession: | ||
| 492 | modeTable = s.SessionMode | 521 | modeTable = s.SessionMode | ||
| 493 | } | 522 | } | ||