detached 51d3a48

Merge pull request #36 from bnema/feat/pane-resizing

feat(ui): add Zellij-style pane resize mode
commit
51d3a483851b5baac10bf5926eb8e1d8fefb707d
author
bnema · · 2025-12-26 08:31 UTC
committer
GitHub · · 2025-12-26 08:31 UTC
merge of
82a17d2 + 2738642 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
25 files · +1185 -112
M CHANGELOG.md +2 -0
@@ -5,6 +5,8 @@ All notable changes to this project will be documented in this file.
55 ## [Unreleased]
66
77 ### Added
8+- **Zellij-style pane resize mode**: New modal resize mode (`Ctrl+N`) to adjust split sizes with keyboard (`h/j/k/l` or arrow keys, `+/-` smart resize). Shows a per-pane border indicator and keeps the timeout alive while you resize.
9+- **Mouse-driven pane resizing**: Drag split dividers with the mouse; split ratios are persisted in session snapshots (rounded to 2 decimals) and restored on resurrection.
810 - **Dynamic window title**: Window title now displays the active pane's page title in format `<Page Title> - Dumber`. Updates when switching tabs/panes or navigating. Truncated at 255 characters.
911 - **Link URL status overlay**: Shows destination URL in bottom-left corner when hovering over links, images, or media elements. Standard browser UX pattern with 100ms show delay to avoid flicker and 150ms CSS fade transitions. Configurable via CSS.
1012 - **Local file path support**: `dumber browse test.html` now correctly opens local HTML files by converting existing file paths to `file://` URLs. Supports absolute paths, relative paths, and `~/` home directory expansion.
@@ -5,6 +5,8 @@ All notable changes to this project will be documented in this file.
5 ## [Unreleased]5 ## [Unreleased]
6 6
7 ### Added7 ### Added
8+- **Zellij-style pane resize mode**: New modal resize mode (`Ctrl+N`) to adjust split sizes with keyboard (`h/j/k/l` or arrow keys, `+/-` smart resize). Shows a per-pane border indicator and keeps the timeout alive while you resize.
9+- **Mouse-driven pane resizing**: Drag split dividers with the mouse; split ratios are persisted in session snapshots (rounded to 2 decimals) and restored on resurrection.
8 - **Dynamic window title**: Window title now displays the active pane's page title in format `<Page Title> - Dumber`. Updates when switching tabs/panes or navigating. Truncated at 255 characters.10 - **Dynamic window title**: Window title now displays the active pane's page title in format `<Page Title> - Dumber`. Updates when switching tabs/panes or navigating. Truncated at 255 characters.
9 - **Link URL status overlay**: Shows destination URL in bottom-left corner when hovering over links, images, or media elements. Standard browser UX pattern with 100ms show delay to avoid flicker and 150ms CSS fade transitions. Configurable via CSS.11 - **Link URL status overlay**: Shows destination URL in bottom-left corner when hovering over links, images, or media elements. Standard browser UX pattern with 100ms show delay to avoid flicker and 150ms CSS fade transitions. Configurable via CSS.
10 - **Local file path support**: `dumber browse test.html` now correctly opens local HTML files by converting existing file paths to `file://` URLs. Supports absolute paths, relative paths, and `~/` home directory expansion.12 - **Local file path support**: `dumber browse test.html` now correctly opens local HTML files by converting existing file paths to `file://` URLs. Supports absolute paths, relative paths, and `~/` home directory expansion.
M README.md +12 -1
@@ -80,6 +80,16 @@ Early development with regular releases. Core features work well for daily use b
8080 | **Alt+Arrow Keys** | Navigate Panes | Move focus between panes |
8181 | **Alt+Up/Down** | Navigate Stack | Navigate between stacked panes |
8282
83+#### Resize Mode
84+| Shortcut | Action | Notes |
85+|----------|--------|-------|
86+| **Ctrl+N** | Enter Resize Mode | Modal mode for pane resizing |
87+| **←/↓/↑/→** / **h/j/k/l** (in resize mode) | Move Divider | Resizes the nearest split by moving the divider |
88+| **H/J/K/L** (in resize mode) | Move Divider (Inverse) | Inverts the direction |
89+| **+ / -** (in resize mode) | Smart Resize | Grow/shrink the active pane (best-effort) |
90+| **Enter** (in resize mode) | Confirm | Exit resize mode |
91+| **Escape** (in resize mode) | Cancel | Exit resize mode |
92+
8393 #### Session Management
8494 | Shortcut | Action | Notes |
8595 |----------|--------|-------|
@@ -91,6 +101,7 @@ Early development with regular releases. Core features work well for daily use b
91101 ### Mouse Controls
92102 | Action | Result | Notes |
93103 |--------|---------|-------|
104+| **Drag pane divider** | Resize panes | Updates split ratio and persists in session snapshots |
94105 | **Ctrl+Scroll Up** | Zoom In | Smooth zoom control |
95106 | **Ctrl+Scroll Down** | Zoom Out | Smooth zoom control |
96107 | **Mouse Button 8** | Navigate Back | Side button (back) |
@@ -293,7 +304,7 @@ Split your browser window into multiple panes, each running independent web sess
293304
294305 ## Session Management
295306
296-Dumber automatically saves your browser session (tabs, panes, splits, URLs) and allows you to restore previous sessions.
307+Dumber automatically saves your browser session (tabs, panes, split ratios, URLs) and allows you to restore previous sessions.
297308
298309 ### How it Works
299310 1. **Automatic Snapshots**: Session state is saved automatically (debounced, every 5s by default) and on graceful shutdown
@@ -80,6 +80,16 @@ Early development with regular releases. Core features work well for daily use b
80 | **Alt+Arrow Keys** | Navigate Panes | Move focus between panes |80 | **Alt+Arrow Keys** | Navigate Panes | Move focus between panes |
81 | **Alt+Up/Down** | Navigate Stack | Navigate between stacked panes |81 | **Alt+Up/Down** | Navigate Stack | Navigate between stacked panes |
82 82
83+#### Resize Mode
84+| Shortcut | Action | Notes |
85+|----------|--------|-------|
86+| **Ctrl+N** | Enter Resize Mode | Modal mode for pane resizing |
87+| **←/↓/↑/→** / **h/j/k/l** (in resize mode) | Move Divider | Resizes the nearest split by moving the divider |
88+| **H/J/K/L** (in resize mode) | Move Divider (Inverse) | Inverts the direction |
89+| **+ / -** (in resize mode) | Smart Resize | Grow/shrink the active pane (best-effort) |
90+| **Enter** (in resize mode) | Confirm | Exit resize mode |
91+| **Escape** (in resize mode) | Cancel | Exit resize mode |
92+
83 #### Session Management93 #### Session Management
84 | Shortcut | Action | Notes |94 | Shortcut | Action | Notes |
85 |----------|--------|-------|95 |----------|--------|-------|
@@ -91,6 +101,7 @@ Early development with regular releases. Core features work well for daily use b
91 ### Mouse Controls101 ### Mouse Controls
92 | Action | Result | Notes |102 | Action | Result | Notes |
93 |--------|---------|-------|103 |--------|---------|-------|
104+| **Drag pane divider** | Resize panes | Updates split ratio and persists in session snapshots |
94 | **Ctrl+Scroll Up** | Zoom In | Smooth zoom control |105 | **Ctrl+Scroll Up** | Zoom In | Smooth zoom control |
95 | **Ctrl+Scroll Down** | Zoom Out | Smooth zoom control |106 | **Ctrl+Scroll Down** | Zoom Out | Smooth zoom control |
96 | **Mouse Button 8** | Navigate Back | Side button (back) |107 | **Mouse Button 8** | Navigate Back | Side button (back) |
@@ -293,7 +304,7 @@ Split your browser window into multiple panes, each running independent web sess
293 304
294 ## Session Management305 ## Session Management
295 306
296-Dumber automatically saves your browser session (tabs, panes, splits, URLs) and allows you to restore previous sessions.307+Dumber automatically saves your browser session (tabs, panes, split ratios, URLs) and allows you to restore previous sessions.
297 308
298 ### How it Works309 ### How it Works
299 1. **Automatic Snapshots**: Session state is saved automatically (debounced, every 5s by default) and on graceful shutdown310 1. **Automatic Snapshots**: Session state is saved automatically (debounced, every 5s by default) and on graceful shutdown
M docs/CONFIG.md +34 -0
@@ -243,6 +243,38 @@ cancel = ["escape"]
243243
244244 > **Note:** Actions are inverted to key→action map in memory for O(1) lookup performance during navigation.
245245
246+### Resize Mode
247+
248+| Key | Type | Default | Description |
249+|-----|------|---------|-------------|
250+| `workspace.resize_mode.activation_shortcut` | string | `"ctrl+n"` | Resize mode activation key |
251+| `workspace.resize_mode.timeout_ms` | int | `3000` | Resize mode timeout (ms) |
252+| `workspace.resize_mode.step_percent` | float | `5.0` | Split ratio step per keystroke (percent) |
253+| `workspace.resize_mode.min_pane_percent` | float | `10.0` | Minimum pane size (percent) |
254+| `workspace.resize_mode.actions` | map | See below | Action→keys mappings |
255+
256+**Default resize mode actions:**
257+```toml
258+[workspace.resize_mode.actions]
259+resize-increase-left = ["h", "arrowleft"]
260+resize-increase-down = ["j", "arrowdown"]
261+resize-increase-up = ["k", "arrowup"]
262+resize-increase-right = ["l", "arrowright"]
263+resize-decrease-left = ["H"]
264+resize-decrease-down = ["J"]
265+resize-decrease-up = ["K"]
266+resize-decrease-right = ["L"]
267+resize-increase = ["+", "="]
268+resize-decrease = ["-"]
269+confirm = ["enter"]
270+cancel = ["escape"]
271+```
272+
273+Notes:
274+- Directional actions (`resize-increase-*/resize-decrease-*`) move the split divider.
275+- Smart actions (`resize-increase` / `resize-decrease`) grow/shrink the active pane (best-effort) by picking a direction automatically.
276+- Timeout is refreshed on each resize keypress so you can keep adjusting without re-entering the mode.
277+
246278 ### Global Shortcuts
247279
248280 | Key | Type | Default | Description |
@@ -277,6 +309,8 @@ cancel = ["escape"]
277309 | `workspace.styling.tab_mode_border_color` | string | `"#FFA500"` | Tab mode border color (orange) |
278310 | `workspace.styling.session_mode_border_width` | int | `4` | Session mode border width (px) - Ctrl+O overlay |
279311 | `workspace.styling.session_mode_border_color` | string | `"#9B59B6"` | Session mode border color (purple) |
312+| `workspace.styling.resize_mode_border_width` | int | `4` | Resize mode border width (px) |
313+| `workspace.styling.resize_mode_border_color` | string | `"#00D4AA"` | Resize mode border color (teal) |
280314 | `workspace.styling.transition_duration` | int | `120` | Border transition duration (ms) |
281315
282316 ## Session
@@ -243,6 +243,38 @@ cancel = ["escape"]
243 243
244 > **Note:** Actions are inverted to key→action map in memory for O(1) lookup performance during navigation.244 > **Note:** Actions are inverted to key→action map in memory for O(1) lookup performance during navigation.
245 245
246+### Resize Mode
247+
248+| Key | Type | Default | Description |
249+|-----|------|---------|-------------|
250+| `workspace.resize_mode.activation_shortcut` | string | `"ctrl+n"` | Resize mode activation key |
251+| `workspace.resize_mode.timeout_ms` | int | `3000` | Resize mode timeout (ms) |
252+| `workspace.resize_mode.step_percent` | float | `5.0` | Split ratio step per keystroke (percent) |
253+| `workspace.resize_mode.min_pane_percent` | float | `10.0` | Minimum pane size (percent) |
254+| `workspace.resize_mode.actions` | map | See below | Action→keys mappings |
255+
256+**Default resize mode actions:**
257+```toml
258+[workspace.resize_mode.actions]
259+resize-increase-left = ["h", "arrowleft"]
260+resize-increase-down = ["j", "arrowdown"]
261+resize-increase-up = ["k", "arrowup"]
262+resize-increase-right = ["l", "arrowright"]
263+resize-decrease-left = ["H"]
264+resize-decrease-down = ["J"]
265+resize-decrease-up = ["K"]
266+resize-decrease-right = ["L"]
267+resize-increase = ["+", "="]
268+resize-decrease = ["-"]
269+confirm = ["enter"]
270+cancel = ["escape"]
271+```
272+
273+Notes:
274+- Directional actions (`resize-increase-*/resize-decrease-*`) move the split divider.
275+- Smart actions (`resize-increase` / `resize-decrease`) grow/shrink the active pane (best-effort) by picking a direction automatically.
276+- Timeout is refreshed on each resize keypress so you can keep adjusting without re-entering the mode.
277+
246 ### Global Shortcuts278 ### Global Shortcuts
247 279
248 | Key | Type | Default | Description |280 | Key | Type | Default | Description |
@@ -277,6 +309,8 @@ cancel = ["escape"]
277 | `workspace.styling.tab_mode_border_color` | string | `"#FFA500"` | Tab mode border color (orange) |309 | `workspace.styling.tab_mode_border_color` | string | `"#FFA500"` | Tab mode border color (orange) |
278 | `workspace.styling.session_mode_border_width` | int | `4` | Session mode border width (px) - Ctrl+O overlay |310 | `workspace.styling.session_mode_border_width` | int | `4` | Session mode border width (px) - Ctrl+O overlay |
279 | `workspace.styling.session_mode_border_color` | string | `"#9B59B6"` | Session mode border color (purple) |311 | `workspace.styling.session_mode_border_color` | string | `"#9B59B6"` | Session mode border color (purple) |
312+| `workspace.styling.resize_mode_border_width` | int | `4` | Resize mode border width (px) |
313+| `workspace.styling.resize_mode_border_color` | string | `"#00D4AA"` | Resize mode border color (teal) |
280 | `workspace.styling.transition_duration` | int | `120` | Border transition duration (ms) |314 | `workspace.styling.transition_duration` | int | `120` | Border transition duration (ms) |
281 315
282 ## Session316 ## Session
M internal/application/usecase/manage_panes.go +270 -0
@@ -2,7 +2,9 @@ package usecase
22
33 import (
44 "context"
5+ "errors"
56 "fmt"
7+ "math"
68 "sort"
79
810 "github.com/bnema/dumber/internal/domain/entity"
@@ -29,6 +31,26 @@ const (
2931 NavDown NavigateDirection = "down"
3032 )
3133
34+// ResizeDirection indicates the direction for pane resizing.
35+type ResizeDirection string
36+
37+const (
38+ ResizeIncreaseLeft ResizeDirection = "increase_left"
39+ ResizeIncreaseRight ResizeDirection = "increase_right"
40+ ResizeIncreaseUp ResizeDirection = "increase_up"
41+ ResizeIncreaseDown ResizeDirection = "increase_down"
42+
43+ ResizeDecreaseLeft ResizeDirection = "decrease_left"
44+ ResizeDecreaseRight ResizeDirection = "decrease_right"
45+ ResizeDecreaseUp ResizeDirection = "decrease_up"
46+ ResizeDecreaseDown ResizeDirection = "decrease_down"
47+
48+ ResizeIncrease ResizeDirection = "increase"
49+ ResizeDecrease ResizeDirection = "decrease"
50+)
51+
52+var ErrNothingToResize = errors.New("nothing to resize")
53+
3254 // ManagePanesUseCase handles pane tree operations.
3355 type ManagePanesUseCase struct {
3456 idGenerator IDGenerator
@@ -166,6 +188,254 @@ func (uc *ManagePanesUseCase) Split(ctx context.Context, input SplitPaneInput) (
166188 }, nil
167189 }
168190
191+// Resize adjusts the nearest applicable split ratio for the given direction.
192+// stepPercent is applied per keystroke (e.g. 5.0 means 5%).
193+// minPanePercent enforces a minimum size for each side of a split.
194+func (uc *ManagePanesUseCase) Resize(
195+ ctx context.Context,
196+ ws *entity.Workspace,
197+ paneNode *entity.PaneNode,
198+ dir ResizeDirection,
199+ stepPercent float64,
200+ minPanePercent float64,
201+) error {
202+ log := logging.FromContext(ctx)
203+ if uc == nil {
204+ return fmt.Errorf("manage panes use case is nil")
205+ }
206+
207+ if ws == nil {
208+ return fmt.Errorf("workspace is required")
209+ }
210+ if ws.Root == nil {
211+ return ErrNothingToResize
212+ }
213+ if paneNode == nil {
214+ return fmt.Errorf("pane node is required")
215+ }
216+
217+ target := paneNode
218+ if target.Parent != nil && target.Parent.IsStacked {
219+ target = target.Parent
220+ }
221+
222+ actualDir := dir
223+ switch dir {
224+ case ResizeIncrease:
225+ actualDir = findSmartResizeDirection(target, true)
226+ case ResizeDecrease:
227+ actualDir = findSmartResizeDirection(target, false)
228+ }
229+ if actualDir == "" {
230+ return ErrNothingToResize
231+ }
232+
233+ axis, ok := axisForResizeDirection(actualDir)
234+ if !ok {
235+ return ErrNothingToResize
236+ }
237+
238+ splitNode := findNearestSplitForAxis(target, axis)
239+ if splitNode == nil {
240+ return ErrNothingToResize
241+ }
242+
243+ // We treat resize directions as moving the split divider.
244+ // SplitRatio is the proportion allocated to the first child (left/top).
245+ // - Moving the divider right/down increases SplitRatio.
246+ // - Moving the divider left/up decreases SplitRatio.
247+ delta := deltaForDividerMove(actualDir, stepPercent)
248+
249+ minRatio := minPanePercent / 100.0
250+ maxRatio := 1.0 - minRatio
251+ oldRatio := splitNode.SplitRatio
252+ splitNode.SplitRatio = clampFloat64(splitNode.SplitRatio+delta, minRatio, maxRatio)
253+
254+ log.Debug().
255+ Str("direction", string(dir)).
256+ Str("actual_direction", string(actualDir)).
257+ Float64("old_ratio", oldRatio).
258+ Float64("new_ratio", splitNode.SplitRatio).
259+ Msg("pane resized")
260+
261+ return nil
262+}
263+
264+type SetSplitRatioInput struct {
265+ Workspace *entity.Workspace
266+ SplitNodeID string
267+ Ratio float64
268+ MinPanePercent float64
269+}
270+
271+func (uc *ManagePanesUseCase) SetSplitRatio(ctx context.Context, input SetSplitRatioInput) error {
272+ log := logging.FromContext(ctx)
273+ if uc == nil {
274+ return fmt.Errorf("manage panes use case is nil")
275+ }
276+ if input.Workspace == nil {
277+ return fmt.Errorf("workspace is required")
278+ }
279+ if input.Workspace.Root == nil {
280+ return ErrNothingToResize
281+ }
282+ if input.SplitNodeID == "" {
283+ return fmt.Errorf("split node id is required")
284+ }
285+
286+ var splitNode *entity.PaneNode
287+ input.Workspace.Root.Walk(func(node *entity.PaneNode) bool {
288+ if node.ID == input.SplitNodeID {
289+ splitNode = node
290+ return false
291+ }
292+ return true
293+ })
294+
295+ if splitNode == nil || !splitNode.IsSplit() {
296+ return fmt.Errorf("split node not found: %s", input.SplitNodeID)
297+ }
298+
299+ minRatio := input.MinPanePercent / 100.0
300+ maxRatio := 1.0 - minRatio
301+ oldRatio := splitNode.SplitRatio
302+ clamped := clampFloat64(input.Ratio, minRatio, maxRatio)
303+ splitNode.SplitRatio = roundSplitRatio(clamped)
304+
305+ log.Debug().
306+ Str("split_node_id", input.SplitNodeID).
307+ Float64("old_ratio", oldRatio).
308+ Float64("new_ratio", splitNode.SplitRatio).
309+ Msg("split ratio set")
310+
311+ return nil
312+}
313+
314+func findSmartResizeDirection(target *entity.PaneNode, growActive bool) ResizeDirection {
315+ splitNode, axis, isStartChild := findNearestSplitForResize(target)
316+ if splitNode == nil {
317+ return ""
318+ }
319+
320+ // Smart resize means "grow/shrink the active pane".
321+ // SplitRatio is the proportion allocated to the first child (left/top).
322+ // - If active is first child: grow by increasing ratio, shrink by decreasing.
323+ // - If active is second child: grow by decreasing ratio, shrink by increasing.
324+ growMeansIncreaseRatio := isStartChild
325+ if !growActive {
326+ growMeansIncreaseRatio = !growMeansIncreaseRatio
327+ }
328+
329+ switch axis {
330+ case resizeAxisHorizontal:
331+ if growMeansIncreaseRatio {
332+ return ResizeIncreaseRight
333+ }
334+ return ResizeIncreaseLeft
335+ case resizeAxisVertical:
336+ if growMeansIncreaseRatio {
337+ return ResizeIncreaseDown
338+ }
339+ return ResizeIncreaseUp
340+ default:
341+ return ""
342+ }
343+}
344+
345+// findNearestSplitForResize returns the nearest split ancestor for the active pane.
346+// It prefers horizontal splits over vertical when both are available at the same depth.
347+func findNearestSplitForResize(node *entity.PaneNode) (splitNode *entity.PaneNode, axis resizeAxis, isStartChild bool) {
348+ current := node
349+ for current != nil && current.Parent != nil {
350+ parent := current.Parent
351+ if parent.IsSplit() {
352+ isStartChild = parent.Left() == current
353+ switch parent.SplitDir {
354+ case entity.SplitHorizontal:
355+ return parent, resizeAxisHorizontal, isStartChild
356+ case entity.SplitVertical:
357+ return parent, resizeAxisVertical, isStartChild
358+ }
359+ }
360+ current = parent
361+ }
362+ return nil, resizeAxisNone, false
363+}
364+
365+type resizeAxis int
366+
367+const (
368+ resizeAxisNone resizeAxis = iota
369+ resizeAxisHorizontal
370+ resizeAxisVertical
371+)
372+
373+func axisForResizeDirection(dir ResizeDirection) (resizeAxis, bool) {
374+ switch dir {
375+ case ResizeIncreaseLeft, ResizeIncreaseRight, ResizeDecreaseLeft, ResizeDecreaseRight:
376+ return resizeAxisHorizontal, true
377+ case ResizeIncreaseUp, ResizeIncreaseDown, ResizeDecreaseUp, ResizeDecreaseDown:
378+ return resizeAxisVertical, true
379+ default:
380+ return resizeAxisNone, false
381+ }
382+}
383+
384+func deltaForDividerMove(dir ResizeDirection, stepPercent float64) float64 {
385+ if stepPercent < 0 {
386+ stepPercent = -stepPercent
387+ }
388+ delta := stepPercent / 100.0
389+
390+ switch dir {
391+ case ResizeIncreaseRight, ResizeIncreaseDown:
392+ return delta
393+ case ResizeIncreaseLeft, ResizeIncreaseUp:
394+ return -delta
395+ case ResizeDecreaseRight, ResizeDecreaseDown:
396+ return -delta
397+ case ResizeDecreaseLeft, ResizeDecreaseUp:
398+ return delta
399+ default:
400+ return 0
401+ }
402+}
403+
404+// findNearestSplitForAxis walks up the tree to find the nearest split matching the axis.
405+func findNearestSplitForAxis(node *entity.PaneNode, axis resizeAxis) *entity.PaneNode {
406+ current := node
407+ for current != nil && current.Parent != nil {
408+ parent := current.Parent
409+ if parent.IsSplit() {
410+ if axis == resizeAxisHorizontal && parent.SplitDir == entity.SplitHorizontal {
411+ return parent
412+ }
413+ if axis == resizeAxisVertical && parent.SplitDir == entity.SplitVertical {
414+ return parent
415+ }
416+ }
417+ current = parent
418+ }
419+ return nil
420+}
421+
422+const splitRatioRoundFactor = 100.0
423+
424+func roundSplitRatio(ratio float64) float64 {
425+ // Keep snapshots stable and readable; avoids persisting noisy float values.
426+ return math.Round(ratio*splitRatioRoundFactor) / splitRatioRoundFactor
427+}
428+
429+func clampFloat64(v, minVal, maxVal float64) float64 {
430+ if v < minVal {
431+ return minVal
432+ }
433+ if v > maxVal {
434+ return maxVal
435+ }
436+ return v
437+}
438+
169439 // Close removes a pane and promotes its sibling.
170440 // Returns the sibling node that was promoted, or nil if this was the last pane.
171441 func (uc *ManagePanesUseCase) Close(ctx context.Context, ws *entity.Workspace, paneNode *entity.PaneNode) (*entity.PaneNode, error) {
@@ -2,7 +2,9 @@ package usecase
2 2
3 import (3 import (
4 "context"4 "context"
5+ "errors"
5 "fmt"6 "fmt"
7+ "math"
6 "sort"8 "sort"
7 9
8 "github.com/bnema/dumber/internal/domain/entity"10 "github.com/bnema/dumber/internal/domain/entity"
@@ -29,6 +31,26 @@ const (
29 NavDown NavigateDirection = "down"31 NavDown NavigateDirection = "down"
30 )32 )
31 33
34+// ResizeDirection indicates the direction for pane resizing.
35+type ResizeDirection string
36+
37+const (
38+ ResizeIncreaseLeft ResizeDirection = "increase_left"
39+ ResizeIncreaseRight ResizeDirection = "increase_right"
40+ ResizeIncreaseUp ResizeDirection = "increase_up"
41+ ResizeIncreaseDown ResizeDirection = "increase_down"
42+
43+ ResizeDecreaseLeft ResizeDirection = "decrease_left"
44+ ResizeDecreaseRight ResizeDirection = "decrease_right"
45+ ResizeDecreaseUp ResizeDirection = "decrease_up"
46+ ResizeDecreaseDown ResizeDirection = "decrease_down"
47+
48+ ResizeIncrease ResizeDirection = "increase"
49+ ResizeDecrease ResizeDirection = "decrease"
50+)
51+
52+var ErrNothingToResize = errors.New("nothing to resize")
53+
32 // ManagePanesUseCase handles pane tree operations.54 // ManagePanesUseCase handles pane tree operations.
33 type ManagePanesUseCase struct {55 type ManagePanesUseCase struct {
34 idGenerator IDGenerator56 idGenerator IDGenerator
@@ -166,6 +188,254 @@ func (uc *ManagePanesUseCase) Split(ctx context.Context, input SplitPaneInput) (
166 }, nil188 }, nil
167 }189 }
168 190
191+// Resize adjusts the nearest applicable split ratio for the given direction.
192+// stepPercent is applied per keystroke (e.g. 5.0 means 5%).
193+// minPanePercent enforces a minimum size for each side of a split.
194+func (uc *ManagePanesUseCase) Resize(
195+ ctx context.Context,
196+ ws *entity.Workspace,
197+ paneNode *entity.PaneNode,
198+ dir ResizeDirection,
199+ stepPercent float64,
200+ minPanePercent float64,
201+) error {
202+ log := logging.FromContext(ctx)
203+ if uc == nil {
204+ return fmt.Errorf("manage panes use case is nil")
205+ }
206+
207+ if ws == nil {
208+ return fmt.Errorf("workspace is required")
209+ }
210+ if ws.Root == nil {
211+ return ErrNothingToResize
212+ }
213+ if paneNode == nil {
214+ return fmt.Errorf("pane node is required")
215+ }
216+
217+ target := paneNode
218+ if target.Parent != nil && target.Parent.IsStacked {
219+ target = target.Parent
220+ }
221+
222+ actualDir := dir
223+ switch dir {
224+ case ResizeIncrease:
225+ actualDir = findSmartResizeDirection(target, true)
226+ case ResizeDecrease:
227+ actualDir = findSmartResizeDirection(target, false)
228+ }
229+ if actualDir == "" {
230+ return ErrNothingToResize
231+ }
232+
233+ axis, ok := axisForResizeDirection(actualDir)
234+ if !ok {
235+ return ErrNothingToResize
236+ }
237+
238+ splitNode := findNearestSplitForAxis(target, axis)
239+ if splitNode == nil {
240+ return ErrNothingToResize
241+ }
242+
243+ // We treat resize directions as moving the split divider.
244+ // SplitRatio is the proportion allocated to the first child (left/top).
245+ // - Moving the divider right/down increases SplitRatio.
246+ // - Moving the divider left/up decreases SplitRatio.
247+ delta := deltaForDividerMove(actualDir, stepPercent)
248+
249+ minRatio := minPanePercent / 100.0
250+ maxRatio := 1.0 - minRatio
251+ oldRatio := splitNode.SplitRatio
252+ splitNode.SplitRatio = clampFloat64(splitNode.SplitRatio+delta, minRatio, maxRatio)
253+
254+ log.Debug().
255+ Str("direction", string(dir)).
256+ Str("actual_direction", string(actualDir)).
257+ Float64("old_ratio", oldRatio).
258+ Float64("new_ratio", splitNode.SplitRatio).
259+ Msg("pane resized")
260+
261+ return nil
262+}
263+
264+type SetSplitRatioInput struct {
265+ Workspace *entity.Workspace
266+ SplitNodeID string
267+ Ratio float64
268+ MinPanePercent float64
269+}
270+
271+func (uc *ManagePanesUseCase) SetSplitRatio(ctx context.Context, input SetSplitRatioInput) error {
272+ log := logging.FromContext(ctx)
273+ if uc == nil {
274+ return fmt.Errorf("manage panes use case is nil")
275+ }
276+ if input.Workspace == nil {
277+ return fmt.Errorf("workspace is required")
278+ }
279+ if input.Workspace.Root == nil {
280+ return ErrNothingToResize
281+ }
282+ if input.SplitNodeID == "" {
283+ return fmt.Errorf("split node id is required")
284+ }
285+
286+ var splitNode *entity.PaneNode
287+ input.Workspace.Root.Walk(func(node *entity.PaneNode) bool {
288+ if node.ID == input.SplitNodeID {
289+ splitNode = node
290+ return false
291+ }
292+ return true
293+ })
294+
295+ if splitNode == nil || !splitNode.IsSplit() {
296+ return fmt.Errorf("split node not found: %s", input.SplitNodeID)
297+ }
298+
299+ minRatio := input.MinPanePercent / 100.0
300+ maxRatio := 1.0 - minRatio
301+ oldRatio := splitNode.SplitRatio
302+ clamped := clampFloat64(input.Ratio, minRatio, maxRatio)
303+ splitNode.SplitRatio = roundSplitRatio(clamped)
304+
305+ log.Debug().
306+ Str("split_node_id", input.SplitNodeID).
307+ Float64("old_ratio", oldRatio).
308+ Float64("new_ratio", splitNode.SplitRatio).
309+ Msg("split ratio set")
310+
311+ return nil
312+}
313+
314+func findSmartResizeDirection(target *entity.PaneNode, growActive bool) ResizeDirection {
315+ splitNode, axis, isStartChild := findNearestSplitForResize(target)
316+ if splitNode == nil {
317+ return ""
318+ }
319+
320+ // Smart resize means "grow/shrink the active pane".
321+ // SplitRatio is the proportion allocated to the first child (left/top).
322+ // - If active is first child: grow by increasing ratio, shrink by decreasing.
323+ // - If active is second child: grow by decreasing ratio, shrink by increasing.
324+ growMeansIncreaseRatio := isStartChild
325+ if !growActive {
326+ growMeansIncreaseRatio = !growMeansIncreaseRatio
327+ }
328+
329+ switch axis {
330+ case resizeAxisHorizontal:
331+ if growMeansIncreaseRatio {
332+ return ResizeIncreaseRight
333+ }
334+ return ResizeIncreaseLeft
335+ case resizeAxisVertical:
336+ if growMeansIncreaseRatio {
337+ return ResizeIncreaseDown
338+ }
339+ return ResizeIncreaseUp
340+ default:
341+ return ""
342+ }
343+}
344+
345+// findNearestSplitForResize returns the nearest split ancestor for the active pane.
346+// It prefers horizontal splits over vertical when both are available at the same depth.
347+func findNearestSplitForResize(node *entity.PaneNode) (splitNode *entity.PaneNode, axis resizeAxis, isStartChild bool) {
348+ current := node
349+ for current != nil && current.Parent != nil {
350+ parent := current.Parent
351+ if parent.IsSplit() {
352+ isStartChild = parent.Left() == current
353+ switch parent.SplitDir {
354+ case entity.SplitHorizontal:
355+ return parent, resizeAxisHorizontal, isStartChild
356+ case entity.SplitVertical:
357+ return parent, resizeAxisVertical, isStartChild
358+ }
359+ }
360+ current = parent
361+ }
362+ return nil, resizeAxisNone, false
363+}
364+
365+type resizeAxis int
366+
367+const (
368+ resizeAxisNone resizeAxis = iota
369+ resizeAxisHorizontal
370+ resizeAxisVertical
371+)
372+
373+func axisForResizeDirection(dir ResizeDirection) (resizeAxis, bool) {
374+ switch dir {
375+ case ResizeIncreaseLeft, ResizeIncreaseRight, ResizeDecreaseLeft, ResizeDecreaseRight:
376+ return resizeAxisHorizontal, true
377+ case ResizeIncreaseUp, ResizeIncreaseDown, ResizeDecreaseUp, ResizeDecreaseDown:
378+ return resizeAxisVertical, true
379+ default:
380+ return resizeAxisNone, false
381+ }
382+}
383+
384+func deltaForDividerMove(dir ResizeDirection, stepPercent float64) float64 {
385+ if stepPercent < 0 {
386+ stepPercent = -stepPercent
387+ }
388+ delta := stepPercent / 100.0
389+
390+ switch dir {
391+ case ResizeIncreaseRight, ResizeIncreaseDown:
392+ return delta
393+ case ResizeIncreaseLeft, ResizeIncreaseUp:
394+ return -delta
395+ case ResizeDecreaseRight, ResizeDecreaseDown:
396+ return -delta
397+ case ResizeDecreaseLeft, ResizeDecreaseUp:
398+ return delta
399+ default:
400+ return 0
401+ }
402+}
403+
404+// findNearestSplitForAxis walks up the tree to find the nearest split matching the axis.
405+func findNearestSplitForAxis(node *entity.PaneNode, axis resizeAxis) *entity.PaneNode {
406+ current := node
407+ for current != nil && current.Parent != nil {
408+ parent := current.Parent
409+ if parent.IsSplit() {
410+ if axis == resizeAxisHorizontal && parent.SplitDir == entity.SplitHorizontal {
411+ return parent
412+ }
413+ if axis == resizeAxisVertical && parent.SplitDir == entity.SplitVertical {
414+ return parent
415+ }
416+ }
417+ current = parent
418+ }
419+ return nil
420+}
421+
422+const splitRatioRoundFactor = 100.0
423+
424+func roundSplitRatio(ratio float64) float64 {
425+ // Keep snapshots stable and readable; avoids persisting noisy float values.
426+ return math.Round(ratio*splitRatioRoundFactor) / splitRatioRoundFactor
427+}
428+
429+func clampFloat64(v, minVal, maxVal float64) float64 {
430+ if v < minVal {
431+ return minVal
432+ }
433+ if v > maxVal {
434+ return maxVal
435+ }
436+ return v
437+}
438+
169 // Close removes a pane and promotes its sibling.439 // Close removes a pane and promotes its sibling.
170 // Returns the sibling node that was promoted, or nil if this was the last pane.440 // Returns the sibling node that was promoted, or nil if this was the last pane.
171 func (uc *ManagePanesUseCase) Close(ctx context.Context, ws *entity.Workspace, paneNode *entity.PaneNode) (*entity.PaneNode, error) {441 func (uc *ManagePanesUseCase) Close(ctx context.Context, ws *entity.Workspace, paneNode *entity.PaneNode) (*entity.PaneNode, error) {
A internal/application/usecase/manage_panes_resize_test.go +163 -0
@@ -0,0 +1,163 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+
8+ "github.com/bnema/dumber/internal/domain/entity"
9+)
10+
11+func TestManagePanesUseCase_Resize_Errors(t *testing.T) {
12+ uc := NewManagePanesUseCase(func() string { return "id" })
13+
14+ ctx := context.Background()
15+
16+ err := uc.Resize(ctx, nil, nil, ResizeIncreaseDown, 5, 10)
17+ if err == nil {
18+ t.Fatalf("expected error when workspace is nil")
19+ }
20+
21+ ws := &entity.Workspace{}
22+ err = uc.Resize(ctx, ws, nil, ResizeIncreaseDown, 5, 10)
23+ if err == nil {
24+ t.Fatalf("expected error when pane node is nil")
25+ }
26+
27+ // Root nil should return ErrNothingToResize.
28+ leaf := &entity.PaneNode{ID: "p1", Pane: &entity.Pane{ID: "p1"}}
29+ err = uc.Resize(ctx, ws, leaf, ResizeIncreaseDown, 5, 10)
30+ if !errors.Is(err, ErrNothingToResize) {
31+ t.Fatalf("expected ErrNothingToResize, got %v", err)
32+ }
33+}
34+
35+func TestManagePanesUseCase_Resize_VerticalDividerMove(t *testing.T) {
36+ uc := NewManagePanesUseCase(func() string { return "id" })
37+ ctx := context.Background()
38+
39+ top := &entity.PaneNode{ID: "top", Pane: &entity.Pane{ID: "top"}}
40+ bottom := &entity.PaneNode{ID: "bottom", Pane: &entity.Pane{ID: "bottom"}}
41+ root := &entity.PaneNode{
42+ ID: "split",
43+ SplitDir: entity.SplitVertical,
44+ SplitRatio: 0.5,
45+ Children: []*entity.PaneNode{top, bottom},
46+ }
47+ top.Parent = root
48+ bottom.Parent = root
49+
50+ ws := &entity.Workspace{Root: root, ActivePaneID: "bottom"}
51+
52+ // Moving divider down increases the first child's ratio.
53+ if err := uc.Resize(ctx, ws, bottom, ResizeIncreaseDown, 5.0, 10.0); err != nil {
54+ t.Fatalf("unexpected error: %v", err)
55+ }
56+ if got, want := root.SplitRatio, 0.55; got != want {
57+ t.Fatalf("split ratio = %v, want %v", got, want)
58+ }
59+
60+ // Moving divider up decreases the first child's ratio.
61+ if err := uc.Resize(ctx, ws, bottom, ResizeIncreaseUp, 5.0, 10.0); err != nil {
62+ t.Fatalf("unexpected error: %v", err)
63+ }
64+ if got, want := root.SplitRatio, 0.5; got != want {
65+ t.Fatalf("split ratio = %v, want %v", got, want)
66+ }
67+}
68+
69+func TestManagePanesUseCase_Resize_SmartResizeGrowsAndShrinksActivePane(t *testing.T) {
70+ uc := NewManagePanesUseCase(func() string { return "id" })
71+ ctx := context.Background()
72+
73+ top := &entity.PaneNode{ID: "top", Pane: &entity.Pane{ID: "top"}}
74+ bottom := &entity.PaneNode{ID: "bottom", Pane: &entity.Pane{ID: "bottom"}}
75+ root := &entity.PaneNode{
76+ ID: "split",
77+ SplitDir: entity.SplitVertical,
78+ SplitRatio: 0.5,
79+ Children: []*entity.PaneNode{top, bottom},
80+ }
81+ top.Parent = root
82+ bottom.Parent = root
83+
84+ ws := &entity.Workspace{Root: root, ActivePaneID: "bottom"}
85+
86+ // Smart increase should grow the active (bottom) pane: decrease SplitRatio.
87+ if err := uc.Resize(ctx, ws, bottom, ResizeIncrease, 5.0, 10.0); err != nil {
88+ t.Fatalf("unexpected error: %v", err)
89+ }
90+ if got, want := root.SplitRatio, 0.45; got != want {
91+ t.Fatalf("split ratio = %v, want %v", got, want)
92+ }
93+
94+ // Smart decrease should shrink the active pane: increase SplitRatio.
95+ if err := uc.Resize(ctx, ws, bottom, ResizeDecrease, 5.0, 10.0); err != nil {
96+ t.Fatalf("unexpected error: %v", err)
97+ }
98+ if got, want := root.SplitRatio, 0.5; got != want {
99+ t.Fatalf("split ratio = %v, want %v", got, want)
100+ }
101+}
102+
103+func TestManagePanesUseCase_Resize_ClampsToMinPanePercent(t *testing.T) {
104+ uc := NewManagePanesUseCase(func() string { return "id" })
105+ ctx := context.Background()
106+
107+ left := &entity.PaneNode{ID: "left", Pane: &entity.Pane{ID: "left"}}
108+ right := &entity.PaneNode{ID: "right", Pane: &entity.Pane{ID: "right"}}
109+ root := &entity.PaneNode{
110+ ID: "split",
111+ SplitDir: entity.SplitHorizontal,
112+ SplitRatio: 0.9,
113+ Children: []*entity.PaneNode{left, right},
114+ }
115+ left.Parent = root
116+ right.Parent = root
117+
118+ ws := &entity.Workspace{Root: root, ActivePaneID: "left"}
119+
120+ // maxRatio = 1 - (minPanePercent/100) = 0.9
121+ if err := uc.Resize(ctx, ws, left, ResizeIncreaseRight, 5.0, 10.0); err != nil {
122+ t.Fatalf("unexpected error: %v", err)
123+ }
124+ if got, want := root.SplitRatio, 0.9; got != want {
125+ t.Fatalf("split ratio = %v, want %v", got, want)
126+ }
127+}
128+
129+func TestManagePanesUseCase_Resize_TargetsStackContainer(t *testing.T) {
130+ uc := NewManagePanesUseCase(func() string { return "id" })
131+ ctx := context.Background()
132+
133+ stackLeaf1 := &entity.PaneNode{ID: "s1", Pane: &entity.Pane{ID: "s1"}}
134+ stackLeaf2 := &entity.PaneNode{ID: "s2", Pane: &entity.Pane{ID: "s2"}}
135+ stack := &entity.PaneNode{
136+ ID: "stack",
137+ IsStacked: true,
138+ ActiveStackIndex: 0,
139+ Children: []*entity.PaneNode{stackLeaf1, stackLeaf2},
140+ }
141+ stackLeaf1.Parent = stack
142+ stackLeaf2.Parent = stack
143+
144+ right := &entity.PaneNode{ID: "right", Pane: &entity.Pane{ID: "right"}}
145+ root := &entity.PaneNode{
146+ ID: "split",
147+ SplitDir: entity.SplitHorizontal,
148+ SplitRatio: 0.5,
149+ Children: []*entity.PaneNode{stack, right},
150+ }
151+ stack.Parent = root
152+ right.Parent = root
153+
154+ ws := &entity.Workspace{Root: root, ActivePaneID: "s1"}
155+
156+ // Pass the leaf inside the stack; Resize should act on the split above the stack.
157+ if err := uc.Resize(ctx, ws, stackLeaf1, ResizeIncreaseRight, 5.0, 10.0); err != nil {
158+ t.Fatalf("unexpected error: %v", err)
159+ }
160+ if got, want := root.SplitRatio, 0.55; got != want {
161+ t.Fatalf("split ratio = %v, want %v", got, want)
162+ }
163+}
@@ -0,0 +1,163 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+
8+ "github.com/bnema/dumber/internal/domain/entity"
9+)
10+
11+func TestManagePanesUseCase_Resize_Errors(t *testing.T) {
12+ uc := NewManagePanesUseCase(func() string { return "id" })
13+
14+ ctx := context.Background()
15+
16+ err := uc.Resize(ctx, nil, nil, ResizeIncreaseDown, 5, 10)
17+ if err == nil {
18+ t.Fatalf("expected error when workspace is nil")
19+ }
20+
21+ ws := &entity.Workspace{}
22+ err = uc.Resize(ctx, ws, nil, ResizeIncreaseDown, 5, 10)
23+ if err == nil {
24+ t.Fatalf("expected error when pane node is nil")
25+ }
26+
27+ // Root nil should return ErrNothingToResize.
28+ leaf := &entity.PaneNode{ID: "p1", Pane: &entity.Pane{ID: "p1"}}
29+ err = uc.Resize(ctx, ws, leaf, ResizeIncreaseDown, 5, 10)
30+ if !errors.Is(err, ErrNothingToResize) {
31+ t.Fatalf("expected ErrNothingToResize, got %v", err)
32+ }
33+}
34+
35+func TestManagePanesUseCase_Resize_VerticalDividerMove(t *testing.T) {
36+ uc := NewManagePanesUseCase(func() string { return "id" })
37+ ctx := context.Background()
38+
39+ top := &entity.PaneNode{ID: "top", Pane: &entity.Pane{ID: "top"}}
40+ bottom := &entity.PaneNode{ID: "bottom", Pane: &entity.Pane{ID: "bottom"}}
41+ root := &entity.PaneNode{
42+ ID: "split",
43+ SplitDir: entity.SplitVertical,
44+ SplitRatio: 0.5,
45+ Children: []*entity.PaneNode{top, bottom},
46+ }
47+ top.Parent = root
48+ bottom.Parent = root
49+
50+ ws := &entity.Workspace{Root: root, ActivePaneID: "bottom"}
51+
52+ // Moving divider down increases the first child's ratio.
53+ if err := uc.Resize(ctx, ws, bottom, ResizeIncreaseDown, 5.0, 10.0); err != nil {
54+ t.Fatalf("unexpected error: %v", err)
55+ }
56+ if got, want := root.SplitRatio, 0.55; got != want {
57+ t.Fatalf("split ratio = %v, want %v", got, want)
58+ }
59+
60+ // Moving divider up decreases the first child's ratio.
61+ if err := uc.Resize(ctx, ws, bottom, ResizeIncreaseUp, 5.0, 10.0); err != nil {
62+ t.Fatalf("unexpected error: %v", err)
63+ }
64+ if got, want := root.SplitRatio, 0.5; got != want {
65+ t.Fatalf("split ratio = %v, want %v", got, want)
66+ }
67+}
68+
69+func TestManagePanesUseCase_Resize_SmartResizeGrowsAndShrinksActivePane(t *testing.T) {
70+ uc := NewManagePanesUseCase(func() string { return "id" })
71+ ctx := context.Background()
72+
73+ top := &entity.PaneNode{ID: "top", Pane: &entity.Pane{ID: "top"}}
74+ bottom := &entity.PaneNode{ID: "bottom", Pane: &entity.Pane{ID: "bottom"}}
75+ root := &entity.PaneNode{
76+ ID: "split",
77+ SplitDir: entity.SplitVertical,
78+ SplitRatio: 0.5,
79+ Children: []*entity.PaneNode{top, bottom},
80+ }
81+ top.Parent = root
82+ bottom.Parent = root
83+
84+ ws := &entity.Workspace{Root: root, ActivePaneID: "bottom"}
85+
86+ // Smart increase should grow the active (bottom) pane: decrease SplitRatio.
87+ if err := uc.Resize(ctx, ws, bottom, ResizeIncrease, 5.0, 10.0); err != nil {
88+ t.Fatalf("unexpected error: %v", err)
89+ }
90+ if got, want := root.SplitRatio, 0.45; got != want {
91+ t.Fatalf("split ratio = %v, want %v", got, want)
92+ }
93+
94+ // Smart decrease should shrink the active pane: increase SplitRatio.
95+ if err := uc.Resize(ctx, ws, bottom, ResizeDecrease, 5.0, 10.0); err != nil {
96+ t.Fatalf("unexpected error: %v", err)
97+ }
98+ if got, want := root.SplitRatio, 0.5; got != want {
99+ t.Fatalf("split ratio = %v, want %v", got, want)
100+ }
101+}
102+
103+func TestManagePanesUseCase_Resize_ClampsToMinPanePercent(t *testing.T) {
104+ uc := NewManagePanesUseCase(func() string { return "id" })
105+ ctx := context.Background()
106+
107+ left := &entity.PaneNode{ID: "left", Pane: &entity.Pane{ID: "left"}}
108+ right := &entity.PaneNode{ID: "right", Pane: &entity.Pane{ID: "right"}}
109+ root := &entity.PaneNode{
110+ ID: "split",
111+ SplitDir: entity.SplitHorizontal,
112+ SplitRatio: 0.9,
113+ Children: []*entity.PaneNode{left, right},
114+ }
115+ left.Parent = root
116+ right.Parent = root
117+
118+ ws := &entity.Workspace{Root: root, ActivePaneID: "left"}
119+
120+ // maxRatio = 1 - (minPanePercent/100) = 0.9
121+ if err := uc.Resize(ctx, ws, left, ResizeIncreaseRight, 5.0, 10.0); err != nil {
122+ t.Fatalf("unexpected error: %v", err)
123+ }
124+ if got, want := root.SplitRatio, 0.9; got != want {
125+ t.Fatalf("split ratio = %v, want %v", got, want)
126+ }
127+}
128+
129+func TestManagePanesUseCase_Resize_TargetsStackContainer(t *testing.T) {
130+ uc := NewManagePanesUseCase(func() string { return "id" })
131+ ctx := context.Background()
132+
133+ stackLeaf1 := &entity.PaneNode{ID: "s1", Pane: &entity.Pane{ID: "s1"}}
134+ stackLeaf2 := &entity.PaneNode{ID: "s2", Pane: &entity.Pane{ID: "s2"}}
135+ stack := &entity.PaneNode{
136+ ID: "stack",
137+ IsStacked: true,
138+ ActiveStackIndex: 0,
139+ Children: []*entity.PaneNode{stackLeaf1, stackLeaf2},
140+ }
141+ stackLeaf1.Parent = stack
142+ stackLeaf2.Parent = stack
143+
144+ right := &entity.PaneNode{ID: "right", Pane: &entity.Pane{ID: "right"}}
145+ root := &entity.PaneNode{
146+ ID: "split",
147+ SplitDir: entity.SplitHorizontal,
148+ SplitRatio: 0.5,
149+ Children: []*entity.PaneNode{stack, right},
150+ }
151+ stack.Parent = root
152+ right.Parent = root
153+
154+ ws := &entity.Workspace{Root: root, ActivePaneID: "s1"}
155+
156+ // Pass the leaf inside the stack; Resize should act on the split above the stack.
157+ if err := uc.Resize(ctx, ws, stackLeaf1, ResizeIncreaseRight, 5.0, 10.0); err != nil {
158+ t.Fatalf("unexpected error: %v", err)
159+ }
160+ if got, want := root.SplitRatio, 0.55; got != want {
161+ t.Fatalf("split ratio = %v, want %v", got, want)
162+ }
163+}
M internal/infrastructure/config/defaults.go +36 -6
@@ -19,12 +19,16 @@ const (
1919 defaultOmniboxInitialBehavior = "recent"
2020
2121 // Workspace defaults
22- defaultPaneActivationShortcut = "ctrl+p"
23- defaultPaneTimeoutMilliseconds = 3000
24- defaultTabActivationShortcut = "ctrl+t"
25- defaultTabTimeoutMilliseconds = 3000
26- defaultTabBarPosition = "bottom"
27- defaultPopupPlacement = "right"
22+ defaultPaneActivationShortcut = "ctrl+p"
23+ defaultPaneTimeoutMilliseconds = 3000
24+ defaultTabActivationShortcut = "ctrl+t"
25+ defaultTabTimeoutMilliseconds = 3000
26+ defaultResizeActivationShortcut = "ctrl+n"
27+ defaultResizeTimeoutMilliseconds = 3000
28+ defaultResizeStepPercent = 5.0
29+ defaultResizeMinPanePercent = 10.0
30+ defaultTabBarPosition = "bottom"
31+ defaultPopupPlacement = "right"
2832
2933 // Session defaults
3034 defaultSessionActivationShortcut = "ctrl+o"
@@ -50,6 +54,10 @@ const (
5054 defaultSessionModeBorderWidth = 4
5155 defaultSessionModeBorderColor = "#9B59B6" // Purple for session mode indicator
5256
57+ // Resize mode border (Ctrl+N - overlay)
58+ defaultResizeModeBorderWidth = 4
59+ defaultResizeModeBorderColor = "#00D4AA" // Cyan/teal for resize mode indicator
60+
5361 // Other styling
5462 defaultTransitionDuration = 120
5563 defaultUIScale = 1.0 // UI scale multiplier (1.0 = 100%, 1.2 = 120%)
@@ -174,6 +182,26 @@ func DefaultConfig() *Config {
174182 "cancel": {"escape"},
175183 },
176184 },
185+ ResizeMode: ResizeModeConfig{
186+ ActivationShortcut: defaultResizeActivationShortcut,
187+ TimeoutMilliseconds: defaultResizeTimeoutMilliseconds,
188+ StepPercent: defaultResizeStepPercent,
189+ MinPanePercent: defaultResizeMinPanePercent,
190+ Actions: map[string][]string{
191+ "resize-increase-left": {"h", "arrowleft"},
192+ "resize-increase-down": {"j", "arrowdown"},
193+ "resize-increase-up": {"k", "arrowup"},
194+ "resize-increase-right": {"l", "arrowright"},
195+ "resize-decrease-left": {"H"},
196+ "resize-decrease-down": {"J"},
197+ "resize-decrease-up": {"K"},
198+ "resize-decrease-right": {"L"},
199+ "resize-increase": {"+", "="},
200+ "resize-decrease": {"-"},
201+ "confirm": {"enter"},
202+ "cancel": {"escape"},
203+ },
204+ },
177205 Shortcuts: GlobalShortcutsConfig{
178206 ClosePane: "ctrl+w",
179207 NextTab: "ctrl+tab",
@@ -199,6 +227,8 @@ func DefaultConfig() *Config {
199227 TabModeBorderColor: defaultTabModeBorderColor,
200228 SessionModeBorderWidth: defaultSessionModeBorderWidth,
201229 SessionModeBorderColor: defaultSessionModeBorderColor,
230+ ResizeModeBorderWidth: defaultResizeModeBorderWidth,
231+ ResizeModeBorderColor: defaultResizeModeBorderColor,
202232 TransitionDuration: defaultTransitionDuration,
203233 },
204234 },
@@ -19,12 +19,16 @@ const (
19 defaultOmniboxInitialBehavior = "recent"19 defaultOmniboxInitialBehavior = "recent"
20 20
21 // Workspace defaults21 // Workspace defaults
22- defaultPaneActivationShortcut = "ctrl+p"22+ defaultPaneActivationShortcut = "ctrl+p"
23- defaultPaneTimeoutMilliseconds = 300023+ defaultPaneTimeoutMilliseconds = 3000
24- defaultTabActivationShortcut = "ctrl+t"24+ defaultTabActivationShortcut = "ctrl+t"
25- defaultTabTimeoutMilliseconds = 300025+ defaultTabTimeoutMilliseconds = 3000
26- defaultTabBarPosition = "bottom"26+ defaultResizeActivationShortcut = "ctrl+n"
27- defaultPopupPlacement = "right"27+ defaultResizeTimeoutMilliseconds = 3000
28+ defaultResizeStepPercent = 5.0
29+ defaultResizeMinPanePercent = 10.0
30+ defaultTabBarPosition = "bottom"
31+ defaultPopupPlacement = "right"
28 32
29 // Session defaults33 // Session defaults
30 defaultSessionActivationShortcut = "ctrl+o"34 defaultSessionActivationShortcut = "ctrl+o"
@@ -50,6 +54,10 @@ const (
50 defaultSessionModeBorderWidth = 454 defaultSessionModeBorderWidth = 4
51 defaultSessionModeBorderColor = "#9B59B6" // Purple for session mode indicator55 defaultSessionModeBorderColor = "#9B59B6" // Purple for session mode indicator
52 56
57+ // Resize mode border (Ctrl+N - overlay)
58+ defaultResizeModeBorderWidth = 4
59+ defaultResizeModeBorderColor = "#00D4AA" // Cyan/teal for resize mode indicator
60+
53 // Other styling61 // Other styling
54 defaultTransitionDuration = 12062 defaultTransitionDuration = 120
55 defaultUIScale = 1.0 // UI scale multiplier (1.0 = 100%, 1.2 = 120%)63 defaultUIScale = 1.0 // UI scale multiplier (1.0 = 100%, 1.2 = 120%)
@@ -174,6 +182,26 @@ func DefaultConfig() *Config {
174 "cancel": {"escape"},182 "cancel": {"escape"},
175 },183 },
176 },184 },
185+ ResizeMode: ResizeModeConfig{
186+ ActivationShortcut: defaultResizeActivationShortcut,
187+ TimeoutMilliseconds: defaultResizeTimeoutMilliseconds,
188+ StepPercent: defaultResizeStepPercent,
189+ MinPanePercent: defaultResizeMinPanePercent,
190+ Actions: map[string][]string{
191+ "resize-increase-left": {"h", "arrowleft"},
192+ "resize-increase-down": {"j", "arrowdown"},
193+ "resize-increase-up": {"k", "arrowup"},
194+ "resize-increase-right": {"l", "arrowright"},
195+ "resize-decrease-left": {"H"},
196+ "resize-decrease-down": {"J"},
197+ "resize-decrease-up": {"K"},
198+ "resize-decrease-right": {"L"},
199+ "resize-increase": {"+", "="},
200+ "resize-decrease": {"-"},
201+ "confirm": {"enter"},
202+ "cancel": {"escape"},
203+ },
204+ },
177 Shortcuts: GlobalShortcutsConfig{205 Shortcuts: GlobalShortcutsConfig{
178 ClosePane: "ctrl+w",206 ClosePane: "ctrl+w",
179 NextTab: "ctrl+tab",207 NextTab: "ctrl+tab",
@@ -199,6 +227,8 @@ func DefaultConfig() *Config {
199 TabModeBorderColor: defaultTabModeBorderColor,227 TabModeBorderColor: defaultTabModeBorderColor,
200 SessionModeBorderWidth: defaultSessionModeBorderWidth,228 SessionModeBorderWidth: defaultSessionModeBorderWidth,
201 SessionModeBorderColor: defaultSessionModeBorderColor,229 SessionModeBorderColor: defaultSessionModeBorderColor,
230+ ResizeModeBorderWidth: defaultResizeModeBorderWidth,
231+ ResizeModeBorderColor: defaultResizeModeBorderColor,
202 TransitionDuration: defaultTransitionDuration,232 TransitionDuration: defaultTransitionDuration,
203 },233 },
204 },234 },
M internal/infrastructure/config/loader.go +7 -0
@@ -386,6 +386,11 @@ func (m *Manager) setWorkspaceDefaults(defaults *Config) {
386386 m.viper.SetDefault("workspace.tab_mode.activation_shortcut", defaults.Workspace.TabMode.ActivationShortcut)
387387 m.viper.SetDefault("workspace.tab_mode.timeout_ms", defaults.Workspace.TabMode.TimeoutMilliseconds)
388388 m.viper.SetDefault("workspace.tab_mode.actions", defaults.Workspace.TabMode.Actions)
389+ m.viper.SetDefault("workspace.resize_mode.activation_shortcut", defaults.Workspace.ResizeMode.ActivationShortcut)
390+ m.viper.SetDefault("workspace.resize_mode.timeout_ms", defaults.Workspace.ResizeMode.TimeoutMilliseconds)
391+ m.viper.SetDefault("workspace.resize_mode.step_percent", defaults.Workspace.ResizeMode.StepPercent)
392+ m.viper.SetDefault("workspace.resize_mode.min_pane_percent", defaults.Workspace.ResizeMode.MinPanePercent)
393+ m.viper.SetDefault("workspace.resize_mode.actions", defaults.Workspace.ResizeMode.Actions)
389394 m.viper.SetDefault("workspace.shortcuts.close_pane", defaults.Workspace.Shortcuts.ClosePane)
390395 m.viper.SetDefault("workspace.shortcuts.next_tab", defaults.Workspace.Shortcuts.NextTab)
391396 m.viper.SetDefault("workspace.shortcuts.previous_tab", defaults.Workspace.Shortcuts.PreviousTab)
@@ -406,6 +411,8 @@ func (m *Manager) setWorkspaceDefaults(defaults *Config) {
406411 m.viper.SetDefault("workspace.styling.tab_mode_border_color", defaults.Workspace.Styling.TabModeBorderColor)
407412 m.viper.SetDefault("workspace.styling.session_mode_border_width", defaults.Workspace.Styling.SessionModeBorderWidth)
408413 m.viper.SetDefault("workspace.styling.session_mode_border_color", defaults.Workspace.Styling.SessionModeBorderColor)
414+ m.viper.SetDefault("workspace.styling.resize_mode_border_width", defaults.Workspace.Styling.ResizeModeBorderWidth)
415+ m.viper.SetDefault("workspace.styling.resize_mode_border_color", defaults.Workspace.Styling.ResizeModeBorderColor)
409416 m.viper.SetDefault("workspace.styling.transition_duration", defaults.Workspace.Styling.TransitionDuration)
410417 }
411418
@@ -386,6 +386,11 @@ func (m *Manager) setWorkspaceDefaults(defaults *Config) {
386 m.viper.SetDefault("workspace.tab_mode.activation_shortcut", defaults.Workspace.TabMode.ActivationShortcut)386 m.viper.SetDefault("workspace.tab_mode.activation_shortcut", defaults.Workspace.TabMode.ActivationShortcut)
387 m.viper.SetDefault("workspace.tab_mode.timeout_ms", defaults.Workspace.TabMode.TimeoutMilliseconds)387 m.viper.SetDefault("workspace.tab_mode.timeout_ms", defaults.Workspace.TabMode.TimeoutMilliseconds)
388 m.viper.SetDefault("workspace.tab_mode.actions", defaults.Workspace.TabMode.Actions)388 m.viper.SetDefault("workspace.tab_mode.actions", defaults.Workspace.TabMode.Actions)
389+ m.viper.SetDefault("workspace.resize_mode.activation_shortcut", defaults.Workspace.ResizeMode.ActivationShortcut)
390+ m.viper.SetDefault("workspace.resize_mode.timeout_ms", defaults.Workspace.ResizeMode.TimeoutMilliseconds)
391+ m.viper.SetDefault("workspace.resize_mode.step_percent", defaults.Workspace.ResizeMode.StepPercent)
392+ m.viper.SetDefault("workspace.resize_mode.min_pane_percent", defaults.Workspace.ResizeMode.MinPanePercent)
393+ m.viper.SetDefault("workspace.resize_mode.actions", defaults.Workspace.ResizeMode.Actions)
389 m.viper.SetDefault("workspace.shortcuts.close_pane", defaults.Workspace.Shortcuts.ClosePane)394 m.viper.SetDefault("workspace.shortcuts.close_pane", defaults.Workspace.Shortcuts.ClosePane)
390 m.viper.SetDefault("workspace.shortcuts.next_tab", defaults.Workspace.Shortcuts.NextTab)395 m.viper.SetDefault("workspace.shortcuts.next_tab", defaults.Workspace.Shortcuts.NextTab)
391 m.viper.SetDefault("workspace.shortcuts.previous_tab", defaults.Workspace.Shortcuts.PreviousTab)396 m.viper.SetDefault("workspace.shortcuts.previous_tab", defaults.Workspace.Shortcuts.PreviousTab)
@@ -406,6 +411,8 @@ func (m *Manager) setWorkspaceDefaults(defaults *Config) {
406 m.viper.SetDefault("workspace.styling.tab_mode_border_color", defaults.Workspace.Styling.TabModeBorderColor)411 m.viper.SetDefault("workspace.styling.tab_mode_border_color", defaults.Workspace.Styling.TabModeBorderColor)
407 m.viper.SetDefault("workspace.styling.session_mode_border_width", defaults.Workspace.Styling.SessionModeBorderWidth)412 m.viper.SetDefault("workspace.styling.session_mode_border_width", defaults.Workspace.Styling.SessionModeBorderWidth)
408 m.viper.SetDefault("workspace.styling.session_mode_border_color", defaults.Workspace.Styling.SessionModeBorderColor)413 m.viper.SetDefault("workspace.styling.session_mode_border_color", defaults.Workspace.Styling.SessionModeBorderColor)
414+ m.viper.SetDefault("workspace.styling.resize_mode_border_width", defaults.Workspace.Styling.ResizeModeBorderWidth)
415+ m.viper.SetDefault("workspace.styling.resize_mode_border_color", defaults.Workspace.Styling.ResizeModeBorderColor)
409 m.viper.SetDefault("workspace.styling.transition_duration", defaults.Workspace.Styling.TransitionDuration)416 m.viper.SetDefault("workspace.styling.transition_duration", defaults.Workspace.Styling.TransitionDuration)
410 }417 }
411 418
M internal/infrastructure/config/schema.go +29 -0
@@ -299,6 +299,8 @@ type WorkspaceConfig struct {
299299 PaneMode PaneModeConfig `mapstructure:"pane_mode" yaml:"pane_mode" toml:"pane_mode" json:"pane_mode"`
300300 // TabMode defines modal tab behavior and bindings (Alt+T).
301301 TabMode TabModeConfig `mapstructure:"tab_mode" yaml:"tab_mode" toml:"tab_mode" json:"tab_mode"`
302+ // ResizeMode defines modal pane resizing behavior and bindings (Ctrl+N).
303+ ResizeMode ResizeModeConfig `mapstructure:"resize_mode" yaml:"resize_mode" toml:"resize_mode" json:"resize_mode"`
302304 // Shortcuts holds global (non-modal) keyboard shortcuts.
303305 Shortcuts GlobalShortcutsConfig `mapstructure:"shortcuts" yaml:"shortcuts" toml:"shortcuts" json:"shortcuts"`
304306 // TabBarPosition determines tab bar placement: "top" or "bottom".
@@ -349,6 +351,27 @@ func (t *TabModeConfig) GetKeyBindings() map[string]string {
349351 return keyToAction
350352 }
351353
354+// ResizeModeConfig defines modal behavior for resizing panes (Zellij-style).
355+type ResizeModeConfig struct {
356+ ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` //nolint:lll // struct tags must stay on one line
357+ TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" toml:"timeout_ms" json:"timeout_ms"`
358+ StepPercent float64 `mapstructure:"step_percent" yaml:"step_percent" toml:"step_percent" json:"step_percent"`
359+ MinPanePercent float64 `mapstructure:"min_pane_percent" yaml:"min_pane_percent" toml:"min_pane_percent" json:"min_pane_percent"` //nolint:lll // struct tags must stay on one line
360+ Actions map[string][]string `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"`
361+}
362+
363+// GetKeyBindings returns an inverted map for O(1) key→action lookup.
364+// This is built from the action→keys structure in the config.
365+func (r *ResizeModeConfig) GetKeyBindings() map[string]string {
366+ keyToAction := make(map[string]string)
367+ for action, keys := range r.Actions {
368+ for _, key := range keys {
369+ keyToAction[key] = action
370+ }
371+ }
372+ return keyToAction
373+}
374+
352375 // GlobalShortcutsConfig defines global shortcuts (always active, not modal).
353376 type GlobalShortcutsConfig struct {
354377 ClosePane string `mapstructure:"close_pane" yaml:"close_pane" toml:"close_pane" json:"close_pane"`
@@ -422,6 +445,12 @@ type WorkspaceStylingConfig struct {
422445 // Defaults to "#9B59B6" (purple) if not set - MUST be different from other mode colors
423446 SessionModeBorderColor string `mapstructure:"session_mode_border_color" yaml:"session_mode_border_color" toml:"session_mode_border_color" json:"session_mode_border_color"` //nolint:lll // struct tags must stay on one line
424447
448+ // ResizeModeBorderWidth in pixels for resize mode indicator border (Ctrl+N overlay)
449+ ResizeModeBorderWidth int `mapstructure:"resize_mode_border_width" yaml:"resize_mode_border_width" toml:"resize_mode_border_width" json:"resize_mode_border_width"` //nolint:lll // struct tags must stay on one line
450+ // ResizeModeBorderColor for the resize mode indicator border (CSS color value or theme variable)
451+ // Defaults to "#00D4AA" (cyan/teal) if not set - MUST be different from other mode colors
452+ ResizeModeBorderColor string `mapstructure:"resize_mode_border_color" yaml:"resize_mode_border_color" toml:"resize_mode_border_color" json:"resize_mode_border_color"` //nolint:lll // struct tags must stay on one line
453+
425454 // TransitionDuration in milliseconds for border animations
426455 TransitionDuration int `mapstructure:"transition_duration" yaml:"transition_duration" toml:"transition_duration" json:"transition_duration"` //nolint:lll // struct tags must stay on one line
427456 }
@@ -299,6 +299,8 @@ type WorkspaceConfig struct {
299 PaneMode PaneModeConfig `mapstructure:"pane_mode" yaml:"pane_mode" toml:"pane_mode" json:"pane_mode"`299 PaneMode PaneModeConfig `mapstructure:"pane_mode" yaml:"pane_mode" toml:"pane_mode" json:"pane_mode"`
300 // TabMode defines modal tab behavior and bindings (Alt+T).300 // TabMode defines modal tab behavior and bindings (Alt+T).
301 TabMode TabModeConfig `mapstructure:"tab_mode" yaml:"tab_mode" toml:"tab_mode" json:"tab_mode"`301 TabMode TabModeConfig `mapstructure:"tab_mode" yaml:"tab_mode" toml:"tab_mode" json:"tab_mode"`
302+ // ResizeMode defines modal pane resizing behavior and bindings (Ctrl+N).
303+ ResizeMode ResizeModeConfig `mapstructure:"resize_mode" yaml:"resize_mode" toml:"resize_mode" json:"resize_mode"`
302 // Shortcuts holds global (non-modal) keyboard shortcuts.304 // Shortcuts holds global (non-modal) keyboard shortcuts.
303 Shortcuts GlobalShortcutsConfig `mapstructure:"shortcuts" yaml:"shortcuts" toml:"shortcuts" json:"shortcuts"`305 Shortcuts GlobalShortcutsConfig `mapstructure:"shortcuts" yaml:"shortcuts" toml:"shortcuts" json:"shortcuts"`
304 // TabBarPosition determines tab bar placement: "top" or "bottom".306 // TabBarPosition determines tab bar placement: "top" or "bottom".
@@ -349,6 +351,27 @@ func (t *TabModeConfig) GetKeyBindings() map[string]string {
349 return keyToAction351 return keyToAction
350 }352 }
351 353
354+// ResizeModeConfig defines modal behavior for resizing panes (Zellij-style).
355+type ResizeModeConfig struct {
356+ ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` //nolint:lll // struct tags must stay on one line
357+ TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" toml:"timeout_ms" json:"timeout_ms"`
358+ StepPercent float64 `mapstructure:"step_percent" yaml:"step_percent" toml:"step_percent" json:"step_percent"`
359+ MinPanePercent float64 `mapstructure:"min_pane_percent" yaml:"min_pane_percent" toml:"min_pane_percent" json:"min_pane_percent"` //nolint:lll // struct tags must stay on one line
360+ Actions map[string][]string `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"`
361+}
362+
363+// GetKeyBindings returns an inverted map for O(1) key→action lookup.
364+// This is built from the action→keys structure in the config.
365+func (r *ResizeModeConfig) GetKeyBindings() map[string]string {
366+ keyToAction := make(map[string]string)
367+ for action, keys := range r.Actions {
368+ for _, key := range keys {
369+ keyToAction[key] = action
370+ }
371+ }
372+ return keyToAction
373+}
374+
352 // GlobalShortcutsConfig defines global shortcuts (always active, not modal).375 // GlobalShortcutsConfig defines global shortcuts (always active, not modal).
353 type GlobalShortcutsConfig struct {376 type GlobalShortcutsConfig struct {
354 ClosePane string `mapstructure:"close_pane" yaml:"close_pane" toml:"close_pane" json:"close_pane"`377 ClosePane string `mapstructure:"close_pane" yaml:"close_pane" toml:"close_pane" json:"close_pane"`
@@ -422,6 +445,12 @@ type WorkspaceStylingConfig struct {
422 // Defaults to "#9B59B6" (purple) if not set - MUST be different from other mode colors445 // Defaults to "#9B59B6" (purple) if not set - MUST be different from other mode colors
423 SessionModeBorderColor string `mapstructure:"session_mode_border_color" yaml:"session_mode_border_color" toml:"session_mode_border_color" json:"session_mode_border_color"` //nolint:lll // struct tags must stay on one line446 SessionModeBorderColor string `mapstructure:"session_mode_border_color" yaml:"session_mode_border_color" toml:"session_mode_border_color" json:"session_mode_border_color"` //nolint:lll // struct tags must stay on one line
424 447
448+ // ResizeModeBorderWidth in pixels for resize mode indicator border (Ctrl+N overlay)
449+ ResizeModeBorderWidth int `mapstructure:"resize_mode_border_width" yaml:"resize_mode_border_width" toml:"resize_mode_border_width" json:"resize_mode_border_width"` //nolint:lll // struct tags must stay on one line
450+ // ResizeModeBorderColor for the resize mode indicator border (CSS color value or theme variable)
451+ // Defaults to "#00D4AA" (cyan/teal) if not set - MUST be different from other mode colors
452+ ResizeModeBorderColor string `mapstructure:"resize_mode_border_color" yaml:"resize_mode_border_color" toml:"resize_mode_border_color" json:"resize_mode_border_color"` //nolint:lll // struct tags must stay on one line
453+
425 // TransitionDuration in milliseconds for border animations454 // TransitionDuration in milliseconds for border animations
426 TransitionDuration int `mapstructure:"transition_duration" yaml:"transition_duration" toml:"transition_duration" json:"transition_duration"` //nolint:lll // struct tags must stay on one line455 TransitionDuration int `mapstructure:"transition_duration" yaml:"transition_duration" toml:"transition_duration" json:"transition_duration"` //nolint:lll // struct tags must stay on one line
427 }456 }
M internal/ui/app.go +70 -3
@@ -70,6 +70,8 @@ type App struct {
7070 focusMgr *focus.Manager
7171 borderMgr *focus.BorderManager
7272
73+ resizeModeBorderTarget layout.Widget
74+
7375 // Omnibox configuration (omnibox is created per workspace view)
7476 omniboxCfg component.OmniboxConfig
7577 // Find bar configuration (find bar is created per workspace view)
@@ -910,12 +912,61 @@ func (a *App) handleModeChange(ctx context.Context, from, to input.Mode) {
910912 log := logging.FromContext(ctx)
911913 log.Debug().Str("from", from.String()).Str("to", to.String()).Msg("input mode changed")
912914
913- // Update border overlay visibility based on mode
915+ if from == input.ModeResize && to != input.ModeResize {
916+ a.clearResizeModeBorder()
917+ }
918+ if to == input.ModeResize {
919+ a.applyResizeModeBorder(ctx, a.activeWorkspace())
920+ }
921+
922+ // Update global border overlay visibility based on mode.
923+ // Note: resize mode border is handled per-pane (stack container), not via global overlay.
914924 if a.borderMgr != nil {
915925 a.borderMgr.OnModeChange(ctx, from, to)
916926 }
917927 }
918928
929+func (a *App) clearResizeModeBorder() {
930+ if a.resizeModeBorderTarget != nil {
931+ a.resizeModeBorderTarget.RemoveCssClass("resize-mode-active")
932+ a.resizeModeBorderTarget = nil
933+ }
934+}
935+
936+func (a *App) applyResizeModeBorder(ctx context.Context, ws *entity.Workspace) {
937+ wsView := a.activeWorkspaceView()
938+ if wsView == nil || ws == nil {
939+ a.clearResizeModeBorder()
940+ return
941+ }
942+
943+ paneID := ws.ActivePaneID
944+ if paneID == "" {
945+ a.clearResizeModeBorder()
946+ return
947+ }
948+
949+ // Important: wrap the whole stack container when the active pane is in a stack.
950+ target := wsView.GetStackContainerWidget(paneID)
951+ if target == nil {
952+ a.clearResizeModeBorder()
953+ return
954+ }
955+
956+ if !target.HasCssClass("resize-mode-active") {
957+ target.AddCssClass("resize-mode-active")
958+ }
959+
960+ if a.resizeModeBorderTarget != target {
961+ if a.resizeModeBorderTarget != nil {
962+ a.resizeModeBorderTarget.RemoveCssClass("resize-mode-active")
963+ } else {
964+ logging.FromContext(ctx).Debug().Str("pane_id", string(paneID)).Msg("resize mode border attached")
965+ }
966+ a.resizeModeBorderTarget = target
967+ }
968+}
969+
919970 // createWorkspaceView creates a WorkspaceView for a tab and attaches it to the content area.
920971 func (a *App) createWorkspaceView(ctx context.Context, tab *entity.Tab) {
921972 a.createWorkspaceViewWithoutAttach(ctx, tab)
@@ -961,14 +1012,30 @@ func (a *App) createWorkspaceViewWithoutAttach(ctx context.Context, tab *entity.
9611012 a.contentCoord.AttachToWorkspace(ctx, tab.Workspace, wsView)
9621013 }
9631014
964- // Note: Border overlay is attached to MainWindow, not per-workspace
965- // This ensures it's visible regardless of which tab is active
1015+ // Note: Mode borders for tab/pane/session are attached to MainWindow.
1016+ // Resize mode border is attached to the active pane's stack container.
9661017
9671018 // Set omnibox config for this workspace view
9681019 wsView.SetOmniboxConfig(a.omniboxCfg)
9691020 // Set find bar config for this workspace view
9701021 wsView.SetFindBarConfig(a.findBarCfg)
9711022
1023+ wsView.SetOnPaneFocused(func(paneID entity.PaneID) {
1024+ if a.keyboardHandler != nil && a.keyboardHandler.Mode() == input.ModeResize {
1025+ ws := a.activeWorkspace()
1026+ if ws != nil {
1027+ ws.ActivePaneID = paneID
1028+ }
1029+ a.applyResizeModeBorder(ctx, ws)
1030+ }
1031+ })
1032+
1033+ wsView.SetOnSplitRatioDragged(func(nodeID string, ratio float64) {
1034+ if a.wsCoord != nil {
1035+ _ = a.wsCoord.SetSplitRatio(ctx, nodeID, ratio)
1036+ }
1037+ })
1038+
9721039 // Store in map
9731040 a.workspaceViews[tab.ID] = wsView
9741041
@@ -70,6 +70,8 @@ type App struct {
70 focusMgr *focus.Manager70 focusMgr *focus.Manager
71 borderMgr *focus.BorderManager71 borderMgr *focus.BorderManager
72 72
73+ resizeModeBorderTarget layout.Widget
74+
73 // Omnibox configuration (omnibox is created per workspace view)75 // Omnibox configuration (omnibox is created per workspace view)
74 omniboxCfg component.OmniboxConfig76 omniboxCfg component.OmniboxConfig
75 // Find bar configuration (find bar is created per workspace view)77 // Find bar configuration (find bar is created per workspace view)
@@ -910,12 +912,61 @@ func (a *App) handleModeChange(ctx context.Context, from, to input.Mode) {
910 log := logging.FromContext(ctx)912 log := logging.FromContext(ctx)
911 log.Debug().Str("from", from.String()).Str("to", to.String()).Msg("input mode changed")913 log.Debug().Str("from", from.String()).Str("to", to.String()).Msg("input mode changed")
912 914
913- // Update border overlay visibility based on mode915+ if from == input.ModeResize && to != input.ModeResize {
916+ a.clearResizeModeBorder()
917+ }
918+ if to == input.ModeResize {
919+ a.applyResizeModeBorder(ctx, a.activeWorkspace())
920+ }
921+
922+ // Update global border overlay visibility based on mode.
923+ // Note: resize mode border is handled per-pane (stack container), not via global overlay.
914 if a.borderMgr != nil {924 if a.borderMgr != nil {
915 a.borderMgr.OnModeChange(ctx, from, to)925 a.borderMgr.OnModeChange(ctx, from, to)
916 }926 }
917 }927 }
918 928
929+func (a *App) clearResizeModeBorder() {
930+ if a.resizeModeBorderTarget != nil {
931+ a.resizeModeBorderTarget.RemoveCssClass("resize-mode-active")
932+ a.resizeModeBorderTarget = nil
933+ }
934+}
935+
936+func (a *App) applyResizeModeBorder(ctx context.Context, ws *entity.Workspace) {
937+ wsView := a.activeWorkspaceView()
938+ if wsView == nil || ws == nil {
939+ a.clearResizeModeBorder()
940+ return
941+ }
942+
943+ paneID := ws.ActivePaneID
944+ if paneID == "" {
945+ a.clearResizeModeBorder()
946+ return
947+ }
948+
949+ // Important: wrap the whole stack container when the active pane is in a stack.
950+ target := wsView.GetStackContainerWidget(paneID)
951+ if target == nil {
952+ a.clearResizeModeBorder()
953+ return
954+ }
955+
956+ if !target.HasCssClass("resize-mode-active") {
957+ target.AddCssClass("resize-mode-active")
958+ }
959+
960+ if a.resizeModeBorderTarget != target {
961+ if a.resizeModeBorderTarget != nil {
962+ a.resizeModeBorderTarget.RemoveCssClass("resize-mode-active")
963+ } else {
964+ logging.FromContext(ctx).Debug().Str("pane_id", string(paneID)).Msg("resize mode border attached")
965+ }
966+ a.resizeModeBorderTarget = target
967+ }
968+}
969+
919 // createWorkspaceView creates a WorkspaceView for a tab and attaches it to the content area.970 // createWorkspaceView creates a WorkspaceView for a tab and attaches it to the content area.
920 func (a *App) createWorkspaceView(ctx context.Context, tab *entity.Tab) {971 func (a *App) createWorkspaceView(ctx context.Context, tab *entity.Tab) {
921 a.createWorkspaceViewWithoutAttach(ctx, tab)972 a.createWorkspaceViewWithoutAttach(ctx, tab)
@@ -961,14 +1012,30 @@ func (a *App) createWorkspaceViewWithoutAttach(ctx context.Context, tab *entity.
961 a.contentCoord.AttachToWorkspace(ctx, tab.Workspace, wsView)1012 a.contentCoord.AttachToWorkspace(ctx, tab.Workspace, wsView)
962 }1013 }
963 1014
964- // Note: Border overlay is attached to MainWindow, not per-workspace1015+ // Note: Mode borders for tab/pane/session are attached to MainWindow.
965- // This ensures it's visible regardless of which tab is active1016+ // Resize mode border is attached to the active pane's stack container.
966 1017
967 // Set omnibox config for this workspace view1018 // Set omnibox config for this workspace view
968 wsView.SetOmniboxConfig(a.omniboxCfg)1019 wsView.SetOmniboxConfig(a.omniboxCfg)
969 // Set find bar config for this workspace view1020 // Set find bar config for this workspace view
970 wsView.SetFindBarConfig(a.findBarCfg)1021 wsView.SetFindBarConfig(a.findBarCfg)
971 1022
1023+ wsView.SetOnPaneFocused(func(paneID entity.PaneID) {
1024+ if a.keyboardHandler != nil && a.keyboardHandler.Mode() == input.ModeResize {
1025+ ws := a.activeWorkspace()
1026+ if ws != nil {
1027+ ws.ActivePaneID = paneID
1028+ }
1029+ a.applyResizeModeBorder(ctx, ws)
1030+ }
1031+ })
1032+
1033+ wsView.SetOnSplitRatioDragged(func(nodeID string, ratio float64) {
1034+ if a.wsCoord != nil {
1035+ _ = a.wsCoord.SetSplitRatio(ctx, nodeID, ratio)
1036+ }
1037+ })
1038+
972 // Store in map1039 // Store in map
973 a.workspaceViews[tab.ID] = wsView1040 a.workspaceViews[tab.ID] = wsView
974 1041
M internal/ui/component/workspace_view.go +17 -1
@@ -47,7 +47,8 @@ type WorkspaceView struct {
4747 workspace *entity.Workspace
4848 paneViews map[entity.PaneID]*PaneView
4949
50- onPaneFocused func(paneID entity.PaneID)
50+ onPaneFocused func(paneID entity.PaneID)
51+ onSplitRatioDragged func(nodeID string, ratio float64)
5152
5253 mu sync.RWMutex
5354 }
@@ -128,6 +129,14 @@ func NewWorkspaceView(ctx context.Context, factory layout.WidgetFactory) *Worksp
128129
129130 // Create tree renderer with our adapter as the pane view factory
130131 wv.treeRenderer = layout.NewTreeRenderer(ctx, factory, &paneViewFactoryAdapter{wv: wv, ctx: ctx})
132+ wv.treeRenderer.SetOnSplitRatioChanged(func(nodeID string, ratio float64) {
133+ wv.mu.RLock()
134+ cb := wv.onSplitRatioDragged
135+ wv.mu.RUnlock()
136+ if cb != nil {
137+ cb(nodeID, ratio)
138+ }
139+ })
131140
132141 return wv
133142 }
@@ -303,6 +312,13 @@ func (wv *WorkspaceView) SetOnPaneFocused(fn func(paneID entity.PaneID)) {
303312 wv.onPaneFocused = fn
304313 }
305314
315+func (wv *WorkspaceView) SetOnSplitRatioDragged(fn func(nodeID string, ratio float64)) {
316+ wv.mu.Lock()
317+ defer wv.mu.Unlock()
318+
319+ wv.onSplitRatioDragged = fn
320+}
321+
306322 // Rebuild rebuilds the widget tree from the current workspace.
307323 // Use this after structural changes like splits or closes.
308324 func (wv *WorkspaceView) Rebuild(ctx context.Context) error {
@@ -47,7 +47,8 @@ type WorkspaceView struct {
47 workspace *entity.Workspace47 workspace *entity.Workspace
48 paneViews map[entity.PaneID]*PaneView48 paneViews map[entity.PaneID]*PaneView
49 49
50- onPaneFocused func(paneID entity.PaneID)50+ onPaneFocused func(paneID entity.PaneID)
51+ onSplitRatioDragged func(nodeID string, ratio float64)
51 52
52 mu sync.RWMutex53 mu sync.RWMutex
53 }54 }
@@ -128,6 +129,14 @@ func NewWorkspaceView(ctx context.Context, factory layout.WidgetFactory) *Worksp
128 129
129 // Create tree renderer with our adapter as the pane view factory130 // Create tree renderer with our adapter as the pane view factory
130 wv.treeRenderer = layout.NewTreeRenderer(ctx, factory, &paneViewFactoryAdapter{wv: wv, ctx: ctx})131 wv.treeRenderer = layout.NewTreeRenderer(ctx, factory, &paneViewFactoryAdapter{wv: wv, ctx: ctx})
132+ wv.treeRenderer.SetOnSplitRatioChanged(func(nodeID string, ratio float64) {
133+ wv.mu.RLock()
134+ cb := wv.onSplitRatioDragged
135+ wv.mu.RUnlock()
136+ if cb != nil {
137+ cb(nodeID, ratio)
138+ }
139+ })
131 140
132 return wv141 return wv
133 }142 }
@@ -303,6 +312,13 @@ func (wv *WorkspaceView) SetOnPaneFocused(fn func(paneID entity.PaneID)) {
303 wv.onPaneFocused = fn312 wv.onPaneFocused = fn
304 }313 }
305 314
315+func (wv *WorkspaceView) SetOnSplitRatioDragged(fn func(nodeID string, ratio float64)) {
316+ wv.mu.Lock()
317+ defer wv.mu.Unlock()
318+
319+ wv.onSplitRatioDragged = fn
320+}
321+
306 // Rebuild rebuilds the widget tree from the current workspace.322 // Rebuild rebuilds the widget tree from the current workspace.
307 // Use this after structural changes like splits or closes.323 // Use this after structural changes like splits or closes.
308 func (wv *WorkspaceView) Rebuild(ctx context.Context) error {324 func (wv *WorkspaceView) Rebuild(ctx context.Context) error {
M internal/ui/component/workspace_view_test.go +1 -0
@@ -199,6 +199,7 @@ func TestSetActivePaneID_UpdatesStyling(t *testing.T) {
199199 mockStackBox2.EXPECT().SetVisible(true).Once()
200200 mockPaned.EXPECT().SetStartChild(mockStackBox1).Once()
201201 mockPaned.EXPECT().SetEndChild(mockStackBox2).Once()
202+ mockPaned.EXPECT().ConnectNotifyPosition(mock.Anything).Return(uint32(0)).Once()
202203 mockPaned.EXPECT().GetAllocatedWidth().Return(0).Once()
203204 mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once()
204205 mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once()
@@ -199,6 +199,7 @@ func TestSetActivePaneID_UpdatesStyling(t *testing.T) {
199 mockStackBox2.EXPECT().SetVisible(true).Once()199 mockStackBox2.EXPECT().SetVisible(true).Once()
200 mockPaned.EXPECT().SetStartChild(mockStackBox1).Once()200 mockPaned.EXPECT().SetStartChild(mockStackBox1).Once()
201 mockPaned.EXPECT().SetEndChild(mockStackBox2).Once()201 mockPaned.EXPECT().SetEndChild(mockStackBox2).Once()
202+ mockPaned.EXPECT().ConnectNotifyPosition(mock.Anything).Return(uint32(0)).Once()
202 mockPaned.EXPECT().GetAllocatedWidth().Return(0).Once()203 mockPaned.EXPECT().GetAllocatedWidth().Return(0).Once()
203 mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once()204 mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once()
204 mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once()205 mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once()
M internal/ui/coordinator/workspace.go +106 -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"
@@ -322,11 +323,12 @@ func (c *WorkspaceCoordinator) doIncrementalStackSplit(
322323 splitView = layout.NewSplitView(ctx, factory, layout.OrientationHorizontal,
323324 existingStackWidget, newStackedView.Widget(), output.SplitRatio)
324325 }
326+ c.wireSplitRatioPersistence(ctx, splitView, output.ParentNode.ID)
325327
326328 wsView.SetRootWidgetDirect(splitView.Widget())
327329
328330 if tr != nil {
329- tr.RegisterWidget(output.ParentNode.ID, splitView.Widget())
331+ tr.RegisterSplit(output.ParentNode.ID, splitView.Widget(), layout.OrientationHorizontal)
330332 }
331333 } else {
332334 // Non-root split: stack is inside another split, need to replace in grandparent
@@ -369,6 +371,7 @@ func (c *WorkspaceCoordinator) doIncrementalStackSplit(
369371 splitView = layout.NewSplitView(ctx, factory, layout.OrientationHorizontal,
370372 existingStackWidget, newStackedView.Widget(), output.SplitRatio)
371373 }
374+ c.wireSplitRatioPersistence(ctx, splitView, output.ParentNode.ID)
372375
373376 // Put the new split view in the grandparent's slot
374377 if isStartChild {
@@ -377,7 +380,7 @@ func (c *WorkspaceCoordinator) doIncrementalStackSplit(
377380 panedWidget.SetEndChild(splitView.Widget())
378381 }
379382
380- tr.RegisterWidget(output.ParentNode.ID, splitView.Widget())
383+ tr.RegisterSplit(output.ParentNode.ID, splitView.Widget(), layout.OrientationHorizontal)
381384
382385 log.Debug().
383386 Bool("was_start_child", isStartChild).
@@ -561,6 +564,18 @@ func splitOrientation(direction usecase.SplitDirection) (layout.Orientation, boo
561564 }
562565 }
563566
567+func (c *WorkspaceCoordinator) wireSplitRatioPersistence(ctx context.Context, splitView *layout.SplitView, splitNodeID string) {
568+ if c == nil || splitView == nil || splitNodeID == "" {
569+ return
570+ }
571+
572+ splitView.SetOnRatioChanged(func(ratio float64) {
573+ if err := c.SetSplitRatio(ctx, splitNodeID, ratio); err != nil {
574+ logging.FromContext(ctx).Warn().Err(err).Str("split_node_id", splitNodeID).Msg("failed to persist split ratio")
575+ }
576+ })
577+}
578+
564579 func (c *WorkspaceCoordinator) replaceRootSplit(
565580 ctx context.Context,
566581 wsView *component.WorkspaceView,
@@ -583,9 +598,10 @@ func (c *WorkspaceCoordinator) replaceRootSplit(
583598 splitView = layout.NewSplitView(ctx, factory, orientation,
584599 newStackedView.Widget(), existingRootWidget, output.SplitRatio)
585600 }
601+ c.wireSplitRatioPersistence(ctx, splitView, output.ParentNode.ID)
586602
587603 wsView.SetRootWidgetDirect(splitView.Widget())
588- tr.RegisterWidget(output.ParentNode.ID, splitView.Widget())
604+ tr.RegisterSplit(output.ParentNode.ID, splitView.Widget(), orientation)
589605
590606 logging.FromContext(ctx).Debug().Msg("root split: replaced root with new split view")
591607 return nil
@@ -642,6 +658,7 @@ func (c *WorkspaceCoordinator) replaceNonRootSplit(
642658 splitView = layout.NewSplitView(ctx, factory, orientation,
643659 newStackedView.Widget(), activePaneWidget, output.SplitRatio)
644660 }
661+ c.wireSplitRatioPersistence(ctx, splitView, output.ParentNode.ID)
645662
646663 if isStartChild {
647664 panedWidget.SetStartChild(splitView.Widget())
@@ -649,7 +666,7 @@ func (c *WorkspaceCoordinator) replaceNonRootSplit(
649666 panedWidget.SetEndChild(splitView.Widget())
650667 }
651668
652- tr.RegisterWidget(output.ParentNode.ID, splitView.Widget())
669+ tr.RegisterSplit(output.ParentNode.ID, splitView.Widget(), orientation)
653670
654671 log.Debug().
655672 Bool("was_start_child", isStartChild).
@@ -1860,3 +1877,88 @@ func (c *WorkspaceCoordinator) ShowToastOnActivePane(ctx context.Context, messag
18601877 paneView.ShowToast(ctx, message, level)
18611878 }
18621879 }
1880+
1881+// Resize updates the active split ratio and applies it to GTK widgets.
1882+func (c *WorkspaceCoordinator) Resize(ctx context.Context, dir usecase.ResizeDirection) error {
1883+ log := logging.FromContext(ctx)
1884+
1885+ if c.panesUC == nil {
1886+ log.Warn().Msg("panes use case not available")
1887+ return nil
1888+ }
1889+
1890+ ws, wsView := c.getActiveWS()
1891+ if ws == nil {
1892+ log.Warn().Msg("no active workspace")
1893+ return nil
1894+ }
1895+
1896+ target := ws.ActivePane()
1897+ if target == nil {
1898+ return nil
1899+ }
1900+
1901+ cfg := config.Get()
1902+ err := c.panesUC.Resize(ctx, ws, target, dir, cfg.Workspace.ResizeMode.StepPercent, cfg.Workspace.ResizeMode.MinPanePercent)
1903+ if errors.Is(err, usecase.ErrNothingToResize) {
1904+ c.ShowToastOnActivePane(ctx, "Nothing to resize", component.ToastInfo)
1905+ return nil
1906+ }
1907+ if err != nil {
1908+ return err
1909+ }
1910+
1911+ if wsView != nil {
1912+ c.updateSplitPositions(wsView, ws)
1913+ }
1914+
1915+ c.notifyStateChanged()
1916+ return nil
1917+}
1918+
1919+func (c *WorkspaceCoordinator) SetSplitRatio(ctx context.Context, splitNodeID string, ratio float64) error {
1920+ log := logging.FromContext(ctx)
1921+
1922+ if c.panesUC == nil {
1923+ log.Warn().Msg("panes use case not available")
1924+ return nil
1925+ }
1926+
1927+ ws, _ := c.getActiveWS()
1928+ if ws == nil {
1929+ log.Warn().Msg("no active workspace")
1930+ return nil
1931+ }
1932+
1933+ cfg := config.Get()
1934+ err := c.panesUC.SetSplitRatio(ctx, usecase.SetSplitRatioInput{
1935+ Workspace: ws,
1936+ SplitNodeID: splitNodeID,
1937+ Ratio: ratio,
1938+ MinPanePercent: cfg.Workspace.ResizeMode.MinPanePercent,
1939+ })
1940+ if err != nil {
1941+ return err
1942+ }
1943+
1944+ c.notifyStateChanged()
1945+ return nil
1946+}
1947+
1948+func (c *WorkspaceCoordinator) updateSplitPositions(wsView *component.WorkspaceView, ws *entity.Workspace) {
1949+ if wsView == nil || ws == nil || ws.Root == nil {
1950+ return
1951+ }
1952+
1953+ tr := wsView.TreeRenderer()
1954+ if tr == nil {
1955+ return
1956+ }
1957+
1958+ ws.Root.Walk(func(node *entity.PaneNode) bool {
1959+ if node.IsSplit() {
1960+ _ = tr.UpdateSplitRatio(node.ID, node.SplitRatio)
1961+ }
1962+ return true
1963+ })
1964+}
@@ -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"
@@ -322,11 +323,12 @@ func (c *WorkspaceCoordinator) doIncrementalStackSplit(
322 splitView = layout.NewSplitView(ctx, factory, layout.OrientationHorizontal,323 splitView = layout.NewSplitView(ctx, factory, layout.OrientationHorizontal,
323 existingStackWidget, newStackedView.Widget(), output.SplitRatio)324 existingStackWidget, newStackedView.Widget(), output.SplitRatio)
324 }325 }
326+ c.wireSplitRatioPersistence(ctx, splitView, output.ParentNode.ID)
325 327
326 wsView.SetRootWidgetDirect(splitView.Widget())328 wsView.SetRootWidgetDirect(splitView.Widget())
327 329
328 if tr != nil {330 if tr != nil {
329- tr.RegisterWidget(output.ParentNode.ID, splitView.Widget())331+ tr.RegisterSplit(output.ParentNode.ID, splitView.Widget(), layout.OrientationHorizontal)
330 }332 }
331 } else {333 } else {
332 // Non-root split: stack is inside another split, need to replace in grandparent334 // Non-root split: stack is inside another split, need to replace in grandparent
@@ -369,6 +371,7 @@ func (c *WorkspaceCoordinator) doIncrementalStackSplit(
369 splitView = layout.NewSplitView(ctx, factory, layout.OrientationHorizontal,371 splitView = layout.NewSplitView(ctx, factory, layout.OrientationHorizontal,
370 existingStackWidget, newStackedView.Widget(), output.SplitRatio)372 existingStackWidget, newStackedView.Widget(), output.SplitRatio)
371 }373 }
374+ c.wireSplitRatioPersistence(ctx, splitView, output.ParentNode.ID)
372 375
373 // Put the new split view in the grandparent's slot376 // Put the new split view in the grandparent's slot
374 if isStartChild {377 if isStartChild {
@@ -377,7 +380,7 @@ func (c *WorkspaceCoordinator) doIncrementalStackSplit(
377 panedWidget.SetEndChild(splitView.Widget())380 panedWidget.SetEndChild(splitView.Widget())
378 }381 }
379 382
380- tr.RegisterWidget(output.ParentNode.ID, splitView.Widget())383+ tr.RegisterSplit(output.ParentNode.ID, splitView.Widget(), layout.OrientationHorizontal)
381 384
382 log.Debug().385 log.Debug().
383 Bool("was_start_child", isStartChild).386 Bool("was_start_child", isStartChild).
@@ -561,6 +564,18 @@ func splitOrientation(direction usecase.SplitDirection) (layout.Orientation, boo
561 }564 }
562 }565 }
563 566
567+func (c *WorkspaceCoordinator) wireSplitRatioPersistence(ctx context.Context, splitView *layout.SplitView, splitNodeID string) {
568+ if c == nil || splitView == nil || splitNodeID == "" {
569+ return
570+ }
571+
572+ splitView.SetOnRatioChanged(func(ratio float64) {
573+ if err := c.SetSplitRatio(ctx, splitNodeID, ratio); err != nil {
574+ logging.FromContext(ctx).Warn().Err(err).Str("split_node_id", splitNodeID).Msg("failed to persist split ratio")
575+ }
576+ })
577+}
578+
564 func (c *WorkspaceCoordinator) replaceRootSplit(579 func (c *WorkspaceCoordinator) replaceRootSplit(
565 ctx context.Context,580 ctx context.Context,
566 wsView *component.WorkspaceView,581 wsView *component.WorkspaceView,
@@ -583,9 +598,10 @@ func (c *WorkspaceCoordinator) replaceRootSplit(
583 splitView = layout.NewSplitView(ctx, factory, orientation,598 splitView = layout.NewSplitView(ctx, factory, orientation,
584 newStackedView.Widget(), existingRootWidget, output.SplitRatio)599 newStackedView.Widget(), existingRootWidget, output.SplitRatio)
585 }600 }
601+ c.wireSplitRatioPersistence(ctx, splitView, output.ParentNode.ID)
586 602
587 wsView.SetRootWidgetDirect(splitView.Widget())603 wsView.SetRootWidgetDirect(splitView.Widget())
588- tr.RegisterWidget(output.ParentNode.ID, splitView.Widget())604+ tr.RegisterSplit(output.ParentNode.ID, splitView.Widget(), orientation)
589 605
590 logging.FromContext(ctx).Debug().Msg("root split: replaced root with new split view")606 logging.FromContext(ctx).Debug().Msg("root split: replaced root with new split view")
591 return nil607 return nil
@@ -642,6 +658,7 @@ func (c *WorkspaceCoordinator) replaceNonRootSplit(
642 splitView = layout.NewSplitView(ctx, factory, orientation,658 splitView = layout.NewSplitView(ctx, factory, orientation,
643 newStackedView.Widget(), activePaneWidget, output.SplitRatio)659 newStackedView.Widget(), activePaneWidget, output.SplitRatio)
644 }660 }
661+ c.wireSplitRatioPersistence(ctx, splitView, output.ParentNode.ID)
645 662
646 if isStartChild {663 if isStartChild {
647 panedWidget.SetStartChild(splitView.Widget())664 panedWidget.SetStartChild(splitView.Widget())
@@ -649,7 +666,7 @@ func (c *WorkspaceCoordinator) replaceNonRootSplit(
649 panedWidget.SetEndChild(splitView.Widget())666 panedWidget.SetEndChild(splitView.Widget())
650 }667 }
651 668
652- tr.RegisterWidget(output.ParentNode.ID, splitView.Widget())669+ tr.RegisterSplit(output.ParentNode.ID, splitView.Widget(), orientation)
653 670
654 log.Debug().671 log.Debug().
655 Bool("was_start_child", isStartChild).672 Bool("was_start_child", isStartChild).
@@ -1860,3 +1877,88 @@ func (c *WorkspaceCoordinator) ShowToastOnActivePane(ctx context.Context, messag
1860 paneView.ShowToast(ctx, message, level)1877 paneView.ShowToast(ctx, message, level)
1861 }1878 }
1862 }1879 }
1880+
1881+// Resize updates the active split ratio and applies it to GTK widgets.
1882+func (c *WorkspaceCoordinator) Resize(ctx context.Context, dir usecase.ResizeDirection) error {
1883+ log := logging.FromContext(ctx)
1884+
1885+ if c.panesUC == nil {
1886+ log.Warn().Msg("panes use case not available")
1887+ return nil
1888+ }
1889+
1890+ ws, wsView := c.getActiveWS()
1891+ if ws == nil {
1892+ log.Warn().Msg("no active workspace")
1893+ return nil
1894+ }
1895+
1896+ target := ws.ActivePane()
1897+ if target == nil {
1898+ return nil
1899+ }
1900+
1901+ cfg := config.Get()
1902+ err := c.panesUC.Resize(ctx, ws, target, dir, cfg.Workspace.ResizeMode.StepPercent, cfg.Workspace.ResizeMode.MinPanePercent)
1903+ if errors.Is(err, usecase.ErrNothingToResize) {
1904+ c.ShowToastOnActivePane(ctx, "Nothing to resize", component.ToastInfo)
1905+ return nil
1906+ }
1907+ if err != nil {
1908+ return err
1909+ }
1910+
1911+ if wsView != nil {
1912+ c.updateSplitPositions(wsView, ws)
1913+ }
1914+
1915+ c.notifyStateChanged()
1916+ return nil
1917+}
1918+
1919+func (c *WorkspaceCoordinator) SetSplitRatio(ctx context.Context, splitNodeID string, ratio float64) error {
1920+ log := logging.FromContext(ctx)
1921+
1922+ if c.panesUC == nil {
1923+ log.Warn().Msg("panes use case not available")
1924+ return nil
1925+ }
1926+
1927+ ws, _ := c.getActiveWS()
1928+ if ws == nil {
1929+ log.Warn().Msg("no active workspace")
1930+ return nil
1931+ }
1932+
1933+ cfg := config.Get()
1934+ err := c.panesUC.SetSplitRatio(ctx, usecase.SetSplitRatioInput{
1935+ Workspace: ws,
1936+ SplitNodeID: splitNodeID,
1937+ Ratio: ratio,
1938+ MinPanePercent: cfg.Workspace.ResizeMode.MinPanePercent,
1939+ })
1940+ if err != nil {
1941+ return err
1942+ }
1943+
1944+ c.notifyStateChanged()
1945+ return nil
1946+}
1947+
1948+func (c *WorkspaceCoordinator) updateSplitPositions(wsView *component.WorkspaceView, ws *entity.Workspace) {
1949+ if wsView == nil || ws == nil || ws.Root == nil {
1950+ return
1951+ }
1952+
1953+ tr := wsView.TreeRenderer()
1954+ if tr == nil {
1955+ return
1956+ }
1957+
1958+ ws.Root.Walk(func(node *entity.PaneNode) bool {
1959+ if node.IsSplit() {
1960+ _ = tr.UpdateSplitRatio(node.ID, node.SplitRatio)
1961+ }
1962+ return true
1963+ })
1964+}
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/focus/borders.go +3 -0
@@ -76,6 +76,9 @@ func (bm *BorderManager) OnModeChange(ctx context.Context, from, to input.Mode)
7676 bm.borderOverlay.SetVisible(true)
7777 case input.ModeNormal:
7878 bm.borderOverlay.SetVisible(false)
79+ default:
80+ // Resize mode is handled by per-pane stack border, not global overlay.
81+ bm.borderOverlay.SetVisible(false)
7982 }
8083
8184 bm.currentMode = to
@@ -76,6 +76,9 @@ func (bm *BorderManager) OnModeChange(ctx context.Context, from, to input.Mode)
76 bm.borderOverlay.SetVisible(true)76 bm.borderOverlay.SetVisible(true)
77 case input.ModeNormal:77 case input.ModeNormal:
78 bm.borderOverlay.SetVisible(false)78 bm.borderOverlay.SetVisible(false)
79+ default:
80+ // Resize mode is handled by per-pane stack border, not global overlay.
81+ bm.borderOverlay.SetVisible(false)
79 }82 }
80 83
81 bm.currentMode = to84 bm.currentMode = to
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 }
M internal/ui/layout/gtk_widgets.go +8 -0
@@ -2,6 +2,7 @@ package layout
22
33 import (
44 "github.com/jwijenbergh/puregotk/v4/gdk"
5+ "github.com/jwijenbergh/puregotk/v4/gobject"
56 "github.com/jwijenbergh/puregotk/v4/graphene"
67 "github.com/jwijenbergh/puregotk/v4/gtk"
78 "github.com/jwijenbergh/puregotk/v4/pango"
@@ -162,6 +163,13 @@ func (p *gtkPaned) ConnectMap(callback func()) uint32 {
162163 return p.inner.ConnectMap(&cb)
163164 }
164165
166+func (p *gtkPaned) ConnectNotifyPosition(callback func()) uint32 {
167+ cb := func(_ gobject.Object, _ uintptr) {
168+ callback()
169+ }
170+ return p.inner.ConnectNotifyWithDetail("position", &cb)
171+}
172+
165173 func (p *gtkPaned) AddTickCallback(callback func() bool) uint {
166174 cb := gtk.TickCallback(func(widget uintptr, frameClock uintptr, userData uintptr) bool {
167175 return callback()
@@ -2,6 +2,7 @@ package layout
2 2
3 import (3 import (
4 "github.com/jwijenbergh/puregotk/v4/gdk"4 "github.com/jwijenbergh/puregotk/v4/gdk"
5+ "github.com/jwijenbergh/puregotk/v4/gobject"
5 "github.com/jwijenbergh/puregotk/v4/graphene"6 "github.com/jwijenbergh/puregotk/v4/graphene"
6 "github.com/jwijenbergh/puregotk/v4/gtk"7 "github.com/jwijenbergh/puregotk/v4/gtk"
7 "github.com/jwijenbergh/puregotk/v4/pango"8 "github.com/jwijenbergh/puregotk/v4/pango"
@@ -162,6 +163,13 @@ func (p *gtkPaned) ConnectMap(callback func()) uint32 {
162 return p.inner.ConnectMap(&cb)163 return p.inner.ConnectMap(&cb)
163 }164 }
164 165
166+func (p *gtkPaned) ConnectNotifyPosition(callback func()) uint32 {
167+ cb := func(_ gobject.Object, _ uintptr) {
168+ callback()
169+ }
170+ return p.inner.ConnectNotifyWithDetail("position", &cb)
171+}
172+
165 func (p *gtkPaned) AddTickCallback(callback func() bool) uint {173 func (p *gtkPaned) AddTickCallback(callback func() bool) uint {
166 cb := gtk.TickCallback(func(widget uintptr, frameClock uintptr, userData uintptr) bool {174 cb := gtk.TickCallback(func(widget uintptr, frameClock uintptr, userData uintptr) bool {
167 return callback()175 return callback()
M internal/ui/layout/mocks/mocks.go +51 -0
@@ -1495,6 +1495,57 @@ func (_c *MockPanedWidget_ConnectMap_Call) RunAndReturn(run func(callback func()
14951495 return _c
14961496 }
14971497
1498+// ConnectNotifyPosition provides a mock function for the type MockPanedWidget
1499+func (_mock *MockPanedWidget) ConnectNotifyPosition(callback func()) uint32 {
1500+ ret := _mock.Called(callback)
1501+
1502+ if len(ret) == 0 {
1503+ panic("no return value specified for ConnectNotifyPosition")
1504+ }
1505+
1506+ var r0 uint32
1507+ if returnFunc, ok := ret.Get(0).(func(func()) uint32); ok {
1508+ r0 = returnFunc(callback)
1509+ } else {
1510+ r0 = ret.Get(0).(uint32)
1511+ }
1512+ return r0
1513+}
1514+
1515+// MockPanedWidget_ConnectNotifyPosition_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ConnectNotifyPosition'
1516+type MockPanedWidget_ConnectNotifyPosition_Call struct {
1517+ *mock.Call
1518+}
1519+
1520+// ConnectNotifyPosition is a helper method to define mock.On call
1521+// - callback func()
1522+func (_e *MockPanedWidget_Expecter) ConnectNotifyPosition(callback interface{}) *MockPanedWidget_ConnectNotifyPosition_Call {
1523+ return &MockPanedWidget_ConnectNotifyPosition_Call{Call: _e.mock.On("ConnectNotifyPosition", callback)}
1524+}
1525+
1526+func (_c *MockPanedWidget_ConnectNotifyPosition_Call) Run(run func(callback func())) *MockPanedWidget_ConnectNotifyPosition_Call {
1527+ _c.Call.Run(func(args mock.Arguments) {
1528+ var arg0 func()
1529+ if args[0] != nil {
1530+ arg0 = args[0].(func())
1531+ }
1532+ run(
1533+ arg0,
1534+ )
1535+ })
1536+ return _c
1537+}
1538+
1539+func (_c *MockPanedWidget_ConnectNotifyPosition_Call) Return(v uint32) *MockPanedWidget_ConnectNotifyPosition_Call {
1540+ _c.Call.Return(v)
1541+ return _c
1542+}
1543+
1544+func (_c *MockPanedWidget_ConnectNotifyPosition_Call) RunAndReturn(run func(callback func()) uint32) *MockPanedWidget_ConnectNotifyPosition_Call {
1545+ _c.Call.Return(run)
1546+ return _c
1547+}
1548+
14981549 // GetAllocatedHeight provides a mock function for the type MockPanedWidget
14991550 func (_mock *MockPanedWidget) GetAllocatedHeight() int {
15001551 ret := _mock.Called()
@@ -1495,6 +1495,57 @@ func (_c *MockPanedWidget_ConnectMap_Call) RunAndReturn(run func(callback func()
1495 return _c1495 return _c
1496 }1496 }
1497 1497
1498+// ConnectNotifyPosition provides a mock function for the type MockPanedWidget
1499+func (_mock *MockPanedWidget) ConnectNotifyPosition(callback func()) uint32 {
1500+ ret := _mock.Called(callback)
1501+
1502+ if len(ret) == 0 {
1503+ panic("no return value specified for ConnectNotifyPosition")
1504+ }
1505+
1506+ var r0 uint32
1507+ if returnFunc, ok := ret.Get(0).(func(func()) uint32); ok {
1508+ r0 = returnFunc(callback)
1509+ } else {
1510+ r0 = ret.Get(0).(uint32)
1511+ }
1512+ return r0
1513+}
1514+
1515+// MockPanedWidget_ConnectNotifyPosition_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ConnectNotifyPosition'
1516+type MockPanedWidget_ConnectNotifyPosition_Call struct {
1517+ *mock.Call
1518+}
1519+
1520+// ConnectNotifyPosition is a helper method to define mock.On call
1521+// - callback func()
1522+func (_e *MockPanedWidget_Expecter) ConnectNotifyPosition(callback interface{}) *MockPanedWidget_ConnectNotifyPosition_Call {
1523+ return &MockPanedWidget_ConnectNotifyPosition_Call{Call: _e.mock.On("ConnectNotifyPosition", callback)}
1524+}
1525+
1526+func (_c *MockPanedWidget_ConnectNotifyPosition_Call) Run(run func(callback func())) *MockPanedWidget_ConnectNotifyPosition_Call {
1527+ _c.Call.Run(func(args mock.Arguments) {
1528+ var arg0 func()
1529+ if args[0] != nil {
1530+ arg0 = args[0].(func())
1531+ }
1532+ run(
1533+ arg0,
1534+ )
1535+ })
1536+ return _c
1537+}
1538+
1539+func (_c *MockPanedWidget_ConnectNotifyPosition_Call) Return(v uint32) *MockPanedWidget_ConnectNotifyPosition_Call {
1540+ _c.Call.Return(v)
1541+ return _c
1542+}
1543+
1544+func (_c *MockPanedWidget_ConnectNotifyPosition_Call) RunAndReturn(run func(callback func()) uint32) *MockPanedWidget_ConnectNotifyPosition_Call {
1545+ _c.Call.Return(run)
1546+ return _c
1547+}
1548+
1498 // GetAllocatedHeight provides a mock function for the type MockPanedWidget1549 // GetAllocatedHeight provides a mock function for the type MockPanedWidget
1499 func (_mock *MockPanedWidget) GetAllocatedHeight() int {1550 func (_mock *MockPanedWidget) GetAllocatedHeight() int {
1500 ret := _mock.Called()1551 ret := _mock.Called()
M internal/ui/layout/split.go +85 -0
@@ -3,8 +3,10 @@ package layout
33 import (
44 "context"
55 "sync"
6+ "time"
67
78 "github.com/bnema/dumber/internal/logging"
9+ "github.com/jwijenbergh/puregotk/v4/glib"
810 "github.com/rs/zerolog"
911 )
1012
@@ -18,6 +20,13 @@ type SplitView struct {
1820 ratio float64 // 0.0-1.0, relative position of divider
1921 logger zerolog.Logger
2022
23+ onRatioChanged func(ratio float64)
24+ pendingNotifyRatio float64
25+ notifyDebounceTimer *time.Timer
26+
27+ hasAppliedRatio bool
28+ suppressNotifyUntil time.Time
29+
2130 mu sync.RWMutex
2231 }
2332
@@ -25,6 +34,9 @@ type SplitView struct {
2534 // At 60fps, 120 frames = ~2 seconds timeout.
2635 const maxRetryFrames = 120
2736
37+const notifyPositionDebounceDelay = 100 * time.Millisecond
38+const notifyPositionSuppressAfterApplyDelay = 50 * time.Millisecond
39+
2840 // NewSplitView creates a new split view with the given orientation and children.
2941 // The ratio determines the initial divider position (0.0-1.0).
3042 func NewSplitView(
@@ -61,6 +73,60 @@ func NewSplitView(
6173 paned.SetEndChild(endChild)
6274 }
6375
76+ paned.ConnectNotifyPosition(func() {
77+ position := paned.GetPosition()
78+ var totalSize int
79+ if orientation == OrientationHorizontal {
80+ totalSize = paned.GetAllocatedWidth()
81+ } else {
82+ totalSize = paned.GetAllocatedHeight()
83+ }
84+ if totalSize <= 0 {
85+ return
86+ }
87+
88+ now := time.Now()
89+
90+ sv.mu.Lock()
91+ // Ignore early notifications before we've applied the snapshot ratio.
92+ // GTK often emits an initial notify::position (typically 50/50) during allocation.
93+ if !sv.hasAppliedRatio {
94+ sv.mu.Unlock()
95+ return
96+ }
97+ if !sv.suppressNotifyUntil.IsZero() && now.Before(sv.suppressNotifyUntil) {
98+ sv.mu.Unlock()
99+ return
100+ }
101+ sv.mu.Unlock()
102+
103+ ratio := clampRatio(float64(position) / float64(totalSize))
104+
105+ sv.mu.Lock()
106+ sv.ratio = ratio
107+ sv.pendingNotifyRatio = ratio
108+ onRatioChanged := sv.onRatioChanged
109+
110+ if sv.notifyDebounceTimer != nil {
111+ sv.notifyDebounceTimer.Stop()
112+ }
113+ sv.notifyDebounceTimer = time.AfterFunc(notifyPositionDebounceDelay, func() {
114+ if onRatioChanged == nil {
115+ return
116+ }
117+ cb := glib.SourceFunc(func(_ uintptr) bool {
118+ sv.mu.RLock()
119+ pending := sv.pendingNotifyRatio
120+ sv.mu.RUnlock()
121+ onRatioChanged(pending)
122+ return false
123+ })
124+ glib.IdleAdd(&cb, 0)
125+ })
126+
127+ sv.mu.Unlock()
128+ })
129+
64130 // Try to apply ratio immediately (in case widget is already allocated)
65131 if sv.ApplyRatio() {
66132 return sv
@@ -114,6 +180,13 @@ func (sv *SplitView) GetRatio() float64 {
114180 return sv.ratio
115181 }
116182
183+func (sv *SplitView) SetOnRatioChanged(fn func(ratio float64)) {
184+ sv.mu.Lock()
185+ defer sv.mu.Unlock()
186+
187+ sv.onRatioChanged = fn
188+}
189+
117190 // ApplyRatio converts the ratio to a pixel position and applies it.
118191 // This should be called after the widget has been mapped and has an allocated size.
119192 // Returns true if the ratio was successfully applied, false if the widget is not yet allocated.
@@ -150,7 +223,19 @@ func (sv *SplitView) ApplyRatio() bool {
150223 Float64("ratio", ratio).
151224 Int("position", position).
152225 Msg("ApplyRatio: setting position")
226+
227+ // Avoid treating programmatic SetPosition as a user resize.
228+ // We suppress notify::position briefly; GTK may emit during/after SetPosition.
229+ sv.mu.Lock()
230+ sv.suppressNotifyUntil = time.Now().Add(notifyPositionSuppressAfterApplyDelay)
231+ sv.mu.Unlock()
232+
153233 sv.paned.SetPosition(position)
234+
235+ sv.mu.Lock()
236+ sv.hasAppliedRatio = true
237+ sv.mu.Unlock()
238+
154239 return true
155240 }
156241
@@ -3,8 +3,10 @@ package layout
3 import (3 import (
4 "context"4 "context"
5 "sync"5 "sync"
6+ "time"
6 7
7 "github.com/bnema/dumber/internal/logging"8 "github.com/bnema/dumber/internal/logging"
9+ "github.com/jwijenbergh/puregotk/v4/glib"
8 "github.com/rs/zerolog"10 "github.com/rs/zerolog"
9 )11 )
10 12
@@ -18,6 +20,13 @@ type SplitView struct {
18 ratio float64 // 0.0-1.0, relative position of divider20 ratio float64 // 0.0-1.0, relative position of divider
19 logger zerolog.Logger21 logger zerolog.Logger
20 22
23+ onRatioChanged func(ratio float64)
24+ pendingNotifyRatio float64
25+ notifyDebounceTimer *time.Timer
26+
27+ hasAppliedRatio bool
28+ suppressNotifyUntil time.Time
29+
21 mu sync.RWMutex30 mu sync.RWMutex
22 }31 }
23 32
@@ -25,6 +34,9 @@ type SplitView struct {
25 // At 60fps, 120 frames = ~2 seconds timeout.34 // At 60fps, 120 frames = ~2 seconds timeout.
26 const maxRetryFrames = 12035 const maxRetryFrames = 120
27 36
37+const notifyPositionDebounceDelay = 100 * time.Millisecond
38+const notifyPositionSuppressAfterApplyDelay = 50 * time.Millisecond
39+
28 // NewSplitView creates a new split view with the given orientation and children.40 // NewSplitView creates a new split view with the given orientation and children.
29 // The ratio determines the initial divider position (0.0-1.0).41 // The ratio determines the initial divider position (0.0-1.0).
30 func NewSplitView(42 func NewSplitView(
@@ -61,6 +73,60 @@ func NewSplitView(
61 paned.SetEndChild(endChild)73 paned.SetEndChild(endChild)
62 }74 }
63 75
76+ paned.ConnectNotifyPosition(func() {
77+ position := paned.GetPosition()
78+ var totalSize int
79+ if orientation == OrientationHorizontal {
80+ totalSize = paned.GetAllocatedWidth()
81+ } else {
82+ totalSize = paned.GetAllocatedHeight()
83+ }
84+ if totalSize <= 0 {
85+ return
86+ }
87+
88+ now := time.Now()
89+
90+ sv.mu.Lock()
91+ // Ignore early notifications before we've applied the snapshot ratio.
92+ // GTK often emits an initial notify::position (typically 50/50) during allocation.
93+ if !sv.hasAppliedRatio {
94+ sv.mu.Unlock()
95+ return
96+ }
97+ if !sv.suppressNotifyUntil.IsZero() && now.Before(sv.suppressNotifyUntil) {
98+ sv.mu.Unlock()
99+ return
100+ }
101+ sv.mu.Unlock()
102+
103+ ratio := clampRatio(float64(position) / float64(totalSize))
104+
105+ sv.mu.Lock()
106+ sv.ratio = ratio
107+ sv.pendingNotifyRatio = ratio
108+ onRatioChanged := sv.onRatioChanged
109+
110+ if sv.notifyDebounceTimer != nil {
111+ sv.notifyDebounceTimer.Stop()
112+ }
113+ sv.notifyDebounceTimer = time.AfterFunc(notifyPositionDebounceDelay, func() {
114+ if onRatioChanged == nil {
115+ return
116+ }
117+ cb := glib.SourceFunc(func(_ uintptr) bool {
118+ sv.mu.RLock()
119+ pending := sv.pendingNotifyRatio
120+ sv.mu.RUnlock()
121+ onRatioChanged(pending)
122+ return false
123+ })
124+ glib.IdleAdd(&cb, 0)
125+ })
126+
127+ sv.mu.Unlock()
128+ })
129+
64 // Try to apply ratio immediately (in case widget is already allocated)130 // Try to apply ratio immediately (in case widget is already allocated)
65 if sv.ApplyRatio() {131 if sv.ApplyRatio() {
66 return sv132 return sv
@@ -114,6 +180,13 @@ func (sv *SplitView) GetRatio() float64 {
114 return sv.ratio180 return sv.ratio
115 }181 }
116 182
183+func (sv *SplitView) SetOnRatioChanged(fn func(ratio float64)) {
184+ sv.mu.Lock()
185+ defer sv.mu.Unlock()
186+
187+ sv.onRatioChanged = fn
188+}
189+
117 // ApplyRatio converts the ratio to a pixel position and applies it.190 // ApplyRatio converts the ratio to a pixel position and applies it.
118 // This should be called after the widget has been mapped and has an allocated size.191 // This should be called after the widget has been mapped and has an allocated size.
119 // Returns true if the ratio was successfully applied, false if the widget is not yet allocated.192 // Returns true if the ratio was successfully applied, false if the widget is not yet allocated.
@@ -150,7 +223,19 @@ func (sv *SplitView) ApplyRatio() bool {
150 Float64("ratio", ratio).223 Float64("ratio", ratio).
151 Int("position", position).224 Int("position", position).
152 Msg("ApplyRatio: setting position")225 Msg("ApplyRatio: setting position")
226+
227+ // Avoid treating programmatic SetPosition as a user resize.
228+ // We suppress notify::position briefly; GTK may emit during/after SetPosition.
229+ sv.mu.Lock()
230+ sv.suppressNotifyUntil = time.Now().Add(notifyPositionSuppressAfterApplyDelay)
231+ sv.mu.Unlock()
232+
153 sv.paned.SetPosition(position)233 sv.paned.SetPosition(position)
234+
235+ sv.mu.Lock()
236+ sv.hasAppliedRatio = true
237+ sv.mu.Unlock()
238+
154 return true239 return true
155 }240 }
156 241
M internal/ui/layout/split_test.go +2 -0
@@ -29,6 +29,8 @@ func setupSplitViewMocks(mockPaned *mocks.MockPanedWidget, orientation layout.Or
2929 mockPaned.EXPECT().GetAllocatedHeight().Return(0).Once()
3030 }
3131
32+ mockPaned.EXPECT().ConnectNotifyPosition(mock.Anything).Return(uint32(0)).Once()
33+
3234 // Since allocation is 0, ApplyRatio returns false and callbacks are registered
3335 mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once()
3436 mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once()
@@ -29,6 +29,8 @@ func setupSplitViewMocks(mockPaned *mocks.MockPanedWidget, orientation layout.Or
29 mockPaned.EXPECT().GetAllocatedHeight().Return(0).Once()29 mockPaned.EXPECT().GetAllocatedHeight().Return(0).Once()
30 }30 }
31 31
32+ mockPaned.EXPECT().ConnectNotifyPosition(mock.Anything).Return(uint32(0)).Once()
33+
32 // Since allocation is 0, ApplyRatio returns false and callbacks are registered34 // Since allocation is 0, ApplyRatio returns false and callbacks are registered
33 mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once()35 mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once()
34 mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once()36 mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once()
M internal/ui/layout/tree.go +64 -16
@@ -3,6 +3,7 @@ package layout
33 import (
44 "context"
55 "errors"
6+ "fmt"
67 "sync"
78
89 "github.com/bnema/dumber/internal/domain/entity"
@@ -27,14 +28,17 @@ type PaneViewFactory interface {
2728 // It maps each node to its corresponding GTK widget and maintains
2829 // a lookup table for finding widgets by node ID.
2930 type TreeRenderer struct {
30- factory WidgetFactory
31- paneViewFactory PaneViewFactory
32- logger zerolog.Logger
33- nodeToWidget map[string]Widget
31+ factory WidgetFactory
32+ paneViewFactory PaneViewFactory
33+ logger zerolog.Logger
34+ nodeToWidget map[string]Widget
35+ splitOrientation map[string]Orientation
3436 // paneToStack maps pane IDs to their containing StackedView.
3537 // Every leaf pane is wrapped in a StackedView for easy stacking later.
3638 paneToStack map[string]*StackedView
37- mu sync.RWMutex
39+
40+ onSplitRatioChanged func(nodeID string, ratio float64)
41+ mu sync.RWMutex
3842 }
3943
4044 // NewTreeRenderer creates a new tree renderer.
@@ -43,14 +47,22 @@ func NewTreeRenderer(ctx context.Context, factory WidgetFactory, paneViewFactory
4347 log.Debug().Msg("creating tree renderer")
4448
4549 return &TreeRenderer{
46- factory: factory,
47- paneViewFactory: paneViewFactory,
48- logger: log.With().Str("component", "tree-renderer").Logger(),
49- nodeToWidget: make(map[string]Widget),
50- paneToStack: make(map[string]*StackedView),
50+ factory: factory,
51+ paneViewFactory: paneViewFactory,
52+ logger: log.With().Str("component", "tree-renderer").Logger(),
53+ nodeToWidget: make(map[string]Widget),
54+ splitOrientation: make(map[string]Orientation),
55+ paneToStack: make(map[string]*StackedView),
5156 }
5257 }
5358
59+func (tr *TreeRenderer) SetOnSplitRatioChanged(fn func(nodeID string, ratio float64)) {
60+ tr.mu.Lock()
61+ defer tr.mu.Unlock()
62+
63+ tr.onSplitRatioChanged = fn
64+}
65+
5466 // Build constructs the entire widget tree from a root PaneNode.
5567 // Returns the root widget that can be embedded in a container.
5668 func (tr *TreeRenderer) Build(ctx context.Context, root *entity.PaneNode) (Widget, error) {
@@ -63,6 +75,7 @@ func (tr *TreeRenderer) Build(ctx context.Context, root *entity.PaneNode) (Widge
6375
6476 // Clear previous mappings
6577 tr.nodeToWidget = make(map[string]Widget)
78+ tr.splitOrientation = make(map[string]Orientation)
6679 tr.paneToStack = make(map[string]*StackedView)
6780
6881 return tr.renderNode(ctx, root)
@@ -166,6 +179,16 @@ func (tr *TreeRenderer) renderSplit(ctx context.Context, node *entity.PaneNode)
166179
167180 // Create split view
168181 splitView := NewSplitView(ctx, tr.factory, orientation, leftWidget, rightWidget, node.SplitRatio)
182+ nodeID := node.ID
183+ splitView.SetOnRatioChanged(func(ratio float64) {
184+ tr.mu.RLock()
185+ cb := tr.onSplitRatioChanged
186+ tr.mu.RUnlock()
187+ if cb != nil {
188+ cb(nodeID, ratio)
189+ }
190+ })
191+ tr.splitOrientation[node.ID] = orientation
169192
170193 return splitView.Widget(), nil
171194 }
@@ -247,6 +270,7 @@ func (tr *TreeRenderer) Clear() {
247270 defer tr.mu.Unlock()
248271
249272 tr.nodeToWidget = make(map[string]Widget)
273+ tr.splitOrientation = make(map[string]Orientation)
250274 }
251275
252276 // RegisterWidget adds or updates a node-to-widget mapping.
@@ -258,6 +282,15 @@ func (tr *TreeRenderer) RegisterWidget(nodeID string, widget Widget) {
258282 tr.nodeToWidget[nodeID] = widget
259283 }
260284
285+// RegisterSplit registers a split widget with its orientation.
286+func (tr *TreeRenderer) RegisterSplit(nodeID string, widget Widget, orientation Orientation) {
287+ tr.mu.Lock()
288+ defer tr.mu.Unlock()
289+
290+ tr.nodeToWidget[nodeID] = widget
291+ tr.splitOrientation[nodeID] = orientation
292+}
293+
261294 // UnregisterWidget removes a node-to-widget mapping.
262295 // Use this when a node is removed or replaced during incremental operations.
263296 func (tr *TreeRenderer) UnregisterWidget(nodeID string) {
@@ -265,6 +298,7 @@ func (tr *TreeRenderer) UnregisterWidget(nodeID string) {
265298 defer tr.mu.Unlock()
266299
267300 delete(tr.nodeToWidget, nodeID)
301+ delete(tr.splitOrientation, nodeID)
268302 }
269303
270304 // UpdateSplitRatio updates the ratio of a split view by node ID.
@@ -272,19 +306,33 @@ func (tr *TreeRenderer) UnregisterWidget(nodeID string) {
272306 func (tr *TreeRenderer) UpdateSplitRatio(nodeID string, ratio float64) error {
273307 tr.mu.RLock()
274308 widget, ok := tr.nodeToWidget[nodeID]
309+ orientation, okOrient := tr.splitOrientation[nodeID]
275310 tr.mu.RUnlock()
276311
277312 if !ok {
278313 return ErrNodeNotFound
279314 }
280315
281- // The widget should be a PanedWidget from a SplitView
282- // We can't directly update the ratio since we only store the Widget interface
283- // The caller would need to track SplitView instances separately or
284- // use the underlying PanedWidget's SetPosition method
316+ paned, ok := widget.(PanedWidget)
317+ if !ok {
318+ return fmt.Errorf("widget is not a PanedWidget")
319+ }
320+ if !okOrient {
321+ return fmt.Errorf("split orientation not found")
322+ }
323+
324+ totalSize := 0
325+ switch orientation {
326+ case OrientationHorizontal:
327+ totalSize = paned.GetAllocatedWidth()
328+ case OrientationVertical:
329+ totalSize = paned.GetAllocatedHeight()
330+ }
331+ if totalSize <= 0 {
332+ return nil
333+ }
285334
286- // For now, we can at least verify the widget exists
287- _ = widget
335+ paned.SetPosition(int(float64(totalSize) * ratio))
288336 return nil
289337 }
290338
@@ -3,6 +3,7 @@ package layout
3 import (3 import (
4 "context"4 "context"
5 "errors"5 "errors"
6+ "fmt"
6 "sync"7 "sync"
7 8
8 "github.com/bnema/dumber/internal/domain/entity"9 "github.com/bnema/dumber/internal/domain/entity"
@@ -27,14 +28,17 @@ type PaneViewFactory interface {
27 // It maps each node to its corresponding GTK widget and maintains28 // It maps each node to its corresponding GTK widget and maintains
28 // a lookup table for finding widgets by node ID.29 // a lookup table for finding widgets by node ID.
29 type TreeRenderer struct {30 type TreeRenderer struct {
30- factory WidgetFactory31+ factory WidgetFactory
31- paneViewFactory PaneViewFactory32+ paneViewFactory PaneViewFactory
32- logger zerolog.Logger33+ logger zerolog.Logger
33- nodeToWidget map[string]Widget34+ nodeToWidget map[string]Widget
35+ splitOrientation map[string]Orientation
34 // paneToStack maps pane IDs to their containing StackedView.36 // paneToStack maps pane IDs to their containing StackedView.
35 // Every leaf pane is wrapped in a StackedView for easy stacking later.37 // Every leaf pane is wrapped in a StackedView for easy stacking later.
36 paneToStack map[string]*StackedView38 paneToStack map[string]*StackedView
37- mu sync.RWMutex39+
40+ onSplitRatioChanged func(nodeID string, ratio float64)
41+ mu sync.RWMutex
38 }42 }
39 43
40 // NewTreeRenderer creates a new tree renderer.44 // NewTreeRenderer creates a new tree renderer.
@@ -43,14 +47,22 @@ func NewTreeRenderer(ctx context.Context, factory WidgetFactory, paneViewFactory
43 log.Debug().Msg("creating tree renderer")47 log.Debug().Msg("creating tree renderer")
44 48
45 return &TreeRenderer{49 return &TreeRenderer{
46- factory: factory,50+ factory: factory,
47- paneViewFactory: paneViewFactory,51+ paneViewFactory: paneViewFactory,
48- logger: log.With().Str("component", "tree-renderer").Logger(),52+ logger: log.With().Str("component", "tree-renderer").Logger(),
49- nodeToWidget: make(map[string]Widget),53+ nodeToWidget: make(map[string]Widget),
50- paneToStack: make(map[string]*StackedView),54+ splitOrientation: make(map[string]Orientation),
55+ paneToStack: make(map[string]*StackedView),
51 }56 }
52 }57 }
53 58
59+func (tr *TreeRenderer) SetOnSplitRatioChanged(fn func(nodeID string, ratio float64)) {
60+ tr.mu.Lock()
61+ defer tr.mu.Unlock()
62+
63+ tr.onSplitRatioChanged = fn
64+}
65+
54 // Build constructs the entire widget tree from a root PaneNode.66 // Build constructs the entire widget tree from a root PaneNode.
55 // Returns the root widget that can be embedded in a container.67 // Returns the root widget that can be embedded in a container.
56 func (tr *TreeRenderer) Build(ctx context.Context, root *entity.PaneNode) (Widget, error) {68 func (tr *TreeRenderer) Build(ctx context.Context, root *entity.PaneNode) (Widget, error) {
@@ -63,6 +75,7 @@ func (tr *TreeRenderer) Build(ctx context.Context, root *entity.PaneNode) (Widge
63 75
64 // Clear previous mappings76 // Clear previous mappings
65 tr.nodeToWidget = make(map[string]Widget)77 tr.nodeToWidget = make(map[string]Widget)
78+ tr.splitOrientation = make(map[string]Orientation)
66 tr.paneToStack = make(map[string]*StackedView)79 tr.paneToStack = make(map[string]*StackedView)
67 80
68 return tr.renderNode(ctx, root)81 return tr.renderNode(ctx, root)
@@ -166,6 +179,16 @@ func (tr *TreeRenderer) renderSplit(ctx context.Context, node *entity.PaneNode)
166 179
167 // Create split view180 // Create split view
168 splitView := NewSplitView(ctx, tr.factory, orientation, leftWidget, rightWidget, node.SplitRatio)181 splitView := NewSplitView(ctx, tr.factory, orientation, leftWidget, rightWidget, node.SplitRatio)
182+ nodeID := node.ID
183+ splitView.SetOnRatioChanged(func(ratio float64) {
184+ tr.mu.RLock()
185+ cb := tr.onSplitRatioChanged
186+ tr.mu.RUnlock()
187+ if cb != nil {
188+ cb(nodeID, ratio)
189+ }
190+ })
191+ tr.splitOrientation[node.ID] = orientation
169 192
170 return splitView.Widget(), nil193 return splitView.Widget(), nil
171 }194 }
@@ -247,6 +270,7 @@ func (tr *TreeRenderer) Clear() {
247 defer tr.mu.Unlock()270 defer tr.mu.Unlock()
248 271
249 tr.nodeToWidget = make(map[string]Widget)272 tr.nodeToWidget = make(map[string]Widget)
273+ tr.splitOrientation = make(map[string]Orientation)
250 }274 }
251 275
252 // RegisterWidget adds or updates a node-to-widget mapping.276 // RegisterWidget adds or updates a node-to-widget mapping.
@@ -258,6 +282,15 @@ func (tr *TreeRenderer) RegisterWidget(nodeID string, widget Widget) {
258 tr.nodeToWidget[nodeID] = widget282 tr.nodeToWidget[nodeID] = widget
259 }283 }
260 284
285+// RegisterSplit registers a split widget with its orientation.
286+func (tr *TreeRenderer) RegisterSplit(nodeID string, widget Widget, orientation Orientation) {
287+ tr.mu.Lock()
288+ defer tr.mu.Unlock()
289+
290+ tr.nodeToWidget[nodeID] = widget
291+ tr.splitOrientation[nodeID] = orientation
292+}
293+
261 // UnregisterWidget removes a node-to-widget mapping.294 // UnregisterWidget removes a node-to-widget mapping.
262 // Use this when a node is removed or replaced during incremental operations.295 // Use this when a node is removed or replaced during incremental operations.
263 func (tr *TreeRenderer) UnregisterWidget(nodeID string) {296 func (tr *TreeRenderer) UnregisterWidget(nodeID string) {
@@ -265,6 +298,7 @@ func (tr *TreeRenderer) UnregisterWidget(nodeID string) {
265 defer tr.mu.Unlock()298 defer tr.mu.Unlock()
266 299
267 delete(tr.nodeToWidget, nodeID)300 delete(tr.nodeToWidget, nodeID)
301+ delete(tr.splitOrientation, nodeID)
268 }302 }
269 303
270 // UpdateSplitRatio updates the ratio of a split view by node ID.304 // UpdateSplitRatio updates the ratio of a split view by node ID.
@@ -272,19 +306,33 @@ func (tr *TreeRenderer) UnregisterWidget(nodeID string) {
272 func (tr *TreeRenderer) UpdateSplitRatio(nodeID string, ratio float64) error {306 func (tr *TreeRenderer) UpdateSplitRatio(nodeID string, ratio float64) error {
273 tr.mu.RLock()307 tr.mu.RLock()
274 widget, ok := tr.nodeToWidget[nodeID]308 widget, ok := tr.nodeToWidget[nodeID]
309+ orientation, okOrient := tr.splitOrientation[nodeID]
275 tr.mu.RUnlock()310 tr.mu.RUnlock()
276 311
277 if !ok {312 if !ok {
278 return ErrNodeNotFound313 return ErrNodeNotFound
279 }314 }
280 315
281- // The widget should be a PanedWidget from a SplitView316+ paned, ok := widget.(PanedWidget)
282- // We can't directly update the ratio since we only store the Widget interface317+ if !ok {
283- // The caller would need to track SplitView instances separately or318+ return fmt.Errorf("widget is not a PanedWidget")
284- // use the underlying PanedWidget's SetPosition method319+ }
320+ if !okOrient {
321+ return fmt.Errorf("split orientation not found")
322+ }
323+
324+ totalSize := 0
325+ switch orientation {
326+ case OrientationHorizontal:
327+ totalSize = paned.GetAllocatedWidth()
328+ case OrientationVertical:
329+ totalSize = paned.GetAllocatedHeight()
330+ }
331+ if totalSize <= 0 {
332+ return nil
333+ }
285 334
286- // For now, we can at least verify the widget exists335+ paned.SetPosition(int(float64(totalSize) * ratio))
287- _ = widget
288 return nil336 return nil
289 }337 }
290 338
M internal/ui/layout/tree_test.go +8 -1
@@ -123,6 +123,7 @@ func TestBuild_HorizontalSplit(t *testing.T) {
123123 mockRightStackBox.EXPECT().SetVisible(true).Once()
124124 mockPaned.EXPECT().SetStartChild(mockLeftStackBox).Once()
125125 mockPaned.EXPECT().SetEndChild(mockRightStackBox).Once()
126+ mockPaned.EXPECT().ConnectNotifyPosition(mock.Anything).Return(uint32(0)).Once()
126127 mockPaned.EXPECT().GetAllocatedWidth().Return(0).Once()
127128 mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once()
128129 mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once()
@@ -172,6 +173,7 @@ func TestBuild_VerticalSplit(t *testing.T) {
172173 mockBottomStackBox.EXPECT().SetVisible(true).Once()
173174 mockPaned.EXPECT().SetStartChild(mockTopStackBox).Once()
174175 mockPaned.EXPECT().SetEndChild(mockBottomStackBox).Once()
176+ mockPaned.EXPECT().ConnectNotifyPosition(mock.Anything).Return(uint32(0)).Once()
175177 mockPaned.EXPECT().GetAllocatedHeight().Return(0).Once()
176178 mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once()
177179 mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once()
@@ -240,6 +242,7 @@ func TestBuild_NestedSplits(t *testing.T) {
240242 mockStackBox2.EXPECT().SetVisible(true).Once()
241243 mockInnerPaned.EXPECT().SetStartChild(mockStackBox1).Once()
242244 mockInnerPaned.EXPECT().SetEndChild(mockStackBox2).Once()
245+ mockInnerPaned.EXPECT().ConnectNotifyPosition(mock.Anything).Return(uint32(0)).Once()
243246 mockInnerPaned.EXPECT().GetAllocatedHeight().Return(0).Once()
244247 mockInnerPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once()
245248 mockInnerPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once()
@@ -253,6 +256,7 @@ func TestBuild_NestedSplits(t *testing.T) {
253256 mockStackBox3.EXPECT().SetVisible(true).Once()
254257 mockOuterPaned.EXPECT().SetStartChild(mockInnerPaned).Once()
255258 mockOuterPaned.EXPECT().SetEndChild(mockStackBox3).Once()
259+ mockOuterPaned.EXPECT().ConnectNotifyPosition(mock.Anything).Return(uint32(0)).Once()
256260 mockOuterPaned.EXPECT().GetAllocatedWidth().Return(0).Once()
257261 mockOuterPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once()
258262 mockOuterPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once()
@@ -414,6 +418,7 @@ func TestGetNodeIDs_ReturnsAllIDs(t *testing.T) {
414418 mockRightStackBox.EXPECT().SetVisible(true).Once()
415419 mockPaned.EXPECT().SetStartChild(mockLeftStackBox).Once()
416420 mockPaned.EXPECT().SetEndChild(mockRightStackBox).Once()
421+ mockPaned.EXPECT().ConnectNotifyPosition(mock.Anything).Return(uint32(0)).Once()
417422 mockPaned.EXPECT().GetAllocatedWidth().Return(0).Once()
418423 mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once()
419424 mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once()
@@ -538,8 +543,10 @@ func TestUpdateSplitRatio_ExistingNode(t *testing.T) {
538543 mockRightStackBox.EXPECT().SetVisible(true).Once()
539544 mockPaned.EXPECT().SetStartChild(mockLeftStackBox).Once()
540545 mockPaned.EXPECT().SetEndChild(mockRightStackBox).Once()
541- mockPaned.EXPECT().GetAllocatedWidth().Return(0).Once()
546+ mockPaned.EXPECT().ConnectNotifyPosition(mock.Anything).Return(uint32(0)).Once()
547+ mockPaned.EXPECT().GetAllocatedWidth().Return(0).Twice()
542548 mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once()
549+
543550 mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once()
544551
545552 renderer := layout.NewTreeRenderer(ctx, mockFactory, mockPaneViewFactory)
@@ -123,6 +123,7 @@ func TestBuild_HorizontalSplit(t *testing.T) {
123 mockRightStackBox.EXPECT().SetVisible(true).Once()123 mockRightStackBox.EXPECT().SetVisible(true).Once()
124 mockPaned.EXPECT().SetStartChild(mockLeftStackBox).Once()124 mockPaned.EXPECT().SetStartChild(mockLeftStackBox).Once()
125 mockPaned.EXPECT().SetEndChild(mockRightStackBox).Once()125 mockPaned.EXPECT().SetEndChild(mockRightStackBox).Once()
126+ mockPaned.EXPECT().ConnectNotifyPosition(mock.Anything).Return(uint32(0)).Once()
126 mockPaned.EXPECT().GetAllocatedWidth().Return(0).Once()127 mockPaned.EXPECT().GetAllocatedWidth().Return(0).Once()
127 mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once()128 mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once()
128 mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once()129 mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once()
@@ -172,6 +173,7 @@ func TestBuild_VerticalSplit(t *testing.T) {
172 mockBottomStackBox.EXPECT().SetVisible(true).Once()173 mockBottomStackBox.EXPECT().SetVisible(true).Once()
173 mockPaned.EXPECT().SetStartChild(mockTopStackBox).Once()174 mockPaned.EXPECT().SetStartChild(mockTopStackBox).Once()
174 mockPaned.EXPECT().SetEndChild(mockBottomStackBox).Once()175 mockPaned.EXPECT().SetEndChild(mockBottomStackBox).Once()
176+ mockPaned.EXPECT().ConnectNotifyPosition(mock.Anything).Return(uint32(0)).Once()
175 mockPaned.EXPECT().GetAllocatedHeight().Return(0).Once()177 mockPaned.EXPECT().GetAllocatedHeight().Return(0).Once()
176 mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once()178 mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once()
177 mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once()179 mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once()
@@ -240,6 +242,7 @@ func TestBuild_NestedSplits(t *testing.T) {
240 mockStackBox2.EXPECT().SetVisible(true).Once()242 mockStackBox2.EXPECT().SetVisible(true).Once()
241 mockInnerPaned.EXPECT().SetStartChild(mockStackBox1).Once()243 mockInnerPaned.EXPECT().SetStartChild(mockStackBox1).Once()
242 mockInnerPaned.EXPECT().SetEndChild(mockStackBox2).Once()244 mockInnerPaned.EXPECT().SetEndChild(mockStackBox2).Once()
245+ mockInnerPaned.EXPECT().ConnectNotifyPosition(mock.Anything).Return(uint32(0)).Once()
243 mockInnerPaned.EXPECT().GetAllocatedHeight().Return(0).Once()246 mockInnerPaned.EXPECT().GetAllocatedHeight().Return(0).Once()
244 mockInnerPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once()247 mockInnerPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once()
245 mockInnerPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once()248 mockInnerPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once()
@@ -253,6 +256,7 @@ func TestBuild_NestedSplits(t *testing.T) {
253 mockStackBox3.EXPECT().SetVisible(true).Once()256 mockStackBox3.EXPECT().SetVisible(true).Once()
254 mockOuterPaned.EXPECT().SetStartChild(mockInnerPaned).Once()257 mockOuterPaned.EXPECT().SetStartChild(mockInnerPaned).Once()
255 mockOuterPaned.EXPECT().SetEndChild(mockStackBox3).Once()258 mockOuterPaned.EXPECT().SetEndChild(mockStackBox3).Once()
259+ mockOuterPaned.EXPECT().ConnectNotifyPosition(mock.Anything).Return(uint32(0)).Once()
256 mockOuterPaned.EXPECT().GetAllocatedWidth().Return(0).Once()260 mockOuterPaned.EXPECT().GetAllocatedWidth().Return(0).Once()
257 mockOuterPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once()261 mockOuterPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once()
258 mockOuterPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once()262 mockOuterPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once()
@@ -414,6 +418,7 @@ func TestGetNodeIDs_ReturnsAllIDs(t *testing.T) {
414 mockRightStackBox.EXPECT().SetVisible(true).Once()418 mockRightStackBox.EXPECT().SetVisible(true).Once()
415 mockPaned.EXPECT().SetStartChild(mockLeftStackBox).Once()419 mockPaned.EXPECT().SetStartChild(mockLeftStackBox).Once()
416 mockPaned.EXPECT().SetEndChild(mockRightStackBox).Once()420 mockPaned.EXPECT().SetEndChild(mockRightStackBox).Once()
421+ mockPaned.EXPECT().ConnectNotifyPosition(mock.Anything).Return(uint32(0)).Once()
417 mockPaned.EXPECT().GetAllocatedWidth().Return(0).Once()422 mockPaned.EXPECT().GetAllocatedWidth().Return(0).Once()
418 mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once()423 mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once()
419 mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once()424 mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once()
@@ -538,8 +543,10 @@ func TestUpdateSplitRatio_ExistingNode(t *testing.T) {
538 mockRightStackBox.EXPECT().SetVisible(true).Once()543 mockRightStackBox.EXPECT().SetVisible(true).Once()
539 mockPaned.EXPECT().SetStartChild(mockLeftStackBox).Once()544 mockPaned.EXPECT().SetStartChild(mockLeftStackBox).Once()
540 mockPaned.EXPECT().SetEndChild(mockRightStackBox).Once()545 mockPaned.EXPECT().SetEndChild(mockRightStackBox).Once()
541- mockPaned.EXPECT().GetAllocatedWidth().Return(0).Once()546+ mockPaned.EXPECT().ConnectNotifyPosition(mock.Anything).Return(uint32(0)).Once()
547+ mockPaned.EXPECT().GetAllocatedWidth().Return(0).Twice()
542 mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once()548 mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once()
549+
543 mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once()550 mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once()
544 551
545 renderer := layout.NewTreeRenderer(ctx, mockFactory, mockPaneViewFactory)552 renderer := layout.NewTreeRenderer(ctx, mockFactory, mockPaneViewFactory)
M internal/ui/layout/widgets.go +1 -0
@@ -95,6 +95,7 @@ type PanedWidget interface {
9595
9696 // Signals
9797 ConnectMap(callback func()) uint32
98+ ConnectNotifyPosition(callback func()) uint32
9899
99100 // Tick callback for frame-based updates (returns signal ID)
100101 // Callback returns true to continue, false to stop
@@ -95,6 +95,7 @@ type PanedWidget interface {
95 95
96 // Signals96 // Signals
97 ConnectMap(callback func()) uint3297 ConnectMap(callback func()) uint32
98+ ConnectNotifyPosition(callback func()) uint32
98 99
99 // Tick callback for frame-based updates (returns signal ID)100 // Tick callback for frame-based updates (returns signal ID)
100 // Callback returns true to continue, false to stop101 // Callback returns true to continue, false to stop
M internal/ui/theme/css.go +10 -0
@@ -475,6 +475,16 @@ func generatePaneCSS(p Palette) string {
475475 box-shadow: inset 0 0 0 0.25em #FFA500;
476476 border-radius: 0;
477477 }
478+
479+/* Resize mode active - thick teal inset border */
480+.resize-mode-active {
481+ background-color: transparent;
482+ /* Prefer inset shadow, but also set outline for widgets that don't paint shadows */
483+ box-shadow: inset 0 0 0 0.25em #00D4AA;
484+ outline: 0.25em solid #00D4AA;
485+ outline-offset: -0.25em;
486+ border-radius: 0;
487+}
478488 `
479489 }
480490
@@ -475,6 +475,16 @@ func generatePaneCSS(p Palette) string {
475 box-shadow: inset 0 0 0 0.25em #FFA500;475 box-shadow: inset 0 0 0 0.25em #FFA500;
476 border-radius: 0;476 border-radius: 0;
477 }477 }
478+
479+/* Resize mode active - thick teal inset border */
480+.resize-mode-active {
481+ background-color: transparent;
482+ /* Prefer inset shadow, but also set outline for widgets that don't paint shadows */
483+ box-shadow: inset 0 0 0 0.25em #00D4AA;
484+ outline: 0.25em solid #00D4AA;
485+ outline-offset: -0.25em;
486+ border-radius: 0;
487+}
478 `488 `
479 }489 }
480 490

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