fix(resize): make smart resize grow active pane
- commit
- 9246f54303ae734e3c5412626b7d51436240dbfa
- author
- brice · · 2025-12-26 07:10 UTC
- parent
- fad762b
- signature
- unsigned
4 files · +93 -21
M CHANGELOG.md +1 -0
| @@ -5,6 +5,7 @@ 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. | |
| 8 | 9 | - **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 | 10 | - **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 | 11 | - **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,7 @@ 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. | |||
| 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. | 9 | - **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. | 10 | - **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. | 11 | - **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 +10 -0
| @@ -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 | |----------|--------|-------| | |
| @@ -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 | |----------|--------|-------| | ||
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 = ["arrowleft", "h"] | |
| 260 | + | resize-increase-down = ["arrowdown", "j"] | |
| 261 | + | resize-increase-up = ["arrowup", "k"] | |
| 262 | + | resize-increase-right = ["arrowright", "l"] | |
| 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 = ["arrowleft", "h"] | |||
| 260 | + | resize-increase-down = ["arrowdown", "j"] | |||
| 261 | + | resize-increase-up = ["arrowup", "k"] | |||
| 262 | + | resize-increase-right = ["arrowright", "l"] | |||
| 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 +48 -21
| @@ -221,19 +221,9 @@ func (uc *ManagePanesUseCase) Resize( | |||
| 221 | 221 | actualDir := dir | |
| 222 | 222 | switch dir { | |
| 223 | 223 | case ResizeIncrease: | |
| 224 | - | actualDir = findFirstAvailableDirection(target, []ResizeDirection{ | |
| 225 | - | ResizeIncreaseRight, | |
| 226 | - | ResizeIncreaseDown, | |
| 227 | - | ResizeIncreaseLeft, | |
| 228 | - | ResizeIncreaseUp, | |
| 229 | - | }) | |
| 224 | + | actualDir = findSmartResizeDirection(target, true) | |
| 230 | 225 | case ResizeDecrease: | |
| 231 | - | actualDir = findFirstAvailableDirection(target, []ResizeDirection{ | |
| 232 | - | ResizeDecreaseLeft, | |
| 233 | - | ResizeDecreaseUp, | |
| 234 | - | ResizeDecreaseRight, | |
| 235 | - | ResizeDecreaseDown, | |
| 236 | - | }) | |
| 226 | + | actualDir = findSmartResizeDirection(target, false) | |
| 237 | 227 | } | |
| 238 | 228 | if actualDir == "" { | |
| 239 | 229 | return ErrNothingToResize | |
| @@ -270,18 +260,55 @@ func (uc *ManagePanesUseCase) Resize( | |||
| 270 | 260 | return nil | |
| 271 | 261 | } | |
| 272 | 262 | ||
| 273 | - | func findFirstAvailableDirection(target *entity.PaneNode, dirs []ResizeDirection) ResizeDirection { | |
| 274 | - | for _, d := range dirs { | |
| 275 | - | axis, ok := axisForResizeDirection(d) | |
| 276 | - | if !ok { | |
| 277 | - | continue | |
| 263 | + | func findSmartResizeDirection(target *entity.PaneNode, growActive bool) ResizeDirection { | |
| 264 | + | splitNode, axis, isStartChild := findNearestSplitForResize(target) | |
| 265 | + | if splitNode == nil { | |
| 266 | + | return "" | |
| 267 | + | } | |
| 268 | + | ||
| 269 | + | // Smart resize means "grow/shrink the active pane". | |
| 270 | + | // SplitRatio is the proportion allocated to the first child (left/top). | |
| 271 | + | // - If active is first child: grow by increasing ratio, shrink by decreasing. | |
| 272 | + | // - If active is second child: grow by decreasing ratio, shrink by increasing. | |
| 273 | + | growMeansIncreaseRatio := isStartChild | |
| 274 | + | if !growActive { | |
| 275 | + | growMeansIncreaseRatio = !growMeansIncreaseRatio | |
| 276 | + | } | |
| 277 | + | ||
| 278 | + | switch axis { | |
| 279 | + | case resizeAxisHorizontal: | |
| 280 | + | if growMeansIncreaseRatio { | |
| 281 | + | return ResizeIncreaseRight | |
| 282 | + | } | |
| 283 | + | return ResizeIncreaseLeft | |
| 284 | + | case resizeAxisVertical: | |
| 285 | + | if growMeansIncreaseRatio { | |
| 286 | + | return ResizeIncreaseDown | |
| 278 | 287 | } | |
| 279 | - | splitNode := findNearestSplitForAxis(target, axis) | |
| 280 | - | if splitNode != nil { | |
| 281 | - | return d | |
| 288 | + | return ResizeIncreaseUp | |
| 289 | + | default: | |
| 290 | + | return "" | |
| 291 | + | } | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | // findNearestSplitForResize returns the nearest split ancestor for the active pane. | |
| 295 | + | // It prefers horizontal splits over vertical when both are available at the same depth. | |
| 296 | + | func findNearestSplitForResize(node *entity.PaneNode) (splitNode *entity.PaneNode, axis resizeAxis, isStartChild bool) { | |
| 297 | + | current := node | |
| 298 | + | for current != nil && current.Parent != nil { | |
| 299 | + | parent := current.Parent | |
| 300 | + | if parent.IsSplit() { | |
| 301 | + | isStartChild = parent.Left() == current | |
| 302 | + | switch parent.SplitDir { | |
| 303 | + | case entity.SplitHorizontal: | |
| 304 | + | return parent, resizeAxisHorizontal, isStartChild | |
| 305 | + | case entity.SplitVertical: | |
| 306 | + | return parent, resizeAxisVertical, isStartChild | |
| 307 | + | } | |
| 282 | 308 | } | |
| 309 | + | current = parent | |
| 283 | 310 | } | |
| 284 | - | return "" | |
| 311 | + | return nil, resizeAxisNone, false | |
| 285 | 312 | } | |
| 286 | 313 | ||
| 287 | 314 | type resizeAxis int | |
| @@ -221,19 +221,9 @@ func (uc *ManagePanesUseCase) Resize( | |||||
| 221 | actualDir := dir | 221 | actualDir := dir | ||
| 222 | switch dir { | 222 | switch dir { | ||
| 223 | case ResizeIncrease: | 223 | case ResizeIncrease: | ||
| 224 | - | actualDir = findFirstAvailableDirection(target, []ResizeDirection{ | 224 | + | actualDir = findSmartResizeDirection(target, true) |
| 225 | - | ResizeIncreaseRight, | |||
| 226 | - | ResizeIncreaseDown, | |||
| 227 | - | ResizeIncreaseLeft, | |||
| 228 | - | ResizeIncreaseUp, | |||
| 229 | - | }) | |||
| 230 | case ResizeDecrease: | 225 | case ResizeDecrease: | ||
| 231 | - | actualDir = findFirstAvailableDirection(target, []ResizeDirection{ | 226 | + | actualDir = findSmartResizeDirection(target, false) |
| 232 | - | ResizeDecreaseLeft, | |||
| 233 | - | ResizeDecreaseUp, | |||
| 234 | - | ResizeDecreaseRight, | |||
| 235 | - | ResizeDecreaseDown, | |||
| 236 | - | }) | |||
| 237 | } | 227 | } | ||
| 238 | if actualDir == "" { | 228 | if actualDir == "" { | ||
| 239 | return ErrNothingToResize | 229 | return ErrNothingToResize | ||
| @@ -270,18 +260,55 @@ func (uc *ManagePanesUseCase) Resize( | |||||
| 270 | return nil | 260 | return nil | ||
| 271 | } | 261 | } | ||
| 272 | 262 | ||||
| 273 | - | func findFirstAvailableDirection(target *entity.PaneNode, dirs []ResizeDirection) ResizeDirection { | 263 | + | func findSmartResizeDirection(target *entity.PaneNode, growActive bool) ResizeDirection { |
| 274 | - | for _, d := range dirs { | 264 | + | splitNode, axis, isStartChild := findNearestSplitForResize(target) |
| 275 | - | axis, ok := axisForResizeDirection(d) | 265 | + | if splitNode == nil { |
| 276 | - | if !ok { | 266 | + | return "" |
| 277 | - | continue | 267 | + | } |
| 268 | + | ||||
| 269 | + | // Smart resize means "grow/shrink the active pane". | |||
| 270 | + | // SplitRatio is the proportion allocated to the first child (left/top). | |||
| 271 | + | // - If active is first child: grow by increasing ratio, shrink by decreasing. | |||
| 272 | + | // - If active is second child: grow by decreasing ratio, shrink by increasing. | |||
| 273 | + | growMeansIncreaseRatio := isStartChild | |||
| 274 | + | if !growActive { | |||
| 275 | + | growMeansIncreaseRatio = !growMeansIncreaseRatio | |||
| 276 | + | } | |||
| 277 | + | ||||
| 278 | + | switch axis { | |||
| 279 | + | case resizeAxisHorizontal: | |||
| 280 | + | if growMeansIncreaseRatio { | |||
| 281 | + | return ResizeIncreaseRight | |||
| 282 | + | } | |||
| 283 | + | return ResizeIncreaseLeft | |||
| 284 | + | case resizeAxisVertical: | |||
| 285 | + | if growMeansIncreaseRatio { | |||
| 286 | + | return ResizeIncreaseDown | |||
| 278 | } | 287 | } | ||
| 279 | - | splitNode := findNearestSplitForAxis(target, axis) | 288 | + | return ResizeIncreaseUp |
| 280 | - | if splitNode != nil { | 289 | + | default: |
| 281 | - | return d | 290 | + | return "" |
| 291 | + | } | |||
| 292 | + | } | |||
| 293 | + | ||||
| 294 | + | // findNearestSplitForResize returns the nearest split ancestor for the active pane. | |||
| 295 | + | // It prefers horizontal splits over vertical when both are available at the same depth. | |||
| 296 | + | func findNearestSplitForResize(node *entity.PaneNode) (splitNode *entity.PaneNode, axis resizeAxis, isStartChild bool) { | |||
| 297 | + | current := node | |||
| 298 | + | for current != nil && current.Parent != nil { | |||
| 299 | + | parent := current.Parent | |||
| 300 | + | if parent.IsSplit() { | |||
| 301 | + | isStartChild = parent.Left() == current | |||
| 302 | + | switch parent.SplitDir { | |||
| 303 | + | case entity.SplitHorizontal: | |||
| 304 | + | return parent, resizeAxisHorizontal, isStartChild | |||
| 305 | + | case entity.SplitVertical: | |||
| 306 | + | return parent, resizeAxisVertical, isStartChild | |||
| 307 | + | } | |||
| 282 | } | 308 | } | ||
| 309 | + | current = parent | |||
| 283 | } | 310 | } | ||
| 284 | - | return "" | 311 | + | return nil, resizeAxisNone, false |
| 285 | } | 312 | } | ||
| 286 | 313 | ||||
| 287 | type resizeAxis int | 314 | type resizeAxis int | ||