feat(usecase): implement pane resizing logic
- commit
- 35136f8eb60acdd928653f07b2bf877caf14ab53
- author
- brice · · 2025-12-26 07:03 UTC
- parent
- 82a17d2
- signature
- unsigned
3 files · +231 -16
M internal/application/usecase/manage_panes.go +185 -0
| @@ -2,6 +2,7 @@ package usecase | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "errors" | |
| 5 | 6 | "fmt" | |
| 6 | 7 | "sort" | |
| 7 | 8 | ||
| @@ -29,6 +30,26 @@ const ( | |||
| 29 | 30 | NavDown NavigateDirection = "down" | |
| 30 | 31 | ) | |
| 31 | 32 | ||
| 33 | + | // ResizeDirection indicates the direction for pane resizing. | |
| 34 | + | type ResizeDirection string | |
| 35 | + | ||
| 36 | + | const ( | |
| 37 | + | ResizeIncreaseLeft ResizeDirection = "increase_left" | |
| 38 | + | ResizeIncreaseRight ResizeDirection = "increase_right" | |
| 39 | + | ResizeIncreaseUp ResizeDirection = "increase_up" | |
| 40 | + | ResizeIncreaseDown ResizeDirection = "increase_down" | |
| 41 | + | ||
| 42 | + | ResizeDecreaseLeft ResizeDirection = "decrease_left" | |
| 43 | + | ResizeDecreaseRight ResizeDirection = "decrease_right" | |
| 44 | + | ResizeDecreaseUp ResizeDirection = "decrease_up" | |
| 45 | + | ResizeDecreaseDown ResizeDirection = "decrease_down" | |
| 46 | + | ||
| 47 | + | ResizeIncrease ResizeDirection = "increase" | |
| 48 | + | ResizeDecrease ResizeDirection = "decrease" | |
| 49 | + | ) | |
| 50 | + | ||
| 51 | + | var ErrNothingToResize = errors.New("nothing to resize") | |
| 52 | + | ||
| 32 | 53 | // ManagePanesUseCase handles pane tree operations. | |
| 33 | 54 | type ManagePanesUseCase struct { | |
| 34 | 55 | idGenerator IDGenerator | |
| @@ -166,6 +187,170 @@ func (uc *ManagePanesUseCase) Split(ctx context.Context, input SplitPaneInput) ( | |||
| 166 | 187 | }, nil | |
| 167 | 188 | } | |
| 168 | 189 | ||
| 190 | + | // Resize adjusts the nearest applicable split ratio for the given direction. | |
| 191 | + | // stepPercent is applied per keystroke (e.g. 5.0 means 5%). | |
| 192 | + | // minPanePercent enforces a minimum size for each side of a split. | |
| 193 | + | func (uc *ManagePanesUseCase) Resize( | |
| 194 | + | ctx context.Context, | |
| 195 | + | ws *entity.Workspace, | |
| 196 | + | paneNode *entity.PaneNode, | |
| 197 | + | dir ResizeDirection, | |
| 198 | + | stepPercent float64, | |
| 199 | + | minPanePercent float64, | |
| 200 | + | ) error { | |
| 201 | + | log := logging.FromContext(ctx) | |
| 202 | + | if uc == nil { | |
| 203 | + | return fmt.Errorf("manage panes use case is nil") | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | if ws == nil { | |
| 207 | + | return fmt.Errorf("workspace is required") | |
| 208 | + | } | |
| 209 | + | if ws.Root == nil { | |
| 210 | + | return ErrNothingToResize | |
| 211 | + | } | |
| 212 | + | if paneNode == nil { | |
| 213 | + | return fmt.Errorf("pane node is required") | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | target := paneNode | |
| 217 | + | if target.Parent != nil && target.Parent.IsStacked { | |
| 218 | + | target = target.Parent | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | actualDir := dir | |
| 222 | + | switch dir { | |
| 223 | + | case ResizeIncrease: | |
| 224 | + | actualDir = findFirstAvailableDirection(target, []ResizeDirection{ | |
| 225 | + | ResizeIncreaseRight, | |
| 226 | + | ResizeIncreaseDown, | |
| 227 | + | ResizeIncreaseLeft, | |
| 228 | + | ResizeIncreaseUp, | |
| 229 | + | }) | |
| 230 | + | case ResizeDecrease: | |
| 231 | + | actualDir = findFirstAvailableDirection(target, []ResizeDirection{ | |
| 232 | + | ResizeDecreaseLeft, | |
| 233 | + | ResizeDecreaseUp, | |
| 234 | + | ResizeDecreaseRight, | |
| 235 | + | ResizeDecreaseDown, | |
| 236 | + | }) | |
| 237 | + | } | |
| 238 | + | if actualDir == "" { | |
| 239 | + | return ErrNothingToResize | |
| 240 | + | } | |
| 241 | + | ||
| 242 | + | axis, ok := axisForResizeDirection(actualDir) | |
| 243 | + | if !ok { | |
| 244 | + | return ErrNothingToResize | |
| 245 | + | } | |
| 246 | + | ||
| 247 | + | splitNode := findNearestSplitForAxis(target, axis) | |
| 248 | + | if splitNode == nil { | |
| 249 | + | return ErrNothingToResize | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | // We treat resize directions as moving the split divider. | |
| 253 | + | // SplitRatio is the proportion allocated to the first child (left/top). | |
| 254 | + | // - Moving the divider right/down increases SplitRatio. | |
| 255 | + | // - Moving the divider left/up decreases SplitRatio. | |
| 256 | + | delta := deltaForDividerMove(actualDir, stepPercent) | |
| 257 | + | ||
| 258 | + | minRatio := minPanePercent / 100.0 | |
| 259 | + | maxRatio := 1.0 - minRatio | |
| 260 | + | oldRatio := splitNode.SplitRatio | |
| 261 | + | splitNode.SplitRatio = clampFloat64(splitNode.SplitRatio+delta, minRatio, maxRatio) | |
| 262 | + | ||
| 263 | + | log.Debug(). | |
| 264 | + | Str("direction", string(dir)). | |
| 265 | + | Str("actual_direction", string(actualDir)). | |
| 266 | + | Float64("old_ratio", oldRatio). | |
| 267 | + | Float64("new_ratio", splitNode.SplitRatio). | |
| 268 | + | Msg("pane resized") | |
| 269 | + | ||
| 270 | + | return nil | |
| 271 | + | } | |
| 272 | + | ||
| 273 | + | func findFirstAvailableDirection(target *entity.PaneNode, dirs []ResizeDirection) ResizeDirection { | |
| 274 | + | for _, d := range dirs { | |
| 275 | + | axis, ok := axisForResizeDirection(d) | |
| 276 | + | if !ok { | |
| 277 | + | continue | |
| 278 | + | } | |
| 279 | + | splitNode := findNearestSplitForAxis(target, axis) | |
| 280 | + | if splitNode != nil { | |
| 281 | + | return d | |
| 282 | + | } | |
| 283 | + | } | |
| 284 | + | return "" | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | type resizeAxis int | |
| 288 | + | ||
| 289 | + | const ( | |
| 290 | + | resizeAxisNone resizeAxis = iota | |
| 291 | + | resizeAxisHorizontal | |
| 292 | + | resizeAxisVertical | |
| 293 | + | ) | |
| 294 | + | ||
| 295 | + | func axisForResizeDirection(dir ResizeDirection) (resizeAxis, bool) { | |
| 296 | + | switch dir { | |
| 297 | + | case ResizeIncreaseLeft, ResizeIncreaseRight, ResizeDecreaseLeft, ResizeDecreaseRight: | |
| 298 | + | return resizeAxisHorizontal, true | |
| 299 | + | case ResizeIncreaseUp, ResizeIncreaseDown, ResizeDecreaseUp, ResizeDecreaseDown: | |
| 300 | + | return resizeAxisVertical, true | |
| 301 | + | default: | |
| 302 | + | return resizeAxisNone, false | |
| 303 | + | } | |
| 304 | + | } | |
| 305 | + | ||
| 306 | + | func deltaForDividerMove(dir ResizeDirection, stepPercent float64) float64 { | |
| 307 | + | delta := stepPercent / 100.0 | |
| 308 | + | if delta < 0 { | |
| 309 | + | delta = -delta | |
| 310 | + | } | |
| 311 | + | ||
| 312 | + | switch dir { | |
| 313 | + | case ResizeIncreaseRight, ResizeIncreaseDown: | |
| 314 | + | return delta | |
| 315 | + | case ResizeIncreaseLeft, ResizeIncreaseUp: | |
| 316 | + | return -delta | |
| 317 | + | case ResizeDecreaseRight, ResizeDecreaseDown: | |
| 318 | + | return -delta | |
| 319 | + | case ResizeDecreaseLeft, ResizeDecreaseUp: | |
| 320 | + | return delta | |
| 321 | + | default: | |
| 322 | + | return 0 | |
| 323 | + | } | |
| 324 | + | } | |
| 325 | + | ||
| 326 | + | // findNearestSplitForAxis walks up the tree to find the nearest split matching the axis. | |
| 327 | + | func findNearestSplitForAxis(node *entity.PaneNode, axis resizeAxis) *entity.PaneNode { | |
| 328 | + | current := node | |
| 329 | + | for current != nil && current.Parent != nil { | |
| 330 | + | parent := current.Parent | |
| 331 | + | if parent.IsSplit() { | |
| 332 | + | if axis == resizeAxisHorizontal && parent.SplitDir == entity.SplitHorizontal { | |
| 333 | + | return parent | |
| 334 | + | } | |
| 335 | + | if axis == resizeAxisVertical && parent.SplitDir == entity.SplitVertical { | |
| 336 | + | return parent | |
| 337 | + | } | |
| 338 | + | } | |
| 339 | + | current = parent | |
| 340 | + | } | |
| 341 | + | return nil | |
| 342 | + | } | |
| 343 | + | ||
| 344 | + | func clampFloat64(v, minVal, maxVal float64) float64 { | |
| 345 | + | if v < minVal { | |
| 346 | + | return minVal | |
| 347 | + | } | |
| 348 | + | if v > maxVal { | |
| 349 | + | return maxVal | |
| 350 | + | } | |
| 351 | + | return v | |
| 352 | + | } | |
| 353 | + | ||
| 169 | 354 | // Close removes a pane and promotes its sibling. | |
| 170 | 355 | // Returns the sibling node that was promoted, or nil if this was the last pane. | |
| 171 | 356 | func (uc *ManagePanesUseCase) Close(ctx context.Context, ws *entity.Workspace, paneNode *entity.PaneNode) (*entity.PaneNode, error) { | |
| @@ -2,6 +2,7 @@ package usecase | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "errors" | |||
| 5 | "fmt" | 6 | "fmt" | ||
| 6 | "sort" | 7 | "sort" | ||
| 7 | 8 | ||||
| @@ -29,6 +30,26 @@ const ( | |||||
| 29 | NavDown NavigateDirection = "down" | 30 | NavDown NavigateDirection = "down" | ||
| 30 | ) | 31 | ) | ||
| 31 | 32 | ||||
| 33 | + | // ResizeDirection indicates the direction for pane resizing. | |||
| 34 | + | type ResizeDirection string | |||
| 35 | + | ||||
| 36 | + | const ( | |||
| 37 | + | ResizeIncreaseLeft ResizeDirection = "increase_left" | |||
| 38 | + | ResizeIncreaseRight ResizeDirection = "increase_right" | |||
| 39 | + | ResizeIncreaseUp ResizeDirection = "increase_up" | |||
| 40 | + | ResizeIncreaseDown ResizeDirection = "increase_down" | |||
| 41 | + | ||||
| 42 | + | ResizeDecreaseLeft ResizeDirection = "decrease_left" | |||
| 43 | + | ResizeDecreaseRight ResizeDirection = "decrease_right" | |||
| 44 | + | ResizeDecreaseUp ResizeDirection = "decrease_up" | |||
| 45 | + | ResizeDecreaseDown ResizeDirection = "decrease_down" | |||
| 46 | + | ||||
| 47 | + | ResizeIncrease ResizeDirection = "increase" | |||
| 48 | + | ResizeDecrease ResizeDirection = "decrease" | |||
| 49 | + | ) | |||
| 50 | + | ||||
| 51 | + | var ErrNothingToResize = errors.New("nothing to resize") | |||
| 52 | + | ||||
| 32 | // ManagePanesUseCase handles pane tree operations. | 53 | // ManagePanesUseCase handles pane tree operations. | ||
| 33 | type ManagePanesUseCase struct { | 54 | type ManagePanesUseCase struct { | ||
| 34 | idGenerator IDGenerator | 55 | idGenerator IDGenerator | ||
| @@ -166,6 +187,170 @@ func (uc *ManagePanesUseCase) Split(ctx context.Context, input SplitPaneInput) ( | |||||
| 166 | }, nil | 187 | }, nil | ||
| 167 | } | 188 | } | ||
| 168 | 189 | ||||
| 190 | + | // Resize adjusts the nearest applicable split ratio for the given direction. | |||
| 191 | + | // stepPercent is applied per keystroke (e.g. 5.0 means 5%). | |||
| 192 | + | // minPanePercent enforces a minimum size for each side of a split. | |||
| 193 | + | func (uc *ManagePanesUseCase) Resize( | |||
| 194 | + | ctx context.Context, | |||
| 195 | + | ws *entity.Workspace, | |||
| 196 | + | paneNode *entity.PaneNode, | |||
| 197 | + | dir ResizeDirection, | |||
| 198 | + | stepPercent float64, | |||
| 199 | + | minPanePercent float64, | |||
| 200 | + | ) error { | |||
| 201 | + | log := logging.FromContext(ctx) | |||
| 202 | + | if uc == nil { | |||
| 203 | + | return fmt.Errorf("manage panes use case is nil") | |||
| 204 | + | } | |||
| 205 | + | ||||
| 206 | + | if ws == nil { | |||
| 207 | + | return fmt.Errorf("workspace is required") | |||
| 208 | + | } | |||
| 209 | + | if ws.Root == nil { | |||
| 210 | + | return ErrNothingToResize | |||
| 211 | + | } | |||
| 212 | + | if paneNode == nil { | |||
| 213 | + | return fmt.Errorf("pane node is required") | |||
| 214 | + | } | |||
| 215 | + | ||||
| 216 | + | target := paneNode | |||
| 217 | + | if target.Parent != nil && target.Parent.IsStacked { | |||
| 218 | + | target = target.Parent | |||
| 219 | + | } | |||
| 220 | + | ||||
| 221 | + | actualDir := dir | |||
| 222 | + | switch dir { | |||
| 223 | + | case ResizeIncrease: | |||
| 224 | + | actualDir = findFirstAvailableDirection(target, []ResizeDirection{ | |||
| 225 | + | ResizeIncreaseRight, | |||
| 226 | + | ResizeIncreaseDown, | |||
| 227 | + | ResizeIncreaseLeft, | |||
| 228 | + | ResizeIncreaseUp, | |||
| 229 | + | }) | |||
| 230 | + | case ResizeDecrease: | |||
| 231 | + | actualDir = findFirstAvailableDirection(target, []ResizeDirection{ | |||
| 232 | + | ResizeDecreaseLeft, | |||
| 233 | + | ResizeDecreaseUp, | |||
| 234 | + | ResizeDecreaseRight, | |||
| 235 | + | ResizeDecreaseDown, | |||
| 236 | + | }) | |||
| 237 | + | } | |||
| 238 | + | if actualDir == "" { | |||
| 239 | + | return ErrNothingToResize | |||
| 240 | + | } | |||
| 241 | + | ||||
| 242 | + | axis, ok := axisForResizeDirection(actualDir) | |||
| 243 | + | if !ok { | |||
| 244 | + | return ErrNothingToResize | |||
| 245 | + | } | |||
| 246 | + | ||||
| 247 | + | splitNode := findNearestSplitForAxis(target, axis) | |||
| 248 | + | if splitNode == nil { | |||
| 249 | + | return ErrNothingToResize | |||
| 250 | + | } | |||
| 251 | + | ||||
| 252 | + | // We treat resize directions as moving the split divider. | |||
| 253 | + | // SplitRatio is the proportion allocated to the first child (left/top). | |||
| 254 | + | // - Moving the divider right/down increases SplitRatio. | |||
| 255 | + | // - Moving the divider left/up decreases SplitRatio. | |||
| 256 | + | delta := deltaForDividerMove(actualDir, stepPercent) | |||
| 257 | + | ||||
| 258 | + | minRatio := minPanePercent / 100.0 | |||
| 259 | + | maxRatio := 1.0 - minRatio | |||
| 260 | + | oldRatio := splitNode.SplitRatio | |||
| 261 | + | splitNode.SplitRatio = clampFloat64(splitNode.SplitRatio+delta, minRatio, maxRatio) | |||
| 262 | + | ||||
| 263 | + | log.Debug(). | |||
| 264 | + | Str("direction", string(dir)). | |||
| 265 | + | Str("actual_direction", string(actualDir)). | |||
| 266 | + | Float64("old_ratio", oldRatio). | |||
| 267 | + | Float64("new_ratio", splitNode.SplitRatio). | |||
| 268 | + | Msg("pane resized") | |||
| 269 | + | ||||
| 270 | + | return nil | |||
| 271 | + | } | |||
| 272 | + | ||||
| 273 | + | func findFirstAvailableDirection(target *entity.PaneNode, dirs []ResizeDirection) ResizeDirection { | |||
| 274 | + | for _, d := range dirs { | |||
| 275 | + | axis, ok := axisForResizeDirection(d) | |||
| 276 | + | if !ok { | |||
| 277 | + | continue | |||
| 278 | + | } | |||
| 279 | + | splitNode := findNearestSplitForAxis(target, axis) | |||
| 280 | + | if splitNode != nil { | |||
| 281 | + | return d | |||
| 282 | + | } | |||
| 283 | + | } | |||
| 284 | + | return "" | |||
| 285 | + | } | |||
| 286 | + | ||||
| 287 | + | type resizeAxis int | |||
| 288 | + | ||||
| 289 | + | const ( | |||
| 290 | + | resizeAxisNone resizeAxis = iota | |||
| 291 | + | resizeAxisHorizontal | |||
| 292 | + | resizeAxisVertical | |||
| 293 | + | ) | |||
| 294 | + | ||||
| 295 | + | func axisForResizeDirection(dir ResizeDirection) (resizeAxis, bool) { | |||
| 296 | + | switch dir { | |||
| 297 | + | case ResizeIncreaseLeft, ResizeIncreaseRight, ResizeDecreaseLeft, ResizeDecreaseRight: | |||
| 298 | + | return resizeAxisHorizontal, true | |||
| 299 | + | case ResizeIncreaseUp, ResizeIncreaseDown, ResizeDecreaseUp, ResizeDecreaseDown: | |||
| 300 | + | return resizeAxisVertical, true | |||
| 301 | + | default: | |||
| 302 | + | return resizeAxisNone, false | |||
| 303 | + | } | |||
| 304 | + | } | |||
| 305 | + | ||||
| 306 | + | func deltaForDividerMove(dir ResizeDirection, stepPercent float64) float64 { | |||
| 307 | + | delta := stepPercent / 100.0 | |||
| 308 | + | if delta < 0 { | |||
| 309 | + | delta = -delta | |||
| 310 | + | } | |||
| 311 | + | ||||
| 312 | + | switch dir { | |||
| 313 | + | case ResizeIncreaseRight, ResizeIncreaseDown: | |||
| 314 | + | return delta | |||
| 315 | + | case ResizeIncreaseLeft, ResizeIncreaseUp: | |||
| 316 | + | return -delta | |||
| 317 | + | case ResizeDecreaseRight, ResizeDecreaseDown: | |||
| 318 | + | return -delta | |||
| 319 | + | case ResizeDecreaseLeft, ResizeDecreaseUp: | |||
| 320 | + | return delta | |||
| 321 | + | default: | |||
| 322 | + | return 0 | |||
| 323 | + | } | |||
| 324 | + | } | |||
| 325 | + | ||||
| 326 | + | // findNearestSplitForAxis walks up the tree to find the nearest split matching the axis. | |||
| 327 | + | func findNearestSplitForAxis(node *entity.PaneNode, axis resizeAxis) *entity.PaneNode { | |||
| 328 | + | current := node | |||
| 329 | + | for current != nil && current.Parent != nil { | |||
| 330 | + | parent := current.Parent | |||
| 331 | + | if parent.IsSplit() { | |||
| 332 | + | if axis == resizeAxisHorizontal && parent.SplitDir == entity.SplitHorizontal { | |||
| 333 | + | return parent | |||
| 334 | + | } | |||
| 335 | + | if axis == resizeAxisVertical && parent.SplitDir == entity.SplitVertical { | |||
| 336 | + | return parent | |||
| 337 | + | } | |||
| 338 | + | } | |||
| 339 | + | current = parent | |||
| 340 | + | } | |||
| 341 | + | return nil | |||
| 342 | + | } | |||
| 343 | + | ||||
| 344 | + | func clampFloat64(v, minVal, maxVal float64) float64 { | |||
| 345 | + | if v < minVal { | |||
| 346 | + | return minVal | |||
| 347 | + | } | |||
| 348 | + | if v > maxVal { | |||
| 349 | + | return maxVal | |||
| 350 | + | } | |||
| 351 | + | return v | |||
| 352 | + | } | |||
| 353 | + | ||||
| 169 | // Close removes a pane and promotes its sibling. | 354 | // Close removes a pane and promotes its sibling. | ||
| 170 | // Returns the sibling node that was promoted, or nil if this was the last pane. | 355 | // 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) { | 356 | func (uc *ManagePanesUseCase) Close(ctx context.Context, ws *entity.Workspace, paneNode *entity.PaneNode) (*entity.PaneNode, error) { | ||
M internal/ui/layout/tree.go +45 -15
| @@ -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,10 +28,11 @@ 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 | |
| @@ -43,11 +45,12 @@ func NewTreeRenderer(ctx context.Context, factory WidgetFactory, paneViewFactory | |||
| 43 | 45 | log.Debug().Msg("creating tree renderer") | |
| 44 | 46 | ||
| 45 | 47 | 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), | |
| 48 | + | factory: factory, | |
| 49 | + | paneViewFactory: paneViewFactory, | |
| 50 | + | logger: log.With().Str("component", "tree-renderer").Logger(), | |
| 51 | + | nodeToWidget: make(map[string]Widget), | |
| 52 | + | splitOrientation: make(map[string]Orientation), | |
| 53 | + | paneToStack: make(map[string]*StackedView), | |
| 51 | 54 | } | |
| 52 | 55 | } | |
| 53 | 56 | ||
| @@ -63,6 +66,7 @@ func (tr *TreeRenderer) Build(ctx context.Context, root *entity.PaneNode) (Widge | |||
| 63 | 66 | ||
| 64 | 67 | // Clear previous mappings | |
| 65 | 68 | tr.nodeToWidget = make(map[string]Widget) | |
| 69 | + | tr.splitOrientation = make(map[string]Orientation) | |
| 66 | 70 | tr.paneToStack = make(map[string]*StackedView) | |
| 67 | 71 | ||
| 68 | 72 | return tr.renderNode(ctx, root) | |
| @@ -166,6 +170,7 @@ func (tr *TreeRenderer) renderSplit(ctx context.Context, node *entity.PaneNode) | |||
| 166 | 170 | ||
| 167 | 171 | // Create split view | |
| 168 | 172 | splitView := NewSplitView(ctx, tr.factory, orientation, leftWidget, rightWidget, node.SplitRatio) | |
| 173 | + | tr.splitOrientation[node.ID] = orientation | |
| 169 | 174 | ||
| 170 | 175 | return splitView.Widget(), nil | |
| 171 | 176 | } | |
| @@ -247,6 +252,7 @@ func (tr *TreeRenderer) Clear() { | |||
| 247 | 252 | defer tr.mu.Unlock() | |
| 248 | 253 | ||
| 249 | 254 | tr.nodeToWidget = make(map[string]Widget) | |
| 255 | + | tr.splitOrientation = make(map[string]Orientation) | |
| 250 | 256 | } | |
| 251 | 257 | ||
| 252 | 258 | // RegisterWidget adds or updates a node-to-widget mapping. | |
| @@ -258,6 +264,15 @@ func (tr *TreeRenderer) RegisterWidget(nodeID string, widget Widget) { | |||
| 258 | 264 | tr.nodeToWidget[nodeID] = widget | |
| 259 | 265 | } | |
| 260 | 266 | ||
| 267 | + | // RegisterSplit registers a split widget with its orientation. | |
| 268 | + | func (tr *TreeRenderer) RegisterSplit(nodeID string, widget Widget, orientation Orientation) { | |
| 269 | + | tr.mu.Lock() | |
| 270 | + | defer tr.mu.Unlock() | |
| 271 | + | ||
| 272 | + | tr.nodeToWidget[nodeID] = widget | |
| 273 | + | tr.splitOrientation[nodeID] = orientation | |
| 274 | + | } | |
| 275 | + | ||
| 261 | 276 | // UnregisterWidget removes a node-to-widget mapping. | |
| 262 | 277 | // Use this when a node is removed or replaced during incremental operations. | |
| 263 | 278 | func (tr *TreeRenderer) UnregisterWidget(nodeID string) { | |
| @@ -265,6 +280,7 @@ func (tr *TreeRenderer) UnregisterWidget(nodeID string) { | |||
| 265 | 280 | defer tr.mu.Unlock() | |
| 266 | 281 | ||
| 267 | 282 | delete(tr.nodeToWidget, nodeID) | |
| 283 | + | delete(tr.splitOrientation, nodeID) | |
| 268 | 284 | } | |
| 269 | 285 | ||
| 270 | 286 | // UpdateSplitRatio updates the ratio of a split view by node ID. | |
| @@ -272,19 +288,33 @@ func (tr *TreeRenderer) UnregisterWidget(nodeID string) { | |||
| 272 | 288 | func (tr *TreeRenderer) UpdateSplitRatio(nodeID string, ratio float64) error { | |
| 273 | 289 | tr.mu.RLock() | |
| 274 | 290 | widget, ok := tr.nodeToWidget[nodeID] | |
| 291 | + | orientation, okOrient := tr.splitOrientation[nodeID] | |
| 275 | 292 | tr.mu.RUnlock() | |
| 276 | 293 | ||
| 277 | 294 | if !ok { | |
| 278 | 295 | return ErrNodeNotFound | |
| 279 | 296 | } | |
| 280 | 297 | ||
| 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 | |
| 298 | + | paned, ok := widget.(PanedWidget) | |
| 299 | + | if !ok { | |
| 300 | + | return fmt.Errorf("widget is not a PanedWidget") | |
| 301 | + | } | |
| 302 | + | if !okOrient { | |
| 303 | + | return fmt.Errorf("split orientation not found") | |
| 304 | + | } | |
| 305 | + | ||
| 306 | + | totalSize := 0 | |
| 307 | + | switch orientation { | |
| 308 | + | case OrientationHorizontal: | |
| 309 | + | totalSize = paned.GetAllocatedWidth() | |
| 310 | + | case OrientationVertical: | |
| 311 | + | totalSize = paned.GetAllocatedHeight() | |
| 312 | + | } | |
| 313 | + | if totalSize <= 0 { | |
| 314 | + | return nil | |
| 315 | + | } | |
| 285 | 316 | ||
| 286 | - | // For now, we can at least verify the widget exists | |
| 287 | - | _ = widget | |
| 317 | + | paned.SetPosition(int(float64(totalSize) * ratio)) | |
| 288 | 318 | return nil | |
| 289 | 319 | } | |
| 290 | 320 | ||
| @@ -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,10 +28,11 @@ 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 | ||
| @@ -43,11 +45,12 @@ func NewTreeRenderer(ctx context.Context, factory WidgetFactory, paneViewFactory | |||||
| 43 | log.Debug().Msg("creating tree renderer") | 45 | log.Debug().Msg("creating tree renderer") | ||
| 44 | 46 | ||||
| 45 | return &TreeRenderer{ | 47 | return &TreeRenderer{ | ||
| 46 | - | factory: factory, | 48 | + | factory: factory, |
| 47 | - | paneViewFactory: paneViewFactory, | 49 | + | paneViewFactory: paneViewFactory, |
| 48 | - | logger: log.With().Str("component", "tree-renderer").Logger(), | 50 | + | logger: log.With().Str("component", "tree-renderer").Logger(), |
| 49 | - | nodeToWidget: make(map[string]Widget), | 51 | + | nodeToWidget: make(map[string]Widget), |
| 50 | - | paneToStack: make(map[string]*StackedView), | 52 | + | splitOrientation: make(map[string]Orientation), |
| 53 | + | paneToStack: make(map[string]*StackedView), | |||
| 51 | } | 54 | } | ||
| 52 | } | 55 | } | ||
| 53 | 56 | ||||
| @@ -63,6 +66,7 @@ func (tr *TreeRenderer) Build(ctx context.Context, root *entity.PaneNode) (Widge | |||||
| 63 | 66 | ||||
| 64 | // Clear previous mappings | 67 | // Clear previous mappings | ||
| 65 | tr.nodeToWidget = make(map[string]Widget) | 68 | tr.nodeToWidget = make(map[string]Widget) | ||
| 69 | + | tr.splitOrientation = make(map[string]Orientation) | |||
| 66 | tr.paneToStack = make(map[string]*StackedView) | 70 | tr.paneToStack = make(map[string]*StackedView) | ||
| 67 | 71 | ||||
| 68 | return tr.renderNode(ctx, root) | 72 | return tr.renderNode(ctx, root) | ||
| @@ -166,6 +170,7 @@ func (tr *TreeRenderer) renderSplit(ctx context.Context, node *entity.PaneNode) | |||||
| 166 | 170 | ||||
| 167 | // Create split view | 171 | // Create split view | ||
| 168 | splitView := NewSplitView(ctx, tr.factory, orientation, leftWidget, rightWidget, node.SplitRatio) | 172 | splitView := NewSplitView(ctx, tr.factory, orientation, leftWidget, rightWidget, node.SplitRatio) | ||
| 173 | + | tr.splitOrientation[node.ID] = orientation | |||
| 169 | 174 | ||||
| 170 | return splitView.Widget(), nil | 175 | return splitView.Widget(), nil | ||
| 171 | } | 176 | } | ||
| @@ -247,6 +252,7 @@ func (tr *TreeRenderer) Clear() { | |||||
| 247 | defer tr.mu.Unlock() | 252 | defer tr.mu.Unlock() | ||
| 248 | 253 | ||||
| 249 | tr.nodeToWidget = make(map[string]Widget) | 254 | tr.nodeToWidget = make(map[string]Widget) | ||
| 255 | + | tr.splitOrientation = make(map[string]Orientation) | |||
| 250 | } | 256 | } | ||
| 251 | 257 | ||||
| 252 | // RegisterWidget adds or updates a node-to-widget mapping. | 258 | // RegisterWidget adds or updates a node-to-widget mapping. | ||
| @@ -258,6 +264,15 @@ func (tr *TreeRenderer) RegisterWidget(nodeID string, widget Widget) { | |||||
| 258 | tr.nodeToWidget[nodeID] = widget | 264 | tr.nodeToWidget[nodeID] = widget | ||
| 259 | } | 265 | } | ||
| 260 | 266 | ||||
| 267 | + | // RegisterSplit registers a split widget with its orientation. | |||
| 268 | + | func (tr *TreeRenderer) RegisterSplit(nodeID string, widget Widget, orientation Orientation) { | |||
| 269 | + | tr.mu.Lock() | |||
| 270 | + | defer tr.mu.Unlock() | |||
| 271 | + | ||||
| 272 | + | tr.nodeToWidget[nodeID] = widget | |||
| 273 | + | tr.splitOrientation[nodeID] = orientation | |||
| 274 | + | } | |||
| 275 | + | ||||
| 261 | // UnregisterWidget removes a node-to-widget mapping. | 276 | // UnregisterWidget removes a node-to-widget mapping. | ||
| 262 | // Use this when a node is removed or replaced during incremental operations. | 277 | // Use this when a node is removed or replaced during incremental operations. | ||
| 263 | func (tr *TreeRenderer) UnregisterWidget(nodeID string) { | 278 | func (tr *TreeRenderer) UnregisterWidget(nodeID string) { | ||
| @@ -265,6 +280,7 @@ func (tr *TreeRenderer) UnregisterWidget(nodeID string) { | |||||
| 265 | defer tr.mu.Unlock() | 280 | defer tr.mu.Unlock() | ||
| 266 | 281 | ||||
| 267 | delete(tr.nodeToWidget, nodeID) | 282 | delete(tr.nodeToWidget, nodeID) | ||
| 283 | + | delete(tr.splitOrientation, nodeID) | |||
| 268 | } | 284 | } | ||
| 269 | 285 | ||||
| 270 | // UpdateSplitRatio updates the ratio of a split view by node ID. | 286 | // UpdateSplitRatio updates the ratio of a split view by node ID. | ||
| @@ -272,19 +288,33 @@ func (tr *TreeRenderer) UnregisterWidget(nodeID string) { | |||||
| 272 | func (tr *TreeRenderer) UpdateSplitRatio(nodeID string, ratio float64) error { | 288 | func (tr *TreeRenderer) UpdateSplitRatio(nodeID string, ratio float64) error { | ||
| 273 | tr.mu.RLock() | 289 | tr.mu.RLock() | ||
| 274 | widget, ok := tr.nodeToWidget[nodeID] | 290 | widget, ok := tr.nodeToWidget[nodeID] | ||
| 291 | + | orientation, okOrient := tr.splitOrientation[nodeID] | |||
| 275 | tr.mu.RUnlock() | 292 | tr.mu.RUnlock() | ||
| 276 | 293 | ||||
| 277 | if !ok { | 294 | if !ok { | ||
| 278 | return ErrNodeNotFound | 295 | return ErrNodeNotFound | ||
| 279 | } | 296 | } | ||
| 280 | 297 | ||||
| 281 | - | // The widget should be a PanedWidget from a SplitView | 298 | + | paned, ok := widget.(PanedWidget) |
| 282 | - | // We can't directly update the ratio since we only store the Widget interface | 299 | + | if !ok { |
| 283 | - | // The caller would need to track SplitView instances separately or | 300 | + | return fmt.Errorf("widget is not a PanedWidget") |
| 284 | - | // use the underlying PanedWidget's SetPosition method | 301 | + | } |
| 302 | + | if !okOrient { | |||
| 303 | + | return fmt.Errorf("split orientation not found") | |||
| 304 | + | } | |||
| 305 | + | ||||
| 306 | + | totalSize := 0 | |||
| 307 | + | switch orientation { | |||
| 308 | + | case OrientationHorizontal: | |||
| 309 | + | totalSize = paned.GetAllocatedWidth() | |||
| 310 | + | case OrientationVertical: | |||
| 311 | + | totalSize = paned.GetAllocatedHeight() | |||
| 312 | + | } | |||
| 313 | + | if totalSize <= 0 { | |||
| 314 | + | return nil | |||
| 315 | + | } | |||
| 285 | 316 | ||||
| 286 | - | // For now, we can at least verify the widget exists | 317 | + | paned.SetPosition(int(float64(totalSize) * ratio)) |
| 287 | - | _ = widget | |||
| 288 | return nil | 318 | return nil | ||
| 289 | } | 319 | } | ||
| 290 | 320 | ||||
M internal/ui/layout/tree_test.go +1 -1
| @@ -538,7 +538,7 @@ func TestUpdateSplitRatio_ExistingNode(t *testing.T) { | |||
| 538 | 538 | mockRightStackBox.EXPECT().SetVisible(true).Once() | |
| 539 | 539 | mockPaned.EXPECT().SetStartChild(mockLeftStackBox).Once() | |
| 540 | 540 | mockPaned.EXPECT().SetEndChild(mockRightStackBox).Once() | |
| 541 | - | mockPaned.EXPECT().GetAllocatedWidth().Return(0).Once() | |
| 541 | + | mockPaned.EXPECT().GetAllocatedWidth().Return(0).Twice() | |
| 542 | 542 | mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once() | |
| 543 | 543 | mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once() | |
| 544 | 544 | ||
| @@ -538,7 +538,7 @@ func TestUpdateSplitRatio_ExistingNode(t *testing.T) { | |||||
| 538 | mockRightStackBox.EXPECT().SetVisible(true).Once() | 538 | mockRightStackBox.EXPECT().SetVisible(true).Once() | ||
| 539 | mockPaned.EXPECT().SetStartChild(mockLeftStackBox).Once() | 539 | mockPaned.EXPECT().SetStartChild(mockLeftStackBox).Once() | ||
| 540 | mockPaned.EXPECT().SetEndChild(mockRightStackBox).Once() | 540 | mockPaned.EXPECT().SetEndChild(mockRightStackBox).Once() | ||
| 541 | - | mockPaned.EXPECT().GetAllocatedWidth().Return(0).Once() | 541 | + | mockPaned.EXPECT().GetAllocatedWidth().Return(0).Twice() |
| 542 | mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once() | 542 | mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once() | ||
| 543 | mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once() | 543 | mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once() | ||
| 544 | 544 | ||||