Merge pull request #36 from bnema/feat/pane-resizing
feat(ui): add Zellij-style pane resize mode
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. | |||
| 5 | 5 | ## [Unreleased] | |
| 6 | 6 | ||
| 7 | 7 | ### 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 | 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 | 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 | 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. | |
| @@ -5,6 +5,8 @@ All notable changes to this project will be documented in this file. | |||||
| 5 | ## [Unreleased] | 5 | ## [Unreleased] | ||
| 6 | 6 | ||||
| 7 | ### Added | 7 | ### 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 | |||
| 80 | 80 | | **Alt+Arrow Keys** | Navigate Panes | Move focus between panes | | |
| 81 | 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 | 93 | #### Session Management | |
| 84 | 94 | | Shortcut | Action | Notes | | |
| 85 | 95 | |----------|--------|-------| | |
| @@ -91,6 +101,7 @@ Early development with regular releases. Core features work well for daily use b | |||
| 91 | 101 | ### Mouse Controls | |
| 92 | 102 | | Action | Result | Notes | | |
| 93 | 103 | |--------|---------|-------| | |
| 104 | + | | **Drag pane divider** | Resize panes | Updates split ratio and persists in session snapshots | | |
| 94 | 105 | | **Ctrl+Scroll Up** | Zoom In | Smooth zoom control | | |
| 95 | 106 | | **Ctrl+Scroll Down** | Zoom Out | Smooth zoom control | | |
| 96 | 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 | 305 | ## 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 | 309 | ### How it Works | |
| 299 | 310 | 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 Management | 93 | #### 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 Controls | 101 | ### 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 Management | 305 | ## 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 Works | 309 | ### How it Works | ||
| 299 | 1. **Automatic Snapshots**: Session state is saved automatically (debounced, every 5s by default) and on graceful shutdown | 310 | 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"] | |||
| 243 | 243 | ||
| 244 | 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 | 278 | ### Global Shortcuts | |
| 247 | 279 | ||
| 248 | 280 | | Key | Type | Default | Description | | |
| @@ -277,6 +309,8 @@ cancel = ["escape"] | |||
| 277 | 309 | | `workspace.styling.tab_mode_border_color` | string | `"#FFA500"` | Tab mode border color (orange) | | |
| 278 | 310 | | `workspace.styling.session_mode_border_width` | int | `4` | Session mode border width (px) - Ctrl+O overlay | | |
| 279 | 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 | 314 | | `workspace.styling.transition_duration` | int | `120` | Border transition duration (ms) | | |
| 281 | 315 | ||
| 282 | 316 | ## 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 Shortcuts | 278 | ### 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 | ## Session | 316 | ## Session | ||
M internal/application/usecase/manage_panes.go +270 -0
| @@ -2,7 +2,9 @@ package usecase | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "errors" | |
| 5 | 6 | "fmt" | |
| 7 | + | "math" | |
| 6 | 8 | "sort" | |
| 7 | 9 | ||
| 8 | 10 | "github.com/bnema/dumber/internal/domain/entity" | |
| @@ -29,6 +31,26 @@ const ( | |||
| 29 | 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 | 54 | // ManagePanesUseCase handles pane tree operations. | |
| 33 | 55 | type ManagePanesUseCase struct { | |
| 34 | 56 | idGenerator IDGenerator | |
| @@ -166,6 +188,254 @@ func (uc *ManagePanesUseCase) Split(ctx context.Context, input SplitPaneInput) ( | |||
| 166 | 188 | }, 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 | 439 | // Close removes a pane and promotes its sibling. | |
| 170 | 440 | // Returns the sibling node that was promoted, or nil if this was the last pane. | |
| 171 | 441 | 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 IDGenerator | 56 | idGenerator IDGenerator | ||
| @@ -166,6 +188,254 @@ func (uc *ManagePanesUseCase) Split(ctx context.Context, input SplitPaneInput) ( | |||||
| 166 | }, nil | 188 | }, 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 ( | |||
| 19 | 19 | defaultOmniboxInitialBehavior = "recent" | |
| 20 | 20 | ||
| 21 | 21 | // 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" | |
| 28 | 32 | ||
| 29 | 33 | // Session defaults | |
| 30 | 34 | defaultSessionActivationShortcut = "ctrl+o" | |
| @@ -50,6 +54,10 @@ const ( | |||
| 50 | 54 | defaultSessionModeBorderWidth = 4 | |
| 51 | 55 | 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 | 61 | // Other styling | |
| 54 | 62 | defaultTransitionDuration = 120 | |
| 55 | 63 | defaultUIScale = 1.0 // UI scale multiplier (1.0 = 100%, 1.2 = 120%) | |
| @@ -174,6 +182,26 @@ func DefaultConfig() *Config { | |||
| 174 | 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 | 205 | Shortcuts: GlobalShortcutsConfig{ | |
| 178 | 206 | ClosePane: "ctrl+w", | |
| 179 | 207 | NextTab: "ctrl+tab", | |
| @@ -199,6 +227,8 @@ func DefaultConfig() *Config { | |||
| 199 | 227 | TabModeBorderColor: defaultTabModeBorderColor, | |
| 200 | 228 | SessionModeBorderWidth: defaultSessionModeBorderWidth, | |
| 201 | 229 | SessionModeBorderColor: defaultSessionModeBorderColor, | |
| 230 | + | ResizeModeBorderWidth: defaultResizeModeBorderWidth, | |
| 231 | + | ResizeModeBorderColor: defaultResizeModeBorderColor, | |
| 202 | 232 | TransitionDuration: defaultTransitionDuration, | |
| 203 | 233 | }, | |
| 204 | 234 | }, | |
| @@ -19,12 +19,16 @@ const ( | |||||
| 19 | defaultOmniboxInitialBehavior = "recent" | 19 | defaultOmniboxInitialBehavior = "recent" | ||
| 20 | 20 | ||||
| 21 | // Workspace defaults | 21 | // Workspace defaults | ||
| 22 | - | defaultPaneActivationShortcut = "ctrl+p" | 22 | + | defaultPaneActivationShortcut = "ctrl+p" |
| 23 | - | defaultPaneTimeoutMilliseconds = 3000 | 23 | + | defaultPaneTimeoutMilliseconds = 3000 |
| 24 | - | defaultTabActivationShortcut = "ctrl+t" | 24 | + | defaultTabActivationShortcut = "ctrl+t" |
| 25 | - | defaultTabTimeoutMilliseconds = 3000 | 25 | + | 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 defaults | 33 | // Session defaults | ||
| 30 | defaultSessionActivationShortcut = "ctrl+o" | 34 | defaultSessionActivationShortcut = "ctrl+o" | ||
| @@ -50,6 +54,10 @@ const ( | |||||
| 50 | defaultSessionModeBorderWidth = 4 | 54 | defaultSessionModeBorderWidth = 4 | ||
| 51 | defaultSessionModeBorderColor = "#9B59B6" // Purple for session mode indicator | 55 | 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 styling | 61 | // Other styling | ||
| 54 | defaultTransitionDuration = 120 | 62 | 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) { | |||
| 386 | 386 | m.viper.SetDefault("workspace.tab_mode.activation_shortcut", defaults.Workspace.TabMode.ActivationShortcut) | |
| 387 | 387 | m.viper.SetDefault("workspace.tab_mode.timeout_ms", defaults.Workspace.TabMode.TimeoutMilliseconds) | |
| 388 | 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 | 394 | m.viper.SetDefault("workspace.shortcuts.close_pane", defaults.Workspace.Shortcuts.ClosePane) | |
| 390 | 395 | m.viper.SetDefault("workspace.shortcuts.next_tab", defaults.Workspace.Shortcuts.NextTab) | |
| 391 | 396 | m.viper.SetDefault("workspace.shortcuts.previous_tab", defaults.Workspace.Shortcuts.PreviousTab) | |
| @@ -406,6 +411,8 @@ func (m *Manager) setWorkspaceDefaults(defaults *Config) { | |||
| 406 | 411 | m.viper.SetDefault("workspace.styling.tab_mode_border_color", defaults.Workspace.Styling.TabModeBorderColor) | |
| 407 | 412 | m.viper.SetDefault("workspace.styling.session_mode_border_width", defaults.Workspace.Styling.SessionModeBorderWidth) | |
| 408 | 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 | 416 | m.viper.SetDefault("workspace.styling.transition_duration", defaults.Workspace.Styling.TransitionDuration) | |
| 410 | 417 | } | |
| 411 | 418 | ||
| @@ -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 { | |||
| 299 | 299 | PaneMode PaneModeConfig `mapstructure:"pane_mode" yaml:"pane_mode" toml:"pane_mode" json:"pane_mode"` | |
| 300 | 300 | // TabMode defines modal tab behavior and bindings (Alt+T). | |
| 301 | 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 | 304 | // Shortcuts holds global (non-modal) keyboard shortcuts. | |
| 303 | 305 | Shortcuts GlobalShortcutsConfig `mapstructure:"shortcuts" yaml:"shortcuts" toml:"shortcuts" json:"shortcuts"` | |
| 304 | 306 | // TabBarPosition determines tab bar placement: "top" or "bottom". | |
| @@ -349,6 +351,27 @@ func (t *TabModeConfig) GetKeyBindings() map[string]string { | |||
| 349 | 351 | 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 | 375 | // GlobalShortcutsConfig defines global shortcuts (always active, not modal). | |
| 353 | 376 | type GlobalShortcutsConfig struct { | |
| 354 | 377 | ClosePane string `mapstructure:"close_pane" yaml:"close_pane" toml:"close_pane" json:"close_pane"` | |
| @@ -422,6 +445,12 @@ type WorkspaceStylingConfig struct { | |||
| 422 | 445 | // Defaults to "#9B59B6" (purple) if not set - MUST be different from other mode colors | |
| 423 | 446 | 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 | 454 | // TransitionDuration in milliseconds for border animations | |
| 426 | 455 | 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 | } | |
| @@ -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 keyToAction | 351 | 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 colors | 445 | // 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 line | 446 | 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 animations | 454 | // 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 line | 455 | 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 { | |||
| 70 | 70 | focusMgr *focus.Manager | |
| 71 | 71 | borderMgr *focus.BorderManager | |
| 72 | 72 | ||
| 73 | + | resizeModeBorderTarget layout.Widget | |
| 74 | + | ||
| 73 | 75 | // Omnibox configuration (omnibox is created per workspace view) | |
| 74 | 76 | omniboxCfg component.OmniboxConfig | |
| 75 | 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 | 912 | log := logging.FromContext(ctx) | |
| 911 | 913 | log.Debug().Str("from", from.String()).Str("to", to.String()).Msg("input mode changed") | |
| 912 | 914 | ||
| 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. | |
| 914 | 924 | if a.borderMgr != nil { | |
| 915 | 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 | 970 | // createWorkspaceView creates a WorkspaceView for a tab and attaches it to the content area. | |
| 920 | 971 | func (a *App) createWorkspaceView(ctx context.Context, tab *entity.Tab) { | |
| 921 | 972 | a.createWorkspaceViewWithoutAttach(ctx, tab) | |
| @@ -961,14 +1012,30 @@ func (a *App) createWorkspaceViewWithoutAttach(ctx context.Context, tab *entity. | |||
| 961 | 1012 | a.contentCoord.AttachToWorkspace(ctx, tab.Workspace, wsView) | |
| 962 | 1013 | } | |
| 963 | 1014 | ||
| 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. | |
| 966 | 1017 | ||
| 967 | 1018 | // Set omnibox config for this workspace view | |
| 968 | 1019 | wsView.SetOmniboxConfig(a.omniboxCfg) | |
| 969 | 1020 | // Set find bar config for this workspace view | |
| 970 | 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 | 1039 | // Store in map | |
| 973 | 1040 | a.workspaceViews[tab.ID] = wsView | |
| 974 | 1041 | ||
| @@ -70,6 +70,8 @@ type App struct { | |||||
| 70 | focusMgr *focus.Manager | 70 | focusMgr *focus.Manager | ||
| 71 | borderMgr *focus.BorderManager | 71 | 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.OmniboxConfig | 76 | 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 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. | |||
| 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-workspace | 1015 | + | // Note: Mode borders for tab/pane/session are attached to MainWindow. |
| 965 | - | // This ensures it's visible regardless of which tab is active | 1016 | + | // Resize mode border is attached to the active pane's stack container. |
| 966 | 1017 | ||||
| 967 | // Set omnibox config for this workspace view | 1018 | // 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 view | 1020 | // 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 map | 1039 | // Store in map | ||
| 973 | a.workspaceViews[tab.ID] = wsView | 1040 | a.workspaceViews[tab.ID] = wsView | ||
| 974 | 1041 | ||||
M internal/ui/component/workspace_view.go +17 -1
| @@ -47,7 +47,8 @@ type WorkspaceView struct { | |||
| 47 | 47 | workspace *entity.Workspace | |
| 48 | 48 | 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 | 53 | mu sync.RWMutex | |
| 53 | 54 | } | |
| @@ -128,6 +129,14 @@ func NewWorkspaceView(ctx context.Context, factory layout.WidgetFactory) *Worksp | |||
| 128 | 129 | ||
| 129 | 130 | // Create tree renderer with our adapter as the pane view factory | |
| 130 | 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 | 141 | return wv | |
| 133 | 142 | } | |
| @@ -303,6 +312,13 @@ func (wv *WorkspaceView) SetOnPaneFocused(fn func(paneID entity.PaneID)) { | |||
| 303 | 312 | 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 | 322 | // Rebuild rebuilds the widget tree from the current workspace. | |
| 307 | 323 | // Use this after structural changes like splits or closes. | |
| 308 | 324 | func (wv *WorkspaceView) Rebuild(ctx context.Context) error { | |
| @@ -47,7 +47,8 @@ type WorkspaceView struct { | |||||
| 47 | workspace *entity.Workspace | 47 | workspace *entity.Workspace | ||
| 48 | paneViews map[entity.PaneID]*PaneView | 48 | 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.RWMutex | 53 | 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 factory | 130 | // 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 wv | 141 | return wv | ||
| 133 | } | 142 | } | ||
| @@ -303,6 +312,13 @@ func (wv *WorkspaceView) SetOnPaneFocused(fn func(paneID entity.PaneID)) { | |||||
| 303 | wv.onPaneFocused = fn | 312 | 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) { | |||
| 199 | 199 | mockStackBox2.EXPECT().SetVisible(true).Once() | |
| 200 | 200 | mockPaned.EXPECT().SetStartChild(mockStackBox1).Once() | |
| 201 | 201 | mockPaned.EXPECT().SetEndChild(mockStackBox2).Once() | |
| 202 | + | mockPaned.EXPECT().ConnectNotifyPosition(mock.Anything).Return(uint32(0)).Once() | |
| 202 | 203 | mockPaned.EXPECT().GetAllocatedWidth().Return(0).Once() | |
| 203 | 204 | mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once() | |
| 204 | 205 | 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 | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "errors" | |
| 5 | 6 | "fmt" | |
| 6 | 7 | ||
| 7 | 8 | "github.com/bnema/dumber/internal/application/usecase" | |
| @@ -322,11 +323,12 @@ func (c *WorkspaceCoordinator) doIncrementalStackSplit( | |||
| 322 | 323 | splitView = layout.NewSplitView(ctx, factory, layout.OrientationHorizontal, | |
| 323 | 324 | existingStackWidget, newStackedView.Widget(), output.SplitRatio) | |
| 324 | 325 | } | |
| 326 | + | c.wireSplitRatioPersistence(ctx, splitView, output.ParentNode.ID) | |
| 325 | 327 | ||
| 326 | 328 | wsView.SetRootWidgetDirect(splitView.Widget()) | |
| 327 | 329 | ||
| 328 | 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 | 333 | } else { | |
| 332 | 334 | // Non-root split: stack is inside another split, need to replace in grandparent | |
| @@ -369,6 +371,7 @@ func (c *WorkspaceCoordinator) doIncrementalStackSplit( | |||
| 369 | 371 | splitView = layout.NewSplitView(ctx, factory, layout.OrientationHorizontal, | |
| 370 | 372 | existingStackWidget, newStackedView.Widget(), output.SplitRatio) | |
| 371 | 373 | } | |
| 374 | + | c.wireSplitRatioPersistence(ctx, splitView, output.ParentNode.ID) | |
| 372 | 375 | ||
| 373 | 376 | // Put the new split view in the grandparent's slot | |
| 374 | 377 | if isStartChild { | |
| @@ -377,7 +380,7 @@ func (c *WorkspaceCoordinator) doIncrementalStackSplit( | |||
| 377 | 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 | 385 | log.Debug(). | |
| 383 | 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 | 579 | func (c *WorkspaceCoordinator) replaceRootSplit( | |
| 565 | 580 | ctx context.Context, | |
| 566 | 581 | wsView *component.WorkspaceView, | |
| @@ -583,9 +598,10 @@ func (c *WorkspaceCoordinator) replaceRootSplit( | |||
| 583 | 598 | splitView = layout.NewSplitView(ctx, factory, orientation, | |
| 584 | 599 | newStackedView.Widget(), existingRootWidget, output.SplitRatio) | |
| 585 | 600 | } | |
| 601 | + | c.wireSplitRatioPersistence(ctx, splitView, output.ParentNode.ID) | |
| 586 | 602 | ||
| 587 | 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 | 606 | logging.FromContext(ctx).Debug().Msg("root split: replaced root with new split view") | |
| 591 | 607 | return nil | |
| @@ -642,6 +658,7 @@ func (c *WorkspaceCoordinator) replaceNonRootSplit( | |||
| 642 | 658 | splitView = layout.NewSplitView(ctx, factory, orientation, | |
| 643 | 659 | newStackedView.Widget(), activePaneWidget, output.SplitRatio) | |
| 644 | 660 | } | |
| 661 | + | c.wireSplitRatioPersistence(ctx, splitView, output.ParentNode.ID) | |
| 645 | 662 | ||
| 646 | 663 | if isStartChild { | |
| 647 | 664 | panedWidget.SetStartChild(splitView.Widget()) | |
| @@ -649,7 +666,7 @@ func (c *WorkspaceCoordinator) replaceNonRootSplit( | |||
| 649 | 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 | 671 | log.Debug(). | |
| 655 | 672 | Bool("was_start_child", isStartChild). | |
| @@ -1860,3 +1877,88 @@ func (c *WorkspaceCoordinator) ShowToastOnActivePane(ctx context.Context, messag | |||
| 1860 | 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 | + | } | |
| @@ -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 grandparent | 334 | // 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 slot | 376 | // 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 nil | 607 | 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() { | |||
| 126 | 126 | input.ActionFocusLeft: func(ctx context.Context) error { return d.wsCoord.FocusPane(ctx, usecase.NavLeft) }, | |
| 127 | 127 | input.ActionFocusUp: func(ctx context.Context) error { return d.wsCoord.FocusPane(ctx, usecase.NavUp) }, | |
| 128 | 128 | input.ActionFocusDown: func(ctx context.Context) error { return d.wsCoord.FocusPane(ctx, usecase.NavDown) }, | |
| 129 | + | // Resize actions | |
| 130 | + | input.ActionResizeIncreaseLeft: func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeIncreaseLeft) }, | |
| 131 | + | input.ActionResizeIncreaseRight: func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeIncreaseRight) }, | |
| 132 | + | input.ActionResizeIncreaseUp: func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeIncreaseUp) }, | |
| 133 | + | input.ActionResizeIncreaseDown: func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeIncreaseDown) }, | |
| 134 | + | input.ActionResizeDecreaseLeft: func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeDecreaseLeft) }, | |
| 135 | + | input.ActionResizeDecreaseRight: func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeDecreaseRight) }, | |
| 136 | + | input.ActionResizeDecreaseUp: func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeDecreaseUp) }, | |
| 137 | + | input.ActionResizeDecreaseDown: func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeDecreaseDown) }, | |
| 138 | + | input.ActionResizeIncrease: func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeIncrease) }, | |
| 139 | + | input.ActionResizeDecrease: func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeDecrease) }, | |
| 129 | 140 | // Stack navigation | |
| 130 | 141 | input.ActionStackNavUp: func(ctx context.Context) error { return d.wsCoord.NavigateStack(ctx, "up") }, | |
| 131 | 142 | input.ActionStackNavDown: func(ctx context.Context) error { return d.wsCoord.NavigateStack(ctx, "down") }, | |
| @@ -126,6 +126,17 @@ func (d *KeyboardDispatcher) initActionHandlers() { | |||||
| 126 | input.ActionFocusLeft: func(ctx context.Context) error { return d.wsCoord.FocusPane(ctx, usecase.NavLeft) }, | 126 | input.ActionFocusLeft: func(ctx context.Context) error { return d.wsCoord.FocusPane(ctx, usecase.NavLeft) }, | ||
| 127 | input.ActionFocusUp: func(ctx context.Context) error { return d.wsCoord.FocusPane(ctx, usecase.NavUp) }, | 127 | input.ActionFocusUp: func(ctx context.Context) error { return d.wsCoord.FocusPane(ctx, usecase.NavUp) }, | ||
| 128 | input.ActionFocusDown: func(ctx context.Context) error { return d.wsCoord.FocusPane(ctx, usecase.NavDown) }, | 128 | input.ActionFocusDown: func(ctx context.Context) error { return d.wsCoord.FocusPane(ctx, usecase.NavDown) }, | ||
| 129 | + | // Resize actions | |||
| 130 | + | input.ActionResizeIncreaseLeft: func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeIncreaseLeft) }, | |||
| 131 | + | input.ActionResizeIncreaseRight: func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeIncreaseRight) }, | |||
| 132 | + | input.ActionResizeIncreaseUp: func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeIncreaseUp) }, | |||
| 133 | + | input.ActionResizeIncreaseDown: func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeIncreaseDown) }, | |||
| 134 | + | input.ActionResizeDecreaseLeft: func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeDecreaseLeft) }, | |||
| 135 | + | input.ActionResizeDecreaseRight: func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeDecreaseRight) }, | |||
| 136 | + | input.ActionResizeDecreaseUp: func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeDecreaseUp) }, | |||
| 137 | + | input.ActionResizeDecreaseDown: func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeDecreaseDown) }, | |||
| 138 | + | input.ActionResizeIncrease: func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeIncrease) }, | |||
| 139 | + | input.ActionResizeDecrease: func(ctx context.Context) error { return d.wsCoord.Resize(ctx, usecase.ResizeDecrease) }, | |||
| 129 | // Stack navigation | 140 | // Stack navigation | ||
| 130 | input.ActionStackNavUp: func(ctx context.Context) error { return d.wsCoord.NavigateStack(ctx, "up") }, | 141 | input.ActionStackNavUp: func(ctx context.Context) error { return d.wsCoord.NavigateStack(ctx, "up") }, | ||
| 131 | input.ActionStackNavDown: func(ctx context.Context) error { return d.wsCoord.NavigateStack(ctx, "down") }, | 142 | input.ActionStackNavDown: func(ctx context.Context) error { return d.wsCoord.NavigateStack(ctx, "down") }, | ||
M internal/ui/focus/borders.go +3 -0
| @@ -76,6 +76,9 @@ func (bm *BorderManager) OnModeChange(ctx context.Context, from, to input.Mode) | |||
| 76 | 76 | bm.borderOverlay.SetVisible(true) | |
| 77 | 77 | case input.ModeNormal: | |
| 78 | 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 | 84 | 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 = to | 84 | bm.currentMode = to | ||
M internal/ui/input/keyboard.go +82 -35
| @@ -9,6 +9,7 @@ import ( | |||
| 9 | 9 | "github.com/bnema/dumber/internal/logging" | |
| 10 | 10 | "github.com/jwijenbergh/puregotk/v4/gdk" | |
| 11 | 11 | "github.com/jwijenbergh/puregotk/v4/gtk" | |
| 12 | + | "github.com/rs/zerolog" | |
| 12 | 13 | ) | |
| 13 | 14 | ||
| 14 | 15 | // ActionHandler is called when a keyboard action is triggered. | |
| @@ -145,22 +146,7 @@ func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.Modifie | |||
| 145 | 146 | ||
| 146 | 147 | mode := h.modal.Mode() | |
| 147 | 148 | ||
| 148 | - | // Look up the action for this key binding | |
| 149 | - | action, found := h.shortcuts.Lookup(binding, mode) | |
| 150 | - | ||
| 151 | - | // Fallback: check hardware keycode for Alt+number shortcuts on non-QWERTY layouts | |
| 152 | - | // This enables Alt+1-9/0 to work on AZERTY, QWERTZ, and other Latin layouts | |
| 153 | - | // where number keys produce different characters without Shift. | |
| 154 | - | if !found && mode == ModeNormal && modifiers == ModAlt { | |
| 155 | - | if keycodeAction, ok := KeycodeToTabAction[keycode]; ok { | |
| 156 | - | action = keycodeAction | |
| 157 | - | found = true | |
| 158 | - | log.Debug(). | |
| 159 | - | Uint("keycode", keycode). | |
| 160 | - | Str("action", string(action)). | |
| 161 | - | Msg("tab switch via hardware keycode fallback (non-QWERTY layout)") | |
| 162 | - | } | |
| 163 | - | } | |
| 149 | + | action, found := h.lookupAction(log, binding, mode, modifiers, keycode) | |
| 164 | 150 | ||
| 165 | 151 | if !found { | |
| 166 | 152 | if mode != ModeNormal { | |
| @@ -171,25 +157,7 @@ func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.Modifie | |||
| 171 | 157 | return false | |
| 172 | 158 | } | |
| 173 | 159 | ||
| 174 | - | // Handle special mode actions first | |
| 175 | - | switch action { | |
| 176 | - | case ActionEnterTabMode: | |
| 177 | - | timeout := time.Duration(h.cfg.Workspace.TabMode.TimeoutMilliseconds) * time.Millisecond | |
| 178 | - | h.modal.EnterTabMode(h.ctx, timeout) | |
| 179 | - | return true | |
| 180 | - | ||
| 181 | - | case ActionEnterPaneMode: | |
| 182 | - | timeout := time.Duration(h.cfg.Workspace.PaneMode.TimeoutMilliseconds) * time.Millisecond | |
| 183 | - | h.modal.EnterPaneMode(h.ctx, timeout) | |
| 184 | - | return true | |
| 185 | - | ||
| 186 | - | case ActionEnterSessionMode: | |
| 187 | - | timeout := time.Duration(h.cfg.Session.SessionMode.TimeoutMilliseconds) * time.Millisecond | |
| 188 | - | h.modal.EnterSessionMode(h.ctx, timeout) | |
| 189 | - | return true | |
| 190 | - | ||
| 191 | - | case ActionExitMode: | |
| 192 | - | h.modal.ExitMode(h.ctx) | |
| 160 | + | if h.handleModeAction(action) { | |
| 193 | 161 | return true | |
| 194 | 162 | } | |
| 195 | 163 | ||
| @@ -208,6 +176,11 @@ func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.Modifie | |||
| 208 | 176 | } | |
| 209 | 177 | } | |
| 210 | 178 | ||
| 179 | + | // In resize mode, any resize keypress should extend the timeout. | |
| 180 | + | if mode == ModeResize && isResizeAction(action) { | |
| 181 | + | h.modal.ResetTimeout(h.ctx) | |
| 182 | + | } | |
| 183 | + | ||
| 211 | 184 | // Auto-exit modal mode after certain actions | |
| 212 | 185 | if mode != ModeNormal && ShouldAutoExitMode(action) { | |
| 213 | 186 | h.modal.ExitMode(h.ctx) | |
| @@ -216,6 +189,80 @@ func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.Modifie | |||
| 216 | 189 | return true // Consumed the key | |
| 217 | 190 | } | |
| 218 | 191 | ||
| 192 | + | func (h *KeyboardHandler) lookupAction( | |
| 193 | + | log *zerolog.Logger, | |
| 194 | + | binding KeyBinding, | |
| 195 | + | mode Mode, | |
| 196 | + | modifiers Modifier, | |
| 197 | + | keycode uint, | |
| 198 | + | ) (Action, bool) { | |
| 199 | + | action, found := h.shortcuts.Lookup(binding, mode) | |
| 200 | + | ||
| 201 | + | // Fallback: check hardware keycode for Alt+number shortcuts on non-QWERTY layouts. | |
| 202 | + | if !found && mode == ModeNormal && modifiers == ModAlt { | |
| 203 | + | if keycodeAction, ok := KeycodeToTabAction[keycode]; ok { | |
| 204 | + | action = keycodeAction | |
| 205 | + | found = true | |
| 206 | + | if log != nil { | |
| 207 | + | log.Debug(). | |
| 208 | + | Uint("keycode", keycode). | |
| 209 | + | Str("action", string(action)). | |
| 210 | + | Msg("tab switch via hardware keycode fallback (non-QWERTY layout)") | |
| 211 | + | } | |
| 212 | + | } | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | return action, found | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | func isResizeAction(action Action) bool { | |
| 219 | + | switch action { | |
| 220 | + | case ActionResizeIncreaseLeft, | |
| 221 | + | ActionResizeIncreaseRight, | |
| 222 | + | ActionResizeIncreaseUp, | |
| 223 | + | ActionResizeIncreaseDown, | |
| 224 | + | ActionResizeDecreaseLeft, | |
| 225 | + | ActionResizeDecreaseRight, | |
| 226 | + | ActionResizeDecreaseUp, | |
| 227 | + | ActionResizeDecreaseDown, | |
| 228 | + | ActionResizeIncrease, | |
| 229 | + | ActionResizeDecrease: | |
| 230 | + | return true | |
| 231 | + | default: | |
| 232 | + | return false | |
| 233 | + | } | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | func (h *KeyboardHandler) handleModeAction(action Action) bool { | |
| 237 | + | switch action { | |
| 238 | + | case ActionEnterTabMode: | |
| 239 | + | timeout := time.Duration(h.cfg.Workspace.TabMode.TimeoutMilliseconds) * time.Millisecond | |
| 240 | + | h.modal.EnterTabMode(h.ctx, timeout) | |
| 241 | + | return true | |
| 242 | + | case ActionEnterPaneMode: | |
| 243 | + | timeout := time.Duration(h.cfg.Workspace.PaneMode.TimeoutMilliseconds) * time.Millisecond | |
| 244 | + | h.modal.EnterPaneMode(h.ctx, timeout) | |
| 245 | + | return true | |
| 246 | + | case ActionEnterSessionMode: | |
| 247 | + | timeout := time.Duration(h.cfg.Session.SessionMode.TimeoutMilliseconds) * time.Millisecond | |
| 248 | + | h.modal.EnterSessionMode(h.ctx, timeout) | |
| 249 | + | return true | |
| 250 | + | case ActionEnterResizeMode: | |
| 251 | + | if h.modal.Mode() == ModeResize { | |
| 252 | + | h.modal.ExitMode(h.ctx) | |
| 253 | + | return true | |
| 254 | + | } | |
| 255 | + | timeout := time.Duration(h.cfg.Workspace.ResizeMode.TimeoutMilliseconds) * time.Millisecond | |
| 256 | + | h.modal.EnterResizeMode(h.ctx, timeout) | |
| 257 | + | return true | |
| 258 | + | case ActionExitMode: | |
| 259 | + | h.modal.ExitMode(h.ctx) | |
| 260 | + | return true | |
| 261 | + | default: | |
| 262 | + | return false | |
| 263 | + | } | |
| 264 | + | } | |
| 265 | + | ||
| 219 | 266 | // EnterTabMode programmatically enters tab mode. | |
| 220 | 267 | // Useful for testing or programmatic mode changes. | |
| 221 | 268 | func (h *KeyboardHandler) EnterTabMode() { | |
| @@ -9,6 +9,7 @@ import ( | |||||
| 9 | "github.com/bnema/dumber/internal/logging" | 9 | "github.com/bnema/dumber/internal/logging" | ||
| 10 | "github.com/jwijenbergh/puregotk/v4/gdk" | 10 | "github.com/jwijenbergh/puregotk/v4/gdk" | ||
| 11 | "github.com/jwijenbergh/puregotk/v4/gtk" | 11 | "github.com/jwijenbergh/puregotk/v4/gtk" | ||
| 12 | + | "github.com/rs/zerolog" | |||
| 12 | ) | 13 | ) | ||
| 13 | 14 | ||||
| 14 | // ActionHandler is called when a keyboard action is triggered. | 15 | // ActionHandler is called when a keyboard action is triggered. | ||
| @@ -145,22 +146,7 @@ func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.Modifie | |||||
| 145 | 146 | ||||
| 146 | mode := h.modal.Mode() | 147 | mode := h.modal.Mode() | ||
| 147 | 148 | ||||
| 148 | - | // Look up the action for this key binding | 149 | + | action, found := h.lookupAction(log, binding, mode, modifiers, keycode) |
| 149 | - | action, found := h.shortcuts.Lookup(binding, mode) | |||
| 150 | - | ||||
| 151 | - | // Fallback: check hardware keycode for Alt+number shortcuts on non-QWERTY layouts | |||
| 152 | - | // This enables Alt+1-9/0 to work on AZERTY, QWERTZ, and other Latin layouts | |||
| 153 | - | // where number keys produce different characters without Shift. | |||
| 154 | - | if !found && mode == ModeNormal && modifiers == ModAlt { | |||
| 155 | - | if keycodeAction, ok := KeycodeToTabAction[keycode]; ok { | |||
| 156 | - | action = keycodeAction | |||
| 157 | - | found = true | |||
| 158 | - | log.Debug(). | |||
| 159 | - | Uint("keycode", keycode). | |||
| 160 | - | Str("action", string(action)). | |||
| 161 | - | Msg("tab switch via hardware keycode fallback (non-QWERTY layout)") | |||
| 162 | - | } | |||
| 163 | - | } | |||
| 164 | 150 | ||||
| 165 | if !found { | 151 | if !found { | ||
| 166 | if mode != ModeNormal { | 152 | if mode != ModeNormal { | ||
| @@ -171,25 +157,7 @@ func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.Modifie | |||||
| 171 | return false | 157 | return false | ||
| 172 | } | 158 | } | ||
| 173 | 159 | ||||
| 174 | - | // Handle special mode actions first | 160 | + | if h.handleModeAction(action) { |
| 175 | - | switch action { | |||
| 176 | - | case ActionEnterTabMode: | |||
| 177 | - | timeout := time.Duration(h.cfg.Workspace.TabMode.TimeoutMilliseconds) * time.Millisecond | |||
| 178 | - | h.modal.EnterTabMode(h.ctx, timeout) | |||
| 179 | - | return true | |||
| 180 | - | ||||
| 181 | - | case ActionEnterPaneMode: | |||
| 182 | - | timeout := time.Duration(h.cfg.Workspace.PaneMode.TimeoutMilliseconds) * time.Millisecond | |||
| 183 | - | h.modal.EnterPaneMode(h.ctx, timeout) | |||
| 184 | - | return true | |||
| 185 | - | ||||
| 186 | - | case ActionEnterSessionMode: | |||
| 187 | - | timeout := time.Duration(h.cfg.Session.SessionMode.TimeoutMilliseconds) * time.Millisecond | |||
| 188 | - | h.modal.EnterSessionMode(h.ctx, timeout) | |||
| 189 | - | return true | |||
| 190 | - | ||||
| 191 | - | case ActionExitMode: | |||
| 192 | - | h.modal.ExitMode(h.ctx) | |||
| 193 | return true | 161 | return true | ||
| 194 | } | 162 | } | ||
| 195 | 163 | ||||
| @@ -208,6 +176,11 @@ func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.Modifie | |||||
| 208 | } | 176 | } | ||
| 209 | } | 177 | } | ||
| 210 | 178 | ||||
| 179 | + | // In resize mode, any resize keypress should extend the timeout. | |||
| 180 | + | if mode == ModeResize && isResizeAction(action) { | |||
| 181 | + | h.modal.ResetTimeout(h.ctx) | |||
| 182 | + | } | |||
| 183 | + | ||||
| 211 | // Auto-exit modal mode after certain actions | 184 | // Auto-exit modal mode after certain actions | ||
| 212 | if mode != ModeNormal && ShouldAutoExitMode(action) { | 185 | if mode != ModeNormal && ShouldAutoExitMode(action) { | ||
| 213 | h.modal.ExitMode(h.ctx) | 186 | h.modal.ExitMode(h.ctx) | ||
| @@ -216,6 +189,80 @@ func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.Modifie | |||||
| 216 | return true // Consumed the key | 189 | return true // Consumed the key | ||
| 217 | } | 190 | } | ||
| 218 | 191 | ||||
| 192 | + | func (h *KeyboardHandler) lookupAction( | |||
| 193 | + | log *zerolog.Logger, | |||
| 194 | + | binding KeyBinding, | |||
| 195 | + | mode Mode, | |||
| 196 | + | modifiers Modifier, | |||
| 197 | + | keycode uint, | |||
| 198 | + | ) (Action, bool) { | |||
| 199 | + | action, found := h.shortcuts.Lookup(binding, mode) | |||
| 200 | + | ||||
| 201 | + | // Fallback: check hardware keycode for Alt+number shortcuts on non-QWERTY layouts. | |||
| 202 | + | if !found && mode == ModeNormal && modifiers == ModAlt { | |||
| 203 | + | if keycodeAction, ok := KeycodeToTabAction[keycode]; ok { | |||
| 204 | + | action = keycodeAction | |||
| 205 | + | found = true | |||
| 206 | + | if log != nil { | |||
| 207 | + | log.Debug(). | |||
| 208 | + | Uint("keycode", keycode). | |||
| 209 | + | Str("action", string(action)). | |||
| 210 | + | Msg("tab switch via hardware keycode fallback (non-QWERTY layout)") | |||
| 211 | + | } | |||
| 212 | + | } | |||
| 213 | + | } | |||
| 214 | + | ||||
| 215 | + | return action, found | |||
| 216 | + | } | |||
| 217 | + | ||||
| 218 | + | func isResizeAction(action Action) bool { | |||
| 219 | + | switch action { | |||
| 220 | + | case ActionResizeIncreaseLeft, | |||
| 221 | + | ActionResizeIncreaseRight, | |||
| 222 | + | ActionResizeIncreaseUp, | |||
| 223 | + | ActionResizeIncreaseDown, | |||
| 224 | + | ActionResizeDecreaseLeft, | |||
| 225 | + | ActionResizeDecreaseRight, | |||
| 226 | + | ActionResizeDecreaseUp, | |||
| 227 | + | ActionResizeDecreaseDown, | |||
| 228 | + | ActionResizeIncrease, | |||
| 229 | + | ActionResizeDecrease: | |||
| 230 | + | return true | |||
| 231 | + | default: | |||
| 232 | + | return false | |||
| 233 | + | } | |||
| 234 | + | } | |||
| 235 | + | ||||
| 236 | + | func (h *KeyboardHandler) handleModeAction(action Action) bool { | |||
| 237 | + | switch action { | |||
| 238 | + | case ActionEnterTabMode: | |||
| 239 | + | timeout := time.Duration(h.cfg.Workspace.TabMode.TimeoutMilliseconds) * time.Millisecond | |||
| 240 | + | h.modal.EnterTabMode(h.ctx, timeout) | |||
| 241 | + | return true | |||
| 242 | + | case ActionEnterPaneMode: | |||
| 243 | + | timeout := time.Duration(h.cfg.Workspace.PaneMode.TimeoutMilliseconds) * time.Millisecond | |||
| 244 | + | h.modal.EnterPaneMode(h.ctx, timeout) | |||
| 245 | + | return true | |||
| 246 | + | case ActionEnterSessionMode: | |||
| 247 | + | timeout := time.Duration(h.cfg.Session.SessionMode.TimeoutMilliseconds) * time.Millisecond | |||
| 248 | + | h.modal.EnterSessionMode(h.ctx, timeout) | |||
| 249 | + | return true | |||
| 250 | + | case ActionEnterResizeMode: | |||
| 251 | + | if h.modal.Mode() == ModeResize { | |||
| 252 | + | h.modal.ExitMode(h.ctx) | |||
| 253 | + | return true | |||
| 254 | + | } | |||
| 255 | + | timeout := time.Duration(h.cfg.Workspace.ResizeMode.TimeoutMilliseconds) * time.Millisecond | |||
| 256 | + | h.modal.EnterResizeMode(h.ctx, timeout) | |||
| 257 | + | return true | |||
| 258 | + | case ActionExitMode: | |||
| 259 | + | h.modal.ExitMode(h.ctx) | |||
| 260 | + | return true | |||
| 261 | + | default: | |||
| 262 | + | return false | |||
| 263 | + | } | |||
| 264 | + | } | |||
| 265 | + | ||||
| 219 | // EnterTabMode programmatically enters tab mode. | 266 | // EnterTabMode programmatically enters tab mode. | ||
| 220 | // Useful for testing or programmatic mode changes. | 267 | // Useful for testing or programmatic mode changes. | ||
| 221 | func (h *KeyboardHandler) EnterTabMode() { | 268 | func (h *KeyboardHandler) EnterTabMode() { | ||
M internal/ui/input/modal.go +39 -0
| @@ -20,6 +20,8 @@ const ( | |||
| 20 | 20 | ModePane | |
| 21 | 21 | // ModeSession is the modal session management mode. | |
| 22 | 22 | ModeSession | |
| 23 | + | // ModeResize is the modal pane resizing mode. | |
| 24 | + | ModeResize | |
| 23 | 25 | ) | |
| 24 | 26 | ||
| 25 | 27 | // String returns a human-readable mode name. | |
| @@ -33,6 +35,8 @@ func (m Mode) String() string { | |||
| 33 | 35 | return "pane" | |
| 34 | 36 | case ModeSession: | |
| 35 | 37 | return "session" | |
| 38 | + | case ModeResize: | |
| 39 | + | return "resize" | |
| 36 | 40 | default: | |
| 37 | 41 | return "unknown" | |
| 38 | 42 | } | |
| @@ -171,6 +175,41 @@ func (m *ModalState) EnterSessionMode(ctx context.Context, timeout time.Duration | |||
| 171 | 175 | } | |
| 172 | 176 | } | |
| 173 | 177 | ||
| 178 | + | // EnterResizeMode switches to resize mode with an optional timeout. | |
| 179 | + | // If timeout is 0, the mode stays until explicitly exited. | |
| 180 | + | func (m *ModalState) EnterResizeMode(ctx context.Context, timeout time.Duration) { | |
| 181 | + | log := logging.FromContext(ctx) | |
| 182 | + | m.mu.Lock() | |
| 183 | + | defer m.mu.Unlock() | |
| 184 | + | ||
| 185 | + | if m.mode == ModeResize { | |
| 186 | + | // Already in resize mode, just reset timeout | |
| 187 | + | m.resetTimeoutLocked(ctx, timeout) | |
| 188 | + | return | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | m.cancelTimerLocked() | |
| 192 | + | oldMode := m.mode | |
| 193 | + | m.mode = ModeResize | |
| 194 | + | m.timeout = timeout | |
| 195 | + | ||
| 196 | + | if timeout > 0 { | |
| 197 | + | m.timer = time.AfterFunc(timeout, func() { | |
| 198 | + | m.ExitMode(ctx) | |
| 199 | + | }) | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | log.Debug(). | |
| 203 | + | Str("from", oldMode.String()). | |
| 204 | + | Str("to", m.mode.String()). | |
| 205 | + | Dur("timeout", timeout). | |
| 206 | + | Msg("entered resize mode") | |
| 207 | + | ||
| 208 | + | if m.onModeChange != nil { | |
| 209 | + | m.onModeChange(oldMode, m.mode) | |
| 210 | + | } | |
| 211 | + | } | |
| 212 | + | ||
| 174 | 213 | // ExitMode returns to normal mode. | |
| 175 | 214 | func (m *ModalState) ExitMode(ctx context.Context) { | |
| 176 | 215 | log := logging.FromContext(ctx) | |
| @@ -20,6 +20,8 @@ const ( | |||||
| 20 | ModePane | 20 | ModePane | ||
| 21 | // ModeSession is the modal session management mode. | 21 | // ModeSession is the modal session management mode. | ||
| 22 | ModeSession | 22 | ModeSession | ||
| 23 | + | // ModeResize is the modal pane resizing mode. | |||
| 24 | + | ModeResize | |||
| 23 | ) | 25 | ) | ||
| 24 | 26 | ||||
| 25 | // String returns a human-readable mode name. | 27 | // String returns a human-readable mode name. | ||
| @@ -33,6 +35,8 @@ func (m Mode) String() string { | |||||
| 33 | return "pane" | 35 | return "pane" | ||
| 34 | case ModeSession: | 36 | case ModeSession: | ||
| 35 | return "session" | 37 | return "session" | ||
| 38 | + | case ModeResize: | |||
| 39 | + | return "resize" | |||
| 36 | default: | 40 | default: | ||
| 37 | return "unknown" | 41 | return "unknown" | ||
| 38 | } | 42 | } | ||
| @@ -171,6 +175,41 @@ func (m *ModalState) EnterSessionMode(ctx context.Context, timeout time.Duration | |||||
| 171 | } | 175 | } | ||
| 172 | } | 176 | } | ||
| 173 | 177 | ||||
| 178 | + | // EnterResizeMode switches to resize mode with an optional timeout. | |||
| 179 | + | // If timeout is 0, the mode stays until explicitly exited. | |||
| 180 | + | func (m *ModalState) EnterResizeMode(ctx context.Context, timeout time.Duration) { | |||
| 181 | + | log := logging.FromContext(ctx) | |||
| 182 | + | m.mu.Lock() | |||
| 183 | + | defer m.mu.Unlock() | |||
| 184 | + | ||||
| 185 | + | if m.mode == ModeResize { | |||
| 186 | + | // Already in resize mode, just reset timeout | |||
| 187 | + | m.resetTimeoutLocked(ctx, timeout) | |||
| 188 | + | return | |||
| 189 | + | } | |||
| 190 | + | ||||
| 191 | + | m.cancelTimerLocked() | |||
| 192 | + | oldMode := m.mode | |||
| 193 | + | m.mode = ModeResize | |||
| 194 | + | m.timeout = timeout | |||
| 195 | + | ||||
| 196 | + | if timeout > 0 { | |||
| 197 | + | m.timer = time.AfterFunc(timeout, func() { | |||
| 198 | + | m.ExitMode(ctx) | |||
| 199 | + | }) | |||
| 200 | + | } | |||
| 201 | + | ||||
| 202 | + | log.Debug(). | |||
| 203 | + | Str("from", oldMode.String()). | |||
| 204 | + | Str("to", m.mode.String()). | |||
| 205 | + | Dur("timeout", timeout). | |||
| 206 | + | Msg("entered resize mode") | |||
| 207 | + | ||||
| 208 | + | if m.onModeChange != nil { | |||
| 209 | + | m.onModeChange(oldMode, m.mode) | |||
| 210 | + | } | |||
| 211 | + | } | |||
| 212 | + | ||||
| 174 | // ExitMode returns to normal mode. | 213 | // ExitMode returns to normal mode. | ||
| 175 | func (m *ModalState) ExitMode(ctx context.Context) { | 214 | func (m *ModalState) ExitMode(ctx context.Context) { | ||
| 176 | log := logging.FromContext(ctx) | 215 | log := logging.FromContext(ctx) | ||
M internal/ui/input/shortcuts.go +74 -45
| @@ -96,6 +96,7 @@ const ( | |||
| 96 | 96 | ActionEnterTabMode Action = "enter_tab_mode" | |
| 97 | 97 | ActionEnterPaneMode Action = "enter_pane_mode" | |
| 98 | 98 | ActionEnterSessionMode Action = "enter_session_mode" | |
| 99 | + | ActionEnterResizeMode Action = "enter_resize_mode" | |
| 99 | 100 | ActionExitMode Action = "exit_mode" | |
| 100 | 101 | ||
| 101 | 102 | // Tab actions (global and modal) | |
| @@ -130,6 +131,18 @@ const ( | |||
| 130 | 131 | ActionFocusUp Action = "focus_up" | |
| 131 | 132 | ActionFocusDown Action = "focus_down" | |
| 132 | 133 | ||
| 134 | + | // Resize actions (modal) | |
| 135 | + | ActionResizeIncreaseLeft Action = "resize_increase_left" | |
| 136 | + | ActionResizeIncreaseRight Action = "resize_increase_right" | |
| 137 | + | ActionResizeIncreaseUp Action = "resize_increase_up" | |
| 138 | + | ActionResizeIncreaseDown Action = "resize_increase_down" | |
| 139 | + | ActionResizeDecreaseLeft Action = "resize_decrease_left" | |
| 140 | + | ActionResizeDecreaseRight Action = "resize_decrease_right" | |
| 141 | + | ActionResizeDecreaseUp Action = "resize_decrease_up" | |
| 142 | + | ActionResizeDecreaseDown Action = "resize_decrease_down" | |
| 143 | + | ActionResizeIncrease Action = "resize_increase" | |
| 144 | + | ActionResizeDecrease Action = "resize_decrease" | |
| 145 | + | ||
| 133 | 146 | // Stack navigation (within stacked panes) | |
| 134 | 147 | ActionStackNavUp Action = "stack_nav_up" | |
| 135 | 148 | ActionStackNavDown Action = "stack_nav_down" | |
| @@ -178,6 +191,8 @@ type ShortcutSet struct { | |||
| 178 | 191 | PaneMode ShortcutTable | |
| 179 | 192 | // SessionMode shortcuts are only active in session mode. | |
| 180 | 193 | SessionMode ShortcutTable | |
| 194 | + | // ResizeMode shortcuts are only active in resize mode. | |
| 195 | + | ResizeMode ShortcutTable | |
| 181 | 196 | } | |
| 182 | 197 | ||
| 183 | 198 | // NewShortcutSet creates a ShortcutSet from the configuration. | |
| @@ -188,17 +203,20 @@ func NewShortcutSet(ctx context.Context, cfg *config.Config) *ShortcutSet { | |||
| 188 | 203 | TabMode: make(ShortcutTable), | |
| 189 | 204 | PaneMode: make(ShortcutTable), | |
| 190 | 205 | SessionMode: make(ShortcutTable), | |
| 206 | + | ResizeMode: make(ShortcutTable), | |
| 191 | 207 | } | |
| 192 | 208 | ||
| 193 | 209 | set.buildGlobalShortcuts(ctx, cfg) | |
| 194 | 210 | set.buildTabModeShortcuts(ctx, &cfg.Workspace) | |
| 195 | 211 | set.buildPaneModeShortcuts(ctx, &cfg.Workspace) | |
| 212 | + | set.buildResizeModeShortcuts(ctx, &cfg.Workspace) | |
| 196 | 213 | set.buildSessionModeShortcuts(ctx, &cfg.Session) | |
| 197 | 214 | ||
| 198 | 215 | log.Debug(). | |
| 199 | 216 | Int("global", len(set.Global)). | |
| 200 | 217 | Int("tab", len(set.TabMode)). | |
| 201 | 218 | Int("pane", len(set.PaneMode)). | |
| 219 | + | Int("resize", len(set.ResizeMode)). | |
| 202 | 220 | Int("session", len(set.SessionMode)). | |
| 203 | 221 | Msg("shortcuts registered") | |
| 204 | 222 | ||
| @@ -229,6 +247,11 @@ func (s *ShortcutSet) buildSessionModeShortcuts(ctx context.Context, cfg *config | |||
| 229 | 247 | s.buildModeShortcuts(ctx, cfg.SessionMode.GetKeyBindings(), s.SessionMode, "session") | |
| 230 | 248 | } | |
| 231 | 249 | ||
| 250 | + | // buildResizeModeShortcuts populates resize mode shortcuts from config. | |
| 251 | + | func (s *ShortcutSet) buildResizeModeShortcuts(ctx context.Context, cfg *config.WorkspaceConfig) { | |
| 252 | + | s.buildModeShortcuts(ctx, cfg.ResizeMode.GetKeyBindings(), s.ResizeMode, "resize") | |
| 253 | + | } | |
| 254 | + | ||
| 232 | 255 | func (s *ShortcutSet) registerActivationShortcuts(ctx context.Context, cfg *config.Config) { | |
| 233 | 256 | log := logging.FromContext(ctx) | |
| 234 | 257 | if binding, ok := ParseKeyString(cfg.Workspace.TabMode.ActivationShortcut); ok { | |
| @@ -261,6 +284,16 @@ func (s *ShortcutSet) registerActivationShortcuts(ctx context.Context, cfg *conf | |||
| 261 | 284 | } else { | |
| 262 | 285 | log.Warn().Str("shortcut", cfg.Session.SessionMode.ActivationShortcut).Msg("failed to parse session mode activation shortcut") | |
| 263 | 286 | } | |
| 287 | + | if binding, ok := ParseKeyString(cfg.Workspace.ResizeMode.ActivationShortcut); ok { | |
| 288 | + | s.Global[binding] = ActionEnterResizeMode | |
| 289 | + | log.Trace(). | |
| 290 | + | Str("shortcut", cfg.Workspace.ResizeMode.ActivationShortcut). | |
| 291 | + | Uint("keyval", binding.Keyval). | |
| 292 | + | Uint("mod", uint(binding.Modifiers)). | |
| 293 | + | Msg("resize mode activation registered") | |
| 294 | + | } else { | |
| 295 | + | log.Warn().Str("shortcut", cfg.Workspace.ResizeMode.ActivationShortcut).Msg("failed to parse resize mode activation shortcut") | |
| 296 | + | } | |
| 264 | 297 | } | |
| 265 | 298 | ||
| 266 | 299 | func (s *ShortcutSet) registerConfiguredShortcuts(cfg *config.WorkspaceConfig) { | |
| @@ -360,62 +393,56 @@ func (s *ShortcutSet) buildModeShortcuts(ctx context.Context, bindings map[strin | |||
| 360 | 393 | Msg(mode + " mode shortcuts built") | |
| 361 | 394 | } | |
| 362 | 395 | ||
| 363 | - | // mapConfigAction maps config action names to Action constants. | |
| 364 | - | func mapConfigAction(configAction string) Action { | |
| 365 | - | switch configAction { | |
| 366 | - | // Mode management | |
| 367 | - | case "cancel", "confirm": | |
| 368 | - | return ActionExitMode | |
| 369 | - | ||
| 396 | + | var configActionToAction = map[string]Action{ | |
| 370 | 397 | // Tab actions | |
| 371 | - | case "new-tab": | |
| 372 | - | return ActionNewTab | |
| 373 | - | case "close-tab": | |
| 374 | - | return ActionCloseTab | |
| 375 | - | case "next-tab": | |
| 376 | - | return ActionNextTab | |
| 377 | - | case "previous-tab": | |
| 378 | - | return ActionPreviousTab | |
| 379 | - | case "rename-tab": | |
| 380 | - | return ActionRenameTab | |
| 398 | + | "new-tab": ActionNewTab, | |
| 399 | + | "close-tab": ActionCloseTab, | |
| 400 | + | "next-tab": ActionNextTab, | |
| 401 | + | "previous-tab": ActionPreviousTab, | |
| 402 | + | "rename-tab": ActionRenameTab, | |
| 381 | 403 | ||
| 382 | 404 | // Pane actions | |
| 383 | - | case "split-right": | |
| 384 | - | return ActionSplitRight | |
| 385 | - | case "split-left": | |
| 386 | - | return ActionSplitLeft | |
| 387 | - | case "split-up": | |
| 388 | - | return ActionSplitUp | |
| 389 | - | case "split-down": | |
| 390 | - | return ActionSplitDown | |
| 391 | - | case "close-pane": | |
| 392 | - | return ActionClosePane | |
| 393 | - | case "stack-pane": | |
| 394 | - | return ActionStackPane | |
| 405 | + | "split-right": ActionSplitRight, | |
| 406 | + | "split-left": ActionSplitLeft, | |
| 407 | + | "split-up": ActionSplitUp, | |
| 408 | + | "split-down": ActionSplitDown, | |
| 409 | + | "close-pane": ActionClosePane, | |
| 410 | + | "stack-pane": ActionStackPane, | |
| 395 | 411 | ||
| 396 | 412 | // Focus navigation | |
| 397 | - | case "focus-right": | |
| 398 | - | return ActionFocusRight | |
| 399 | - | case "focus-left": | |
| 400 | - | return ActionFocusLeft | |
| 401 | - | case "focus-up": | |
| 402 | - | return ActionFocusUp | |
| 403 | - | case "focus-down": | |
| 404 | - | return ActionFocusDown | |
| 413 | + | "focus-right": ActionFocusRight, | |
| 414 | + | "focus-left": ActionFocusLeft, | |
| 415 | + | "focus-up": ActionFocusUp, | |
| 416 | + | "focus-down": ActionFocusDown, | |
| 405 | 417 | ||
| 406 | 418 | // Stack navigation | |
| 407 | - | case "stack-nav-up", "stack-up": | |
| 408 | - | return ActionStackNavUp | |
| 409 | - | case "stack-nav-down", "stack-down": | |
| 410 | - | return ActionStackNavDown | |
| 419 | + | "stack-nav-up": ActionStackNavUp, | |
| 420 | + | "stack-up": ActionStackNavUp, | |
| 421 | + | "stack-nav-down": ActionStackNavDown, | |
| 422 | + | "stack-down": ActionStackNavDown, | |
| 423 | + | ||
| 424 | + | // Resize actions | |
| 425 | + | "resize-increase-left": ActionResizeIncreaseLeft, | |
| 426 | + | "resize-increase-right": ActionResizeIncreaseRight, | |
| 427 | + | "resize-increase-up": ActionResizeIncreaseUp, | |
| 428 | + | "resize-increase-down": ActionResizeIncreaseDown, | |
| 429 | + | "resize-decrease-left": ActionResizeDecreaseLeft, | |
| 430 | + | "resize-decrease-right": ActionResizeDecreaseRight, | |
| 431 | + | "resize-decrease-up": ActionResizeDecreaseUp, | |
| 432 | + | "resize-decrease-down": ActionResizeDecreaseDown, | |
| 433 | + | "resize-increase": ActionResizeIncrease, | |
| 434 | + | "resize-decrease": ActionResizeDecrease, | |
| 411 | 435 | ||
| 412 | 436 | // Session actions | |
| 413 | - | case "session-manager": | |
| 414 | - | return ActionOpenSessionManager | |
| 437 | + | "session-manager": ActionOpenSessionManager, | |
| 438 | + | } | |
| 415 | 439 | ||
| 416 | - | default: | |
| 417 | - | return "" | |
| 440 | + | // mapConfigAction maps config action names to Action constants. | |
| 441 | + | func mapConfigAction(configAction string) Action { | |
| 442 | + | if configAction == "cancel" || configAction == "confirm" { | |
| 443 | + | return ActionExitMode | |
| 418 | 444 | } | |
| 445 | + | return configActionToAction[configAction] | |
| 419 | 446 | } | |
| 420 | 447 | ||
| 421 | 448 | // ParseKeyString converts a config key string like "ctrl+t" to a KeyBinding. | |
| @@ -488,6 +515,8 @@ func (s *ShortcutSet) Lookup(binding KeyBinding, mode Mode) (Action, bool) { | |||
| 488 | 515 | modeTable = s.TabMode | |
| 489 | 516 | case ModePane: | |
| 490 | 517 | modeTable = s.PaneMode | |
| 518 | + | case ModeResize: | |
| 519 | + | modeTable = s.ResizeMode | |
| 491 | 520 | case ModeSession: | |
| 492 | 521 | modeTable = s.SessionMode | |
| 493 | 522 | } | |
| @@ -96,6 +96,7 @@ const ( | |||||
| 96 | ActionEnterTabMode Action = "enter_tab_mode" | 96 | ActionEnterTabMode Action = "enter_tab_mode" | ||
| 97 | ActionEnterPaneMode Action = "enter_pane_mode" | 97 | ActionEnterPaneMode Action = "enter_pane_mode" | ||
| 98 | ActionEnterSessionMode Action = "enter_session_mode" | 98 | ActionEnterSessionMode Action = "enter_session_mode" | ||
| 99 | + | ActionEnterResizeMode Action = "enter_resize_mode" | |||
| 99 | ActionExitMode Action = "exit_mode" | 100 | ActionExitMode Action = "exit_mode" | ||
| 100 | 101 | ||||
| 101 | // Tab actions (global and modal) | 102 | // Tab actions (global and modal) | ||
| @@ -130,6 +131,18 @@ const ( | |||||
| 130 | ActionFocusUp Action = "focus_up" | 131 | ActionFocusUp Action = "focus_up" | ||
| 131 | ActionFocusDown Action = "focus_down" | 132 | ActionFocusDown Action = "focus_down" | ||
| 132 | 133 | ||||
| 134 | + | // Resize actions (modal) | |||
| 135 | + | ActionResizeIncreaseLeft Action = "resize_increase_left" | |||
| 136 | + | ActionResizeIncreaseRight Action = "resize_increase_right" | |||
| 137 | + | ActionResizeIncreaseUp Action = "resize_increase_up" | |||
| 138 | + | ActionResizeIncreaseDown Action = "resize_increase_down" | |||
| 139 | + | ActionResizeDecreaseLeft Action = "resize_decrease_left" | |||
| 140 | + | ActionResizeDecreaseRight Action = "resize_decrease_right" | |||
| 141 | + | ActionResizeDecreaseUp Action = "resize_decrease_up" | |||
| 142 | + | ActionResizeDecreaseDown Action = "resize_decrease_down" | |||
| 143 | + | ActionResizeIncrease Action = "resize_increase" | |||
| 144 | + | ActionResizeDecrease Action = "resize_decrease" | |||
| 145 | + | ||||
| 133 | // Stack navigation (within stacked panes) | 146 | // Stack navigation (within stacked panes) | ||
| 134 | ActionStackNavUp Action = "stack_nav_up" | 147 | ActionStackNavUp Action = "stack_nav_up" | ||
| 135 | ActionStackNavDown Action = "stack_nav_down" | 148 | ActionStackNavDown Action = "stack_nav_down" | ||
| @@ -178,6 +191,8 @@ type ShortcutSet struct { | |||||
| 178 | PaneMode ShortcutTable | 191 | PaneMode ShortcutTable | ||
| 179 | // SessionMode shortcuts are only active in session mode. | 192 | // SessionMode shortcuts are only active in session mode. | ||
| 180 | SessionMode ShortcutTable | 193 | SessionMode ShortcutTable | ||
| 194 | + | // ResizeMode shortcuts are only active in resize mode. | |||
| 195 | + | ResizeMode ShortcutTable | |||
| 181 | } | 196 | } | ||
| 182 | 197 | ||||
| 183 | // NewShortcutSet creates a ShortcutSet from the configuration. | 198 | // NewShortcutSet creates a ShortcutSet from the configuration. | ||
| @@ -188,17 +203,20 @@ func NewShortcutSet(ctx context.Context, cfg *config.Config) *ShortcutSet { | |||||
| 188 | TabMode: make(ShortcutTable), | 203 | TabMode: make(ShortcutTable), | ||
| 189 | PaneMode: make(ShortcutTable), | 204 | PaneMode: make(ShortcutTable), | ||
| 190 | SessionMode: make(ShortcutTable), | 205 | SessionMode: make(ShortcutTable), | ||
| 206 | + | ResizeMode: make(ShortcutTable), | |||
| 191 | } | 207 | } | ||
| 192 | 208 | ||||
| 193 | set.buildGlobalShortcuts(ctx, cfg) | 209 | set.buildGlobalShortcuts(ctx, cfg) | ||
| 194 | set.buildTabModeShortcuts(ctx, &cfg.Workspace) | 210 | set.buildTabModeShortcuts(ctx, &cfg.Workspace) | ||
| 195 | set.buildPaneModeShortcuts(ctx, &cfg.Workspace) | 211 | set.buildPaneModeShortcuts(ctx, &cfg.Workspace) | ||
| 212 | + | set.buildResizeModeShortcuts(ctx, &cfg.Workspace) | |||
| 196 | set.buildSessionModeShortcuts(ctx, &cfg.Session) | 213 | set.buildSessionModeShortcuts(ctx, &cfg.Session) | ||
| 197 | 214 | ||||
| 198 | log.Debug(). | 215 | log.Debug(). | ||
| 199 | Int("global", len(set.Global)). | 216 | Int("global", len(set.Global)). | ||
| 200 | Int("tab", len(set.TabMode)). | 217 | Int("tab", len(set.TabMode)). | ||
| 201 | Int("pane", len(set.PaneMode)). | 218 | Int("pane", len(set.PaneMode)). | ||
| 219 | + | Int("resize", len(set.ResizeMode)). | |||
| 202 | Int("session", len(set.SessionMode)). | 220 | Int("session", len(set.SessionMode)). | ||
| 203 | Msg("shortcuts registered") | 221 | Msg("shortcuts registered") | ||
| 204 | 222 | ||||
| @@ -229,6 +247,11 @@ func (s *ShortcutSet) buildSessionModeShortcuts(ctx context.Context, cfg *config | |||||
| 229 | s.buildModeShortcuts(ctx, cfg.SessionMode.GetKeyBindings(), s.SessionMode, "session") | 247 | s.buildModeShortcuts(ctx, cfg.SessionMode.GetKeyBindings(), s.SessionMode, "session") | ||
| 230 | } | 248 | } | ||
| 231 | 249 | ||||
| 250 | + | // buildResizeModeShortcuts populates resize mode shortcuts from config. | |||
| 251 | + | func (s *ShortcutSet) buildResizeModeShortcuts(ctx context.Context, cfg *config.WorkspaceConfig) { | |||
| 252 | + | s.buildModeShortcuts(ctx, cfg.ResizeMode.GetKeyBindings(), s.ResizeMode, "resize") | |||
| 253 | + | } | |||
| 254 | + | ||||
| 232 | func (s *ShortcutSet) registerActivationShortcuts(ctx context.Context, cfg *config.Config) { | 255 | func (s *ShortcutSet) registerActivationShortcuts(ctx context.Context, cfg *config.Config) { | ||
| 233 | log := logging.FromContext(ctx) | 256 | log := logging.FromContext(ctx) | ||
| 234 | if binding, ok := ParseKeyString(cfg.Workspace.TabMode.ActivationShortcut); ok { | 257 | if binding, ok := ParseKeyString(cfg.Workspace.TabMode.ActivationShortcut); ok { | ||
| @@ -261,6 +284,16 @@ func (s *ShortcutSet) registerActivationShortcuts(ctx context.Context, cfg *conf | |||||
| 261 | } else { | 284 | } else { | ||
| 262 | log.Warn().Str("shortcut", cfg.Session.SessionMode.ActivationShortcut).Msg("failed to parse session mode activation shortcut") | 285 | log.Warn().Str("shortcut", cfg.Session.SessionMode.ActivationShortcut).Msg("failed to parse session mode activation shortcut") | ||
| 263 | } | 286 | } | ||
| 287 | + | if binding, ok := ParseKeyString(cfg.Workspace.ResizeMode.ActivationShortcut); ok { | |||
| 288 | + | s.Global[binding] = ActionEnterResizeMode | |||
| 289 | + | log.Trace(). | |||
| 290 | + | Str("shortcut", cfg.Workspace.ResizeMode.ActivationShortcut). | |||
| 291 | + | Uint("keyval", binding.Keyval). | |||
| 292 | + | Uint("mod", uint(binding.Modifiers)). | |||
| 293 | + | Msg("resize mode activation registered") | |||
| 294 | + | } else { | |||
| 295 | + | log.Warn().Str("shortcut", cfg.Workspace.ResizeMode.ActivationShortcut).Msg("failed to parse resize mode activation shortcut") | |||
| 296 | + | } | |||
| 264 | } | 297 | } | ||
| 265 | 298 | ||||
| 266 | func (s *ShortcutSet) registerConfiguredShortcuts(cfg *config.WorkspaceConfig) { | 299 | func (s *ShortcutSet) registerConfiguredShortcuts(cfg *config.WorkspaceConfig) { | ||
| @@ -360,62 +393,56 @@ func (s *ShortcutSet) buildModeShortcuts(ctx context.Context, bindings map[strin | |||||
| 360 | Msg(mode + " mode shortcuts built") | 393 | Msg(mode + " mode shortcuts built") | ||
| 361 | } | 394 | } | ||
| 362 | 395 | ||||
| 363 | - | // mapConfigAction maps config action names to Action constants. | 396 | + | var configActionToAction = map[string]Action{ |
| 364 | - | func mapConfigAction(configAction string) Action { | |||
| 365 | - | switch configAction { | |||
| 366 | - | // Mode management | |||
| 367 | - | case "cancel", "confirm": | |||
| 368 | - | return ActionExitMode | |||
| 369 | - | ||||
| 370 | // Tab actions | 397 | // Tab actions | ||
| 371 | - | case "new-tab": | 398 | + | "new-tab": ActionNewTab, |
| 372 | - | return ActionNewTab | 399 | + | "close-tab": ActionCloseTab, |
| 373 | - | case "close-tab": | 400 | + | "next-tab": ActionNextTab, |
| 374 | - | return ActionCloseTab | 401 | + | "previous-tab": ActionPreviousTab, |
| 375 | - | case "next-tab": | 402 | + | "rename-tab": ActionRenameTab, |
| 376 | - | return ActionNextTab | |||
| 377 | - | case "previous-tab": | |||
| 378 | - | return ActionPreviousTab | |||
| 379 | - | case "rename-tab": | |||
| 380 | - | return ActionRenameTab | |||
| 381 | 403 | ||||
| 382 | // Pane actions | 404 | // Pane actions | ||
| 383 | - | case "split-right": | 405 | + | "split-right": ActionSplitRight, |
| 384 | - | return ActionSplitRight | 406 | + | "split-left": ActionSplitLeft, |
| 385 | - | case "split-left": | 407 | + | "split-up": ActionSplitUp, |
| 386 | - | return ActionSplitLeft | 408 | + | "split-down": ActionSplitDown, |
| 387 | - | case "split-up": | 409 | + | "close-pane": ActionClosePane, |
| 388 | - | return ActionSplitUp | 410 | + | "stack-pane": ActionStackPane, |
| 389 | - | case "split-down": | |||
| 390 | - | return ActionSplitDown | |||
| 391 | - | case "close-pane": | |||
| 392 | - | return ActionClosePane | |||
| 393 | - | case "stack-pane": | |||
| 394 | - | return ActionStackPane | |||
| 395 | 411 | ||||
| 396 | // Focus navigation | 412 | // Focus navigation | ||
| 397 | - | case "focus-right": | 413 | + | "focus-right": ActionFocusRight, |
| 398 | - | return ActionFocusRight | 414 | + | "focus-left": ActionFocusLeft, |
| 399 | - | case "focus-left": | 415 | + | "focus-up": ActionFocusUp, |
| 400 | - | return ActionFocusLeft | 416 | + | "focus-down": ActionFocusDown, |
| 401 | - | case "focus-up": | |||
| 402 | - | return ActionFocusUp | |||
| 403 | - | case "focus-down": | |||
| 404 | - | return ActionFocusDown | |||
| 405 | 417 | ||||
| 406 | // Stack navigation | 418 | // Stack navigation | ||
| 407 | - | case "stack-nav-up", "stack-up": | 419 | + | "stack-nav-up": ActionStackNavUp, |
| 408 | - | return ActionStackNavUp | 420 | + | "stack-up": ActionStackNavUp, |
| 409 | - | case "stack-nav-down", "stack-down": | 421 | + | "stack-nav-down": ActionStackNavDown, |
| 410 | - | return ActionStackNavDown | 422 | + | "stack-down": ActionStackNavDown, |
| 423 | + | ||||
| 424 | + | // Resize actions | |||
| 425 | + | "resize-increase-left": ActionResizeIncreaseLeft, | |||
| 426 | + | "resize-increase-right": ActionResizeIncreaseRight, | |||
| 427 | + | "resize-increase-up": ActionResizeIncreaseUp, | |||
| 428 | + | "resize-increase-down": ActionResizeIncreaseDown, | |||
| 429 | + | "resize-decrease-left": ActionResizeDecreaseLeft, | |||
| 430 | + | "resize-decrease-right": ActionResizeDecreaseRight, | |||
| 431 | + | "resize-decrease-up": ActionResizeDecreaseUp, | |||
| 432 | + | "resize-decrease-down": ActionResizeDecreaseDown, | |||
| 433 | + | "resize-increase": ActionResizeIncrease, | |||
| 434 | + | "resize-decrease": ActionResizeDecrease, | |||
| 411 | 435 | ||||
| 412 | // Session actions | 436 | // Session actions | ||
| 413 | - | case "session-manager": | 437 | + | "session-manager": ActionOpenSessionManager, |
| 414 | - | return ActionOpenSessionManager | 438 | + | } |
| 415 | 439 | ||||
| 416 | - | default: | 440 | + | // mapConfigAction maps config action names to Action constants. |
| 417 | - | return "" | 441 | + | func mapConfigAction(configAction string) Action { |
| 442 | + | if configAction == "cancel" || configAction == "confirm" { | |||
| 443 | + | return ActionExitMode | |||
| 418 | } | 444 | } | ||
| 445 | + | return configActionToAction[configAction] | |||
| 419 | } | 446 | } | ||
| 420 | 447 | ||||
| 421 | // ParseKeyString converts a config key string like "ctrl+t" to a KeyBinding. | 448 | // ParseKeyString converts a config key string like "ctrl+t" to a KeyBinding. | ||
| @@ -488,6 +515,8 @@ func (s *ShortcutSet) Lookup(binding KeyBinding, mode Mode) (Action, bool) { | |||||
| 488 | modeTable = s.TabMode | 515 | modeTable = s.TabMode | ||
| 489 | case ModePane: | 516 | case ModePane: | ||
| 490 | modeTable = s.PaneMode | 517 | modeTable = s.PaneMode | ||
| 518 | + | case ModeResize: | |||
| 519 | + | modeTable = s.ResizeMode | |||
| 491 | case ModeSession: | 520 | case ModeSession: | ||
| 492 | modeTable = s.SessionMode | 521 | modeTable = s.SessionMode | ||
| 493 | } | 522 | } | ||
M internal/ui/layout/gtk_widgets.go +8 -0
| @@ -2,6 +2,7 @@ package layout | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "github.com/jwijenbergh/puregotk/v4/gdk" | |
| 5 | + | "github.com/jwijenbergh/puregotk/v4/gobject" | |
| 5 | 6 | "github.com/jwijenbergh/puregotk/v4/graphene" | |
| 6 | 7 | "github.com/jwijenbergh/puregotk/v4/gtk" | |
| 7 | 8 | "github.com/jwijenbergh/puregotk/v4/pango" | |
| @@ -162,6 +163,13 @@ func (p *gtkPaned) ConnectMap(callback func()) uint32 { | |||
| 162 | 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 | 173 | func (p *gtkPaned) AddTickCallback(callback func() bool) uint { | |
| 166 | 174 | cb := gtk.TickCallback(func(widget uintptr, frameClock uintptr, userData uintptr) bool { | |
| 167 | 175 | 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() | |||
| 1495 | 1495 | 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 | 1549 | // GetAllocatedHeight provides a mock function for the type MockPanedWidget | |
| 1499 | 1550 | func (_mock *MockPanedWidget) GetAllocatedHeight() int { | |
| 1500 | 1551 | ret := _mock.Called() | |
| @@ -1495,6 +1495,57 @@ func (_c *MockPanedWidget_ConnectMap_Call) RunAndReturn(run func(callback func() | |||||
| 1495 | return _c | 1495 | 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 MockPanedWidget | 1549 | // 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 | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "sync" | |
| 6 | + | "time" | |
| 6 | 7 | ||
| 7 | 8 | "github.com/bnema/dumber/internal/logging" | |
| 9 | + | "github.com/jwijenbergh/puregotk/v4/glib" | |
| 8 | 10 | "github.com/rs/zerolog" | |
| 9 | 11 | ) | |
| 10 | 12 | ||
| @@ -18,6 +20,13 @@ type SplitView struct { | |||
| 18 | 20 | ratio float64 // 0.0-1.0, relative position of divider | |
| 19 | 21 | 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 | 30 | mu sync.RWMutex | |
| 22 | 31 | } | |
| 23 | 32 | ||
| @@ -25,6 +34,9 @@ type SplitView struct { | |||
| 25 | 34 | // At 60fps, 120 frames = ~2 seconds timeout. | |
| 26 | 35 | const maxRetryFrames = 120 | |
| 27 | 36 | ||
| 37 | + | const notifyPositionDebounceDelay = 100 * time.Millisecond | |
| 38 | + | const notifyPositionSuppressAfterApplyDelay = 50 * time.Millisecond | |
| 39 | + | ||
| 28 | 40 | // NewSplitView creates a new split view with the given orientation and children. | |
| 29 | 41 | // The ratio determines the initial divider position (0.0-1.0). | |
| 30 | 42 | func NewSplitView( | |
| @@ -61,6 +73,60 @@ func NewSplitView( | |||
| 61 | 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 | 130 | // Try to apply ratio immediately (in case widget is already allocated) | |
| 65 | 131 | if sv.ApplyRatio() { | |
| 66 | 132 | return sv | |
| @@ -114,6 +180,13 @@ func (sv *SplitView) GetRatio() float64 { | |||
| 114 | 180 | 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 | 190 | // ApplyRatio converts the ratio to a pixel position and applies it. | |
| 118 | 191 | // This should be called after the widget has been mapped and has an allocated size. | |
| 119 | 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 | 223 | Float64("ratio", ratio). | |
| 151 | 224 | Int("position", position). | |
| 152 | 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 | 233 | sv.paned.SetPosition(position) | |
| 234 | + | ||
| 235 | + | sv.mu.Lock() | |
| 236 | + | sv.hasAppliedRatio = true | |
| 237 | + | sv.mu.Unlock() | |
| 238 | + | ||
| 154 | 239 | return true | |
| 155 | 240 | } | |
| 156 | 241 | ||
| @@ -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 divider | 20 | ratio float64 // 0.0-1.0, relative position of divider | ||
| 19 | logger zerolog.Logger | 21 | 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.RWMutex | 30 | 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 = 120 | 35 | 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 sv | 132 | return sv | ||
| @@ -114,6 +180,13 @@ func (sv *SplitView) GetRatio() float64 { | |||||
| 114 | return sv.ratio | 180 | 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 true | 239 | 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 | |||
| 29 | 29 | mockPaned.EXPECT().GetAllocatedHeight().Return(0).Once() | |
| 30 | 30 | } | |
| 31 | 31 | ||
| 32 | + | mockPaned.EXPECT().ConnectNotifyPosition(mock.Anything).Return(uint32(0)).Once() | |
| 33 | + | ||
| 32 | 34 | // Since allocation is 0, ApplyRatio returns false and callbacks are registered | |
| 33 | 35 | mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once() | |
| 34 | 36 | 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 registered | 34 | // 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 | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "errors" | |
| 6 | + | "fmt" | |
| 6 | 7 | "sync" | |
| 7 | 8 | ||
| 8 | 9 | "github.com/bnema/dumber/internal/domain/entity" | |
| @@ -27,14 +28,17 @@ type PaneViewFactory interface { | |||
| 27 | 28 | // It maps each node to its corresponding GTK widget and maintains | |
| 28 | 29 | // a lookup table for finding widgets by node ID. | |
| 29 | 30 | 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 | |
| 34 | 36 | // paneToStack maps pane IDs to their containing StackedView. | |
| 35 | 37 | // Every leaf pane is wrapped in a StackedView for easy stacking later. | |
| 36 | 38 | paneToStack map[string]*StackedView | |
| 37 | - | mu sync.RWMutex | |
| 39 | + | ||
| 40 | + | onSplitRatioChanged func(nodeID string, ratio float64) | |
| 41 | + | mu sync.RWMutex | |
| 38 | 42 | } | |
| 39 | 43 | ||
| 40 | 44 | // NewTreeRenderer creates a new tree renderer. | |
| @@ -43,14 +47,22 @@ func NewTreeRenderer(ctx context.Context, factory WidgetFactory, paneViewFactory | |||
| 43 | 47 | log.Debug().Msg("creating tree renderer") | |
| 44 | 48 | ||
| 45 | 49 | 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), | |
| 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 | 66 | // Build constructs the entire widget tree from a root PaneNode. | |
| 55 | 67 | // Returns the root widget that can be embedded in a container. | |
| 56 | 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 | 76 | // Clear previous mappings | |
| 65 | 77 | tr.nodeToWidget = make(map[string]Widget) | |
| 78 | + | tr.splitOrientation = make(map[string]Orientation) | |
| 66 | 79 | tr.paneToStack = make(map[string]*StackedView) | |
| 67 | 80 | ||
| 68 | 81 | return tr.renderNode(ctx, root) | |
| @@ -166,6 +179,16 @@ func (tr *TreeRenderer) renderSplit(ctx context.Context, node *entity.PaneNode) | |||
| 166 | 179 | ||
| 167 | 180 | // Create split view | |
| 168 | 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 | 193 | return splitView.Widget(), nil | |
| 171 | 194 | } | |
| @@ -247,6 +270,7 @@ func (tr *TreeRenderer) Clear() { | |||
| 247 | 270 | defer tr.mu.Unlock() | |
| 248 | 271 | ||
| 249 | 272 | tr.nodeToWidget = make(map[string]Widget) | |
| 273 | + | tr.splitOrientation = make(map[string]Orientation) | |
| 250 | 274 | } | |
| 251 | 275 | ||
| 252 | 276 | // RegisterWidget adds or updates a node-to-widget mapping. | |
| @@ -258,6 +282,15 @@ func (tr *TreeRenderer) RegisterWidget(nodeID string, widget Widget) { | |||
| 258 | 282 | 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 | 294 | // UnregisterWidget removes a node-to-widget mapping. | |
| 262 | 295 | // Use this when a node is removed or replaced during incremental operations. | |
| 263 | 296 | func (tr *TreeRenderer) UnregisterWidget(nodeID string) { | |
| @@ -265,6 +298,7 @@ func (tr *TreeRenderer) UnregisterWidget(nodeID string) { | |||
| 265 | 298 | defer tr.mu.Unlock() | |
| 266 | 299 | ||
| 267 | 300 | delete(tr.nodeToWidget, nodeID) | |
| 301 | + | delete(tr.splitOrientation, nodeID) | |
| 268 | 302 | } | |
| 269 | 303 | ||
| 270 | 304 | // UpdateSplitRatio updates the ratio of a split view by node ID. | |
| @@ -272,19 +306,33 @@ func (tr *TreeRenderer) UnregisterWidget(nodeID string) { | |||
| 272 | 306 | func (tr *TreeRenderer) UpdateSplitRatio(nodeID string, ratio float64) error { | |
| 273 | 307 | tr.mu.RLock() | |
| 274 | 308 | widget, ok := tr.nodeToWidget[nodeID] | |
| 309 | + | orientation, okOrient := tr.splitOrientation[nodeID] | |
| 275 | 310 | tr.mu.RUnlock() | |
| 276 | 311 | ||
| 277 | 312 | if !ok { | |
| 278 | 313 | return ErrNodeNotFound | |
| 279 | 314 | } | |
| 280 | 315 | ||
| 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 | + | } | |
| 285 | 334 | ||
| 286 | - | // For now, we can at least verify the widget exists | |
| 287 | - | _ = widget | |
| 335 | + | paned.SetPosition(int(float64(totalSize) * ratio)) | |
| 288 | 336 | return nil | |
| 289 | 337 | } | |
| 290 | 338 | ||
| @@ -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 maintains | 28 | // 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 WidgetFactory | 31 | + | factory WidgetFactory |
| 31 | - | paneViewFactory PaneViewFactory | 32 | + | paneViewFactory PaneViewFactory |
| 32 | - | logger zerolog.Logger | 33 | + | logger zerolog.Logger |
| 33 | - | nodeToWidget map[string]Widget | 34 | + | 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]*StackedView | 38 | paneToStack map[string]*StackedView | ||
| 37 | - | mu sync.RWMutex | 39 | + | |
| 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 mappings | 76 | // 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 view | 180 | // 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(), nil | 193 | 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] = widget | 282 | 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 ErrNodeNotFound | 313 | return ErrNodeNotFound | ||
| 279 | } | 314 | } | ||
| 280 | 315 | ||||
| 281 | - | // The widget should be a PanedWidget from a SplitView | 316 | + | paned, ok := widget.(PanedWidget) |
| 282 | - | // We can't directly update the ratio since we only store the Widget interface | 317 | + | if !ok { |
| 283 | - | // The caller would need to track SplitView instances separately or | 318 | + | return fmt.Errorf("widget is not a PanedWidget") |
| 284 | - | // use the underlying PanedWidget's SetPosition method | 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 | + | } | |||
| 285 | 334 | ||||
| 286 | - | // For now, we can at least verify the widget exists | 335 | + | paned.SetPosition(int(float64(totalSize) * ratio)) |
| 287 | - | _ = widget | |||
| 288 | return nil | 336 | 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) { | |||
| 123 | 123 | mockRightStackBox.EXPECT().SetVisible(true).Once() | |
| 124 | 124 | mockPaned.EXPECT().SetStartChild(mockLeftStackBox).Once() | |
| 125 | 125 | mockPaned.EXPECT().SetEndChild(mockRightStackBox).Once() | |
| 126 | + | mockPaned.EXPECT().ConnectNotifyPosition(mock.Anything).Return(uint32(0)).Once() | |
| 126 | 127 | mockPaned.EXPECT().GetAllocatedWidth().Return(0).Once() | |
| 127 | 128 | mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once() | |
| 128 | 129 | mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once() | |
| @@ -172,6 +173,7 @@ func TestBuild_VerticalSplit(t *testing.T) { | |||
| 172 | 173 | mockBottomStackBox.EXPECT().SetVisible(true).Once() | |
| 173 | 174 | mockPaned.EXPECT().SetStartChild(mockTopStackBox).Once() | |
| 174 | 175 | mockPaned.EXPECT().SetEndChild(mockBottomStackBox).Once() | |
| 176 | + | mockPaned.EXPECT().ConnectNotifyPosition(mock.Anything).Return(uint32(0)).Once() | |
| 175 | 177 | mockPaned.EXPECT().GetAllocatedHeight().Return(0).Once() | |
| 176 | 178 | mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once() | |
| 177 | 179 | mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once() | |
| @@ -240,6 +242,7 @@ func TestBuild_NestedSplits(t *testing.T) { | |||
| 240 | 242 | mockStackBox2.EXPECT().SetVisible(true).Once() | |
| 241 | 243 | mockInnerPaned.EXPECT().SetStartChild(mockStackBox1).Once() | |
| 242 | 244 | mockInnerPaned.EXPECT().SetEndChild(mockStackBox2).Once() | |
| 245 | + | mockInnerPaned.EXPECT().ConnectNotifyPosition(mock.Anything).Return(uint32(0)).Once() | |
| 243 | 246 | mockInnerPaned.EXPECT().GetAllocatedHeight().Return(0).Once() | |
| 244 | 247 | mockInnerPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once() | |
| 245 | 248 | mockInnerPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once() | |
| @@ -253,6 +256,7 @@ func TestBuild_NestedSplits(t *testing.T) { | |||
| 253 | 256 | mockStackBox3.EXPECT().SetVisible(true).Once() | |
| 254 | 257 | mockOuterPaned.EXPECT().SetStartChild(mockInnerPaned).Once() | |
| 255 | 258 | mockOuterPaned.EXPECT().SetEndChild(mockStackBox3).Once() | |
| 259 | + | mockOuterPaned.EXPECT().ConnectNotifyPosition(mock.Anything).Return(uint32(0)).Once() | |
| 256 | 260 | mockOuterPaned.EXPECT().GetAllocatedWidth().Return(0).Once() | |
| 257 | 261 | mockOuterPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once() | |
| 258 | 262 | mockOuterPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once() | |
| @@ -414,6 +418,7 @@ func TestGetNodeIDs_ReturnsAllIDs(t *testing.T) { | |||
| 414 | 418 | mockRightStackBox.EXPECT().SetVisible(true).Once() | |
| 415 | 419 | mockPaned.EXPECT().SetStartChild(mockLeftStackBox).Once() | |
| 416 | 420 | mockPaned.EXPECT().SetEndChild(mockRightStackBox).Once() | |
| 421 | + | mockPaned.EXPECT().ConnectNotifyPosition(mock.Anything).Return(uint32(0)).Once() | |
| 417 | 422 | mockPaned.EXPECT().GetAllocatedWidth().Return(0).Once() | |
| 418 | 423 | mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once() | |
| 419 | 424 | mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once() | |
| @@ -538,8 +543,10 @@ func TestUpdateSplitRatio_ExistingNode(t *testing.T) { | |||
| 538 | 543 | mockRightStackBox.EXPECT().SetVisible(true).Once() | |
| 539 | 544 | mockPaned.EXPECT().SetStartChild(mockLeftStackBox).Once() | |
| 540 | 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 | 548 | mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once() | |
| 549 | + | ||
| 543 | 550 | mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once() | |
| 544 | 551 | ||
| 545 | 552 | 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 { | |||
| 95 | 95 | ||
| 96 | 96 | // Signals | |
| 97 | 97 | ConnectMap(callback func()) uint32 | |
| 98 | + | ConnectNotifyPosition(callback func()) uint32 | |
| 98 | 99 | ||
| 99 | 100 | // Tick callback for frame-based updates (returns signal ID) | |
| 100 | 101 | // Callback returns true to continue, false to stop | |
| @@ -95,6 +95,7 @@ type PanedWidget interface { | |||||
| 95 | 95 | ||||
| 96 | // Signals | 96 | // Signals | ||
| 97 | ConnectMap(callback func()) uint32 | 97 | 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 stop | 101 | // 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 { | |||
| 475 | 475 | box-shadow: inset 0 0 0 0.25em #FFA500; | |
| 476 | 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 | ||
| @@ -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 | ||||