feat(focus): add geometric navigation and mode border overlays
- Add focus manager with geometric pane navigation (Alt+HJKL/arrows) - Add border overlay system for tab/pane mode indicators - Implement stack-aware navigation (within stacks, then escape) - Add widget geometry methods (GetAllocatedWidth/Height, ComputePoint) - Fix stacked pane navigation using stack container geometry
- commit
- 8cb64710bd44a4814e35c4e0d7de524a25f4214e
- author
- brice · · 2025-12-16 06:52 UTC
- parent
- 739acba
- signature
- unsigned
11 files · +700 -25
M internal/application/usecase/manage_panes.go +118 -0
| @@ -3,6 +3,7 @@ package usecase | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "fmt" | |
| 6 | + | "sort" | |
| 6 | 7 | ||
| 7 | 8 | "github.com/bnema/dumber/internal/domain/entity" | |
| 8 | 9 | "github.com/bnema/dumber/internal/logging" | |
| @@ -303,6 +304,123 @@ func (uc *ManagePanesUseCase) NavigateFocus(ctx context.Context, ws *entity.Work | |||
| 303 | 304 | return targetNode, nil | |
| 304 | 305 | } | |
| 305 | 306 | ||
| 307 | + | // GeometricNavigationInput contains data for geometric focus navigation. | |
| 308 | + | type GeometricNavigationInput struct { | |
| 309 | + | ActivePaneID entity.PaneID | |
| 310 | + | PaneRects []entity.PaneRect // All visible panes with their positions | |
| 311 | + | Direction NavigateDirection | |
| 312 | + | } | |
| 313 | + | ||
| 314 | + | // GeometricNavigationOutput contains the result. | |
| 315 | + | type GeometricNavigationOutput struct { | |
| 316 | + | TargetPaneID entity.PaneID | |
| 317 | + | Found bool | |
| 318 | + | } | |
| 319 | + | ||
| 320 | + | // NavigateFocusGeometric finds the nearest pane in the given direction using geometry. | |
| 321 | + | // Algorithm: | |
| 322 | + | // 1. Get center of active pane | |
| 323 | + | // 2. Filter candidates that are in the direction (dx < 0 for Left, etc.) | |
| 324 | + | // 3. Score by: primary_distance * 1000 + perpendicular_distance | |
| 325 | + | // 4. Return lowest scoring candidate | |
| 326 | + | func (uc *ManagePanesUseCase) NavigateFocusGeometric( | |
| 327 | + | ctx context.Context, | |
| 328 | + | input GeometricNavigationInput, | |
| 329 | + | ) (*GeometricNavigationOutput, error) { | |
| 330 | + | log := logging.FromContext(ctx) | |
| 331 | + | log.Debug(). | |
| 332 | + | Str("direction", string(input.Direction)). | |
| 333 | + | Str("active", string(input.ActivePaneID)). | |
| 334 | + | Int("candidates", len(input.PaneRects)). | |
| 335 | + | Msg("geometric navigation") | |
| 336 | + | ||
| 337 | + | // Find active pane rect | |
| 338 | + | var activeRect *entity.PaneRect | |
| 339 | + | for i := range input.PaneRects { | |
| 340 | + | if input.PaneRects[i].PaneID == input.ActivePaneID { | |
| 341 | + | activeRect = &input.PaneRects[i] | |
| 342 | + | break | |
| 343 | + | } | |
| 344 | + | } | |
| 345 | + | if activeRect == nil { | |
| 346 | + | log.Debug().Msg("active pane rect not found") | |
| 347 | + | return &GeometricNavigationOutput{Found: false}, nil | |
| 348 | + | } | |
| 349 | + | ||
| 350 | + | acx, acy := activeRect.Center() | |
| 351 | + | ||
| 352 | + | type candidate struct { | |
| 353 | + | paneID entity.PaneID | |
| 354 | + | score int | |
| 355 | + | } | |
| 356 | + | var candidates []candidate | |
| 357 | + | ||
| 358 | + | for _, rect := range input.PaneRects { | |
| 359 | + | if rect.PaneID == input.ActivePaneID { | |
| 360 | + | continue | |
| 361 | + | } | |
| 362 | + | ||
| 363 | + | cx, cy := rect.Center() | |
| 364 | + | dx := cx - acx | |
| 365 | + | dy := cy - acy | |
| 366 | + | ||
| 367 | + | var inDirection bool | |
| 368 | + | var primaryDist, perpDist int | |
| 369 | + | ||
| 370 | + | switch input.Direction { | |
| 371 | + | case NavLeft: | |
| 372 | + | inDirection = dx < 0 | |
| 373 | + | primaryDist = abs(dx) | |
| 374 | + | perpDist = abs(dy) | |
| 375 | + | case NavRight: | |
| 376 | + | inDirection = dx > 0 | |
| 377 | + | primaryDist = abs(dx) | |
| 378 | + | perpDist = abs(dy) | |
| 379 | + | case NavUp: | |
| 380 | + | inDirection = dy < 0 | |
| 381 | + | primaryDist = abs(dy) | |
| 382 | + | perpDist = abs(dx) | |
| 383 | + | case NavDown: | |
| 384 | + | inDirection = dy > 0 | |
| 385 | + | primaryDist = abs(dy) | |
| 386 | + | perpDist = abs(dx) | |
| 387 | + | } | |
| 388 | + | ||
| 389 | + | if inDirection { | |
| 390 | + | score := primaryDist*1000 + perpDist | |
| 391 | + | candidates = append(candidates, candidate{rect.PaneID, score}) | |
| 392 | + | } | |
| 393 | + | } | |
| 394 | + | ||
| 395 | + | if len(candidates) == 0 { | |
| 396 | + | log.Debug().Msg("no candidates in direction") | |
| 397 | + | return &GeometricNavigationOutput{Found: false}, nil | |
| 398 | + | } | |
| 399 | + | ||
| 400 | + | // Sort by score (lowest first) | |
| 401 | + | sort.Slice(candidates, func(i, j int) bool { | |
| 402 | + | return candidates[i].score < candidates[j].score | |
| 403 | + | }) | |
| 404 | + | ||
| 405 | + | log.Debug(). | |
| 406 | + | Str("target", string(candidates[0].paneID)). | |
| 407 | + | Int("score", candidates[0].score). | |
| 408 | + | Msg("geometric navigation found target") | |
| 409 | + | ||
| 410 | + | return &GeometricNavigationOutput{ | |
| 411 | + | TargetPaneID: candidates[0].paneID, | |
| 412 | + | Found: true, | |
| 413 | + | }, nil | |
| 414 | + | } | |
| 415 | + | ||
| 416 | + | // abs returns the absolute value of an integer. | |
| 417 | + | func abs(x int) int { | |
| 418 | + | if x < 0 { | |
| 419 | + | return -x | |
| 420 | + | } | |
| 421 | + | return x | |
| 422 | + | } | |
| 423 | + | ||
| 306 | 424 | // findAdjacentPane finds the adjacent pane in the given direction using tree structure. | |
| 307 | 425 | func (uc *ManagePanesUseCase) findAdjacentPane(node *entity.PaneNode, direction NavigateDirection) *entity.PaneNode { | |
| 308 | 426 | if node == nil { | |
| @@ -3,6 +3,7 @@ package usecase | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "fmt" | 5 | "fmt" | ||
| 6 | + | "sort" | |||
| 6 | 7 | ||||
| 7 | "github.com/bnema/dumber/internal/domain/entity" | 8 | "github.com/bnema/dumber/internal/domain/entity" | ||
| 8 | "github.com/bnema/dumber/internal/logging" | 9 | "github.com/bnema/dumber/internal/logging" | ||
| @@ -303,6 +304,123 @@ func (uc *ManagePanesUseCase) NavigateFocus(ctx context.Context, ws *entity.Work | |||||
| 303 | return targetNode, nil | 304 | return targetNode, nil | ||
| 304 | } | 305 | } | ||
| 305 | 306 | ||||
| 307 | + | // GeometricNavigationInput contains data for geometric focus navigation. | |||
| 308 | + | type GeometricNavigationInput struct { | |||
| 309 | + | ActivePaneID entity.PaneID | |||
| 310 | + | PaneRects []entity.PaneRect // All visible panes with their positions | |||
| 311 | + | Direction NavigateDirection | |||
| 312 | + | } | |||
| 313 | + | ||||
| 314 | + | // GeometricNavigationOutput contains the result. | |||
| 315 | + | type GeometricNavigationOutput struct { | |||
| 316 | + | TargetPaneID entity.PaneID | |||
| 317 | + | Found bool | |||
| 318 | + | } | |||
| 319 | + | ||||
| 320 | + | // NavigateFocusGeometric finds the nearest pane in the given direction using geometry. | |||
| 321 | + | // Algorithm: | |||
| 322 | + | // 1. Get center of active pane | |||
| 323 | + | // 2. Filter candidates that are in the direction (dx < 0 for Left, etc.) | |||
| 324 | + | // 3. Score by: primary_distance * 1000 + perpendicular_distance | |||
| 325 | + | // 4. Return lowest scoring candidate | |||
| 326 | + | func (uc *ManagePanesUseCase) NavigateFocusGeometric( | |||
| 327 | + | ctx context.Context, | |||
| 328 | + | input GeometricNavigationInput, | |||
| 329 | + | ) (*GeometricNavigationOutput, error) { | |||
| 330 | + | log := logging.FromContext(ctx) | |||
| 331 | + | log.Debug(). | |||
| 332 | + | Str("direction", string(input.Direction)). | |||
| 333 | + | Str("active", string(input.ActivePaneID)). | |||
| 334 | + | Int("candidates", len(input.PaneRects)). | |||
| 335 | + | Msg("geometric navigation") | |||
| 336 | + | ||||
| 337 | + | // Find active pane rect | |||
| 338 | + | var activeRect *entity.PaneRect | |||
| 339 | + | for i := range input.PaneRects { | |||
| 340 | + | if input.PaneRects[i].PaneID == input.ActivePaneID { | |||
| 341 | + | activeRect = &input.PaneRects[i] | |||
| 342 | + | break | |||
| 343 | + | } | |||
| 344 | + | } | |||
| 345 | + | if activeRect == nil { | |||
| 346 | + | log.Debug().Msg("active pane rect not found") | |||
| 347 | + | return &GeometricNavigationOutput{Found: false}, nil | |||
| 348 | + | } | |||
| 349 | + | ||||
| 350 | + | acx, acy := activeRect.Center() | |||
| 351 | + | ||||
| 352 | + | type candidate struct { | |||
| 353 | + | paneID entity.PaneID | |||
| 354 | + | score int | |||
| 355 | + | } | |||
| 356 | + | var candidates []candidate | |||
| 357 | + | ||||
| 358 | + | for _, rect := range input.PaneRects { | |||
| 359 | + | if rect.PaneID == input.ActivePaneID { | |||
| 360 | + | continue | |||
| 361 | + | } | |||
| 362 | + | ||||
| 363 | + | cx, cy := rect.Center() | |||
| 364 | + | dx := cx - acx | |||
| 365 | + | dy := cy - acy | |||
| 366 | + | ||||
| 367 | + | var inDirection bool | |||
| 368 | + | var primaryDist, perpDist int | |||
| 369 | + | ||||
| 370 | + | switch input.Direction { | |||
| 371 | + | case NavLeft: | |||
| 372 | + | inDirection = dx < 0 | |||
| 373 | + | primaryDist = abs(dx) | |||
| 374 | + | perpDist = abs(dy) | |||
| 375 | + | case NavRight: | |||
| 376 | + | inDirection = dx > 0 | |||
| 377 | + | primaryDist = abs(dx) | |||
| 378 | + | perpDist = abs(dy) | |||
| 379 | + | case NavUp: | |||
| 380 | + | inDirection = dy < 0 | |||
| 381 | + | primaryDist = abs(dy) | |||
| 382 | + | perpDist = abs(dx) | |||
| 383 | + | case NavDown: | |||
| 384 | + | inDirection = dy > 0 | |||
| 385 | + | primaryDist = abs(dy) | |||
| 386 | + | perpDist = abs(dx) | |||
| 387 | + | } | |||
| 388 | + | ||||
| 389 | + | if inDirection { | |||
| 390 | + | score := primaryDist*1000 + perpDist | |||
| 391 | + | candidates = append(candidates, candidate{rect.PaneID, score}) | |||
| 392 | + | } | |||
| 393 | + | } | |||
| 394 | + | ||||
| 395 | + | if len(candidates) == 0 { | |||
| 396 | + | log.Debug().Msg("no candidates in direction") | |||
| 397 | + | return &GeometricNavigationOutput{Found: false}, nil | |||
| 398 | + | } | |||
| 399 | + | ||||
| 400 | + | // Sort by score (lowest first) | |||
| 401 | + | sort.Slice(candidates, func(i, j int) bool { | |||
| 402 | + | return candidates[i].score < candidates[j].score | |||
| 403 | + | }) | |||
| 404 | + | ||||
| 405 | + | log.Debug(). | |||
| 406 | + | Str("target", string(candidates[0].paneID)). | |||
| 407 | + | Int("score", candidates[0].score). | |||
| 408 | + | Msg("geometric navigation found target") | |||
| 409 | + | ||||
| 410 | + | return &GeometricNavigationOutput{ | |||
| 411 | + | TargetPaneID: candidates[0].paneID, | |||
| 412 | + | Found: true, | |||
| 413 | + | }, nil | |||
| 414 | + | } | |||
| 415 | + | ||||
| 416 | + | // abs returns the absolute value of an integer. | |||
| 417 | + | func abs(x int) int { | |||
| 418 | + | if x < 0 { | |||
| 419 | + | return -x | |||
| 420 | + | } | |||
| 421 | + | return x | |||
| 422 | + | } | |||
| 423 | + | ||||
| 306 | // findAdjacentPane finds the adjacent pane in the given direction using tree structure. | 424 | // findAdjacentPane finds the adjacent pane in the given direction using tree structure. | ||
| 307 | func (uc *ManagePanesUseCase) findAdjacentPane(node *entity.PaneNode, direction NavigateDirection) *entity.PaneNode { | 425 | func (uc *ManagePanesUseCase) findAdjacentPane(node *entity.PaneNode, direction NavigateDirection) *entity.PaneNode { | ||
| 308 | if node == nil { | 426 | if node == nil { | ||
A internal/domain/entity/geometry.go +15 -0
| @@ -0,0 +1,15 @@ | |||
| 1 | + | // Package entity defines domain entities for the browser. | |
| 2 | + | package entity | |
| 3 | + | ||
| 4 | + | // PaneRect represents a pane's screen position and size. | |
| 5 | + | // Used for geometric navigation to find adjacent panes by position. | |
| 6 | + | type PaneRect struct { | |
| 7 | + | PaneID PaneID | |
| 8 | + | X, Y int // Top-left position relative to workspace container | |
| 9 | + | W, H int // Width and height | |
| 10 | + | } | |
| 11 | + | ||
| 12 | + | // Center returns the center point of the rectangle. | |
| 13 | + | func (r PaneRect) Center() (cx, cy int) { | |
| 14 | + | return r.X + r.W/2, r.Y + r.H/2 | |
| 15 | + | } | |
| @@ -0,0 +1,15 @@ | |||||
| 1 | + | // Package entity defines domain entities for the browser. | |||
| 2 | + | package entity | |||
| 3 | + | ||||
| 4 | + | // PaneRect represents a pane's screen position and size. | |||
| 5 | + | // Used for geometric navigation to find adjacent panes by position. | |||
| 6 | + | type PaneRect struct { | |||
| 7 | + | PaneID PaneID | |||
| 8 | + | X, Y int // Top-left position relative to workspace container | |||
| 9 | + | W, H int // Width and height | |||
| 10 | + | } | |||
| 11 | + | ||||
| 12 | + | // Center returns the center point of the rectangle. | |||
| 13 | + | func (r PaneRect) Center() (cx, cy int) { | |||
| 14 | + | return r.X + r.W/2, r.Y + r.H/2 | |||
| 15 | + | } | |||
M internal/ui/app.go +45 -13
| @@ -15,6 +15,7 @@ import ( | |||
| 15 | 15 | "github.com/bnema/dumber/internal/infrastructure/webkit" | |
| 16 | 16 | "github.com/bnema/dumber/internal/logging" | |
| 17 | 17 | "github.com/bnema/dumber/internal/ui/component" | |
| 18 | + | "github.com/bnema/dumber/internal/ui/focus" | |
| 18 | 19 | "github.com/bnema/dumber/internal/ui/input" | |
| 19 | 20 | "github.com/bnema/dumber/internal/ui/layout" | |
| 20 | 21 | "github.com/bnema/dumber/internal/ui/window" | |
| @@ -44,6 +45,10 @@ type App struct { | |||
| 44 | 45 | // Input handling | |
| 45 | 46 | keyboardHandler *input.KeyboardHandler | |
| 46 | 47 | ||
| 48 | + | // Focus and border management | |
| 49 | + | focusMgr *focus.Manager | |
| 50 | + | borderMgr *focus.BorderManager | |
| 51 | + | ||
| 47 | 52 | // Native omnibox | |
| 48 | 53 | omnibox *component.Omnibox | |
| 49 | 54 | ||
| @@ -156,6 +161,12 @@ func (a *App) onActivate(ctx context.Context) { | |||
| 156 | 161 | // Initialize stacked pane manager for incremental widget operations | |
| 157 | 162 | a.stackedPaneMgr = component.NewStackedPaneManager(a.widgetFactory) | |
| 158 | 163 | ||
| 164 | + | // Initialize focus manager for geometric navigation | |
| 165 | + | a.focusMgr = focus.NewManager(a.panesUC) | |
| 166 | + | ||
| 167 | + | // Initialize border manager for mode indicators | |
| 168 | + | a.borderMgr = focus.NewBorderManager(a.widgetFactory) | |
| 169 | + | ||
| 159 | 170 | // Create keyboard handler | |
| 160 | 171 | a.keyboardHandler = input.NewKeyboardHandler( | |
| 161 | 172 | ctx, | |
| @@ -365,6 +376,11 @@ func (a *App) handleKeyboardAction(ctx context.Context, action input.Action) err | |||
| 365 | 376 | func (a *App) handleModeChange(ctx context.Context, from, to input.Mode) { | |
| 366 | 377 | log := logging.FromContext(ctx) | |
| 367 | 378 | log.Debug().Str("from", from.String()).Str("to", to.String()).Msg("input mode changed") | |
| 379 | + | ||
| 380 | + | // Update border overlay visibility based on mode | |
| 381 | + | if a.borderMgr != nil { | |
| 382 | + | a.borderMgr.OnModeChange(ctx, from, to) | |
| 383 | + | } | |
| 368 | 384 | } | |
| 369 | 385 | ||
| 370 | 386 | // handleNewTab creates a new tab. | |
| @@ -510,6 +526,11 @@ func (a *App) createWorkspaceView(ctx context.Context, tab *entity.Tab) { | |||
| 510 | 526 | // Ensure WebViews are attached to panes | |
| 511 | 527 | a.attachWorkspaceWebViews(ctx, tab.Workspace, wsView) | |
| 512 | 528 | ||
| 529 | + | // Attach border manager overlay for mode indicators | |
| 530 | + | if a.borderMgr != nil { | |
| 531 | + | wsView.SetModeBorderOverlay(a.borderMgr.Widget()) | |
| 532 | + | } | |
| 533 | + | ||
| 513 | 534 | // Store in map | |
| 514 | 535 | a.workspaceViews[tab.ID] = wsView | |
| 515 | 536 | ||
| @@ -646,18 +667,32 @@ func (a *App) handleClosePane(ctx context.Context) error { | |||
| 646 | 667 | func (a *App) handlePaneFocus(ctx context.Context, direction usecase.NavigateDirection) error { | |
| 647 | 668 | log := logging.FromContext(ctx) | |
| 648 | 669 | ||
| 649 | - | if a.panesUC == nil { | |
| 650 | - | log.Warn().Msg("panes use case not available") | |
| 651 | - | return nil | |
| 652 | - | } | |
| 653 | - | ||
| 654 | 670 | ws := a.activeWorkspace() | |
| 655 | 671 | if ws == nil { | |
| 656 | 672 | log.Warn().Msg("no active workspace") | |
| 657 | 673 | return nil | |
| 658 | 674 | } | |
| 659 | 675 | ||
| 660 | - | newPane, err := a.panesUC.NavigateFocus(ctx, ws, direction) | |
| 676 | + | wsView := a.activeWorkspaceView() | |
| 677 | + | if wsView == nil { | |
| 678 | + | log.Warn().Msg("no active workspace view") | |
| 679 | + | return nil | |
| 680 | + | } | |
| 681 | + | ||
| 682 | + | // Use geometric navigation if focus manager is available | |
| 683 | + | var newPane *entity.PaneNode | |
| 684 | + | var err error | |
| 685 | + | ||
| 686 | + | if a.focusMgr != nil { | |
| 687 | + | newPane, err = a.focusMgr.NavigateGeometric(ctx, ws, wsView, direction) | |
| 688 | + | } else if a.panesUC != nil { | |
| 689 | + | // Fallback to structural navigation | |
| 690 | + | newPane, err = a.panesUC.NavigateFocus(ctx, ws, direction) | |
| 691 | + | } else { | |
| 692 | + | log.Warn().Msg("no navigation manager available") | |
| 693 | + | return nil | |
| 694 | + | } | |
| 695 | + | ||
| 661 | 696 | if err != nil { | |
| 662 | 697 | log.Error().Err(err).Str("direction", string(direction)).Msg("failed to navigate focus") | |
| 663 | 698 | return err | |
| @@ -669,13 +704,10 @@ func (a *App) handlePaneFocus(ctx context.Context, direction usecase.NavigateDir | |||
| 669 | 704 | } | |
| 670 | 705 | ||
| 671 | 706 | // Update the workspace view's active pane | |
| 672 | - | wsView := a.activeWorkspaceView() | |
| 673 | - | if wsView != nil { | |
| 674 | - | if err := wsView.SetActivePaneID(newPane.Pane.ID); err != nil { | |
| 675 | - | log.Warn().Err(err).Msg("failed to update active pane in view") | |
| 676 | - | } else { | |
| 677 | - | wsView.FocusPane(newPane.Pane.ID) | |
| 678 | - | } | |
| 707 | + | if err := wsView.SetActivePaneID(newPane.Pane.ID); err != nil { | |
| 708 | + | log.Warn().Err(err).Msg("failed to update active pane in view") | |
| 709 | + | } else { | |
| 710 | + | wsView.FocusPane(newPane.Pane.ID) | |
| 679 | 711 | } | |
| 680 | 712 | ||
| 681 | 713 | log.Debug().Str("direction", string(direction)).Str("new_pane_id", newPane.ID).Msg("focus navigated") | |
| @@ -15,6 +15,7 @@ import ( | |||||
| 15 | "github.com/bnema/dumber/internal/infrastructure/webkit" | 15 | "github.com/bnema/dumber/internal/infrastructure/webkit" | ||
| 16 | "github.com/bnema/dumber/internal/logging" | 16 | "github.com/bnema/dumber/internal/logging" | ||
| 17 | "github.com/bnema/dumber/internal/ui/component" | 17 | "github.com/bnema/dumber/internal/ui/component" | ||
| 18 | + | "github.com/bnema/dumber/internal/ui/focus" | |||
| 18 | "github.com/bnema/dumber/internal/ui/input" | 19 | "github.com/bnema/dumber/internal/ui/input" | ||
| 19 | "github.com/bnema/dumber/internal/ui/layout" | 20 | "github.com/bnema/dumber/internal/ui/layout" | ||
| 20 | "github.com/bnema/dumber/internal/ui/window" | 21 | "github.com/bnema/dumber/internal/ui/window" | ||
| @@ -44,6 +45,10 @@ type App struct { | |||||
| 44 | // Input handling | 45 | // Input handling | ||
| 45 | keyboardHandler *input.KeyboardHandler | 46 | keyboardHandler *input.KeyboardHandler | ||
| 46 | 47 | ||||
| 48 | + | // Focus and border management | |||
| 49 | + | focusMgr *focus.Manager | |||
| 50 | + | borderMgr *focus.BorderManager | |||
| 51 | + | ||||
| 47 | // Native omnibox | 52 | // Native omnibox | ||
| 48 | omnibox *component.Omnibox | 53 | omnibox *component.Omnibox | ||
| 49 | 54 | ||||
| @@ -156,6 +161,12 @@ func (a *App) onActivate(ctx context.Context) { | |||||
| 156 | // Initialize stacked pane manager for incremental widget operations | 161 | // Initialize stacked pane manager for incremental widget operations | ||
| 157 | a.stackedPaneMgr = component.NewStackedPaneManager(a.widgetFactory) | 162 | a.stackedPaneMgr = component.NewStackedPaneManager(a.widgetFactory) | ||
| 158 | 163 | ||||
| 164 | + | // Initialize focus manager for geometric navigation | |||
| 165 | + | a.focusMgr = focus.NewManager(a.panesUC) | |||
| 166 | + | ||||
| 167 | + | // Initialize border manager for mode indicators | |||
| 168 | + | a.borderMgr = focus.NewBorderManager(a.widgetFactory) | |||
| 169 | + | ||||
| 159 | // Create keyboard handler | 170 | // Create keyboard handler | ||
| 160 | a.keyboardHandler = input.NewKeyboardHandler( | 171 | a.keyboardHandler = input.NewKeyboardHandler( | ||
| 161 | ctx, | 172 | ctx, | ||
| @@ -365,6 +376,11 @@ func (a *App) handleKeyboardAction(ctx context.Context, action input.Action) err | |||||
| 365 | func (a *App) handleModeChange(ctx context.Context, from, to input.Mode) { | 376 | func (a *App) handleModeChange(ctx context.Context, from, to input.Mode) { | ||
| 366 | log := logging.FromContext(ctx) | 377 | log := logging.FromContext(ctx) | ||
| 367 | log.Debug().Str("from", from.String()).Str("to", to.String()).Msg("input mode changed") | 378 | log.Debug().Str("from", from.String()).Str("to", to.String()).Msg("input mode changed") | ||
| 379 | + | ||||
| 380 | + | // Update border overlay visibility based on mode | |||
| 381 | + | if a.borderMgr != nil { | |||
| 382 | + | a.borderMgr.OnModeChange(ctx, from, to) | |||
| 383 | + | } | |||
| 368 | } | 384 | } | ||
| 369 | 385 | ||||
| 370 | // handleNewTab creates a new tab. | 386 | // handleNewTab creates a new tab. | ||
| @@ -510,6 +526,11 @@ func (a *App) createWorkspaceView(ctx context.Context, tab *entity.Tab) { | |||||
| 510 | // Ensure WebViews are attached to panes | 526 | // Ensure WebViews are attached to panes | ||
| 511 | a.attachWorkspaceWebViews(ctx, tab.Workspace, wsView) | 527 | a.attachWorkspaceWebViews(ctx, tab.Workspace, wsView) | ||
| 512 | 528 | ||||
| 529 | + | // Attach border manager overlay for mode indicators | |||
| 530 | + | if a.borderMgr != nil { | |||
| 531 | + | wsView.SetModeBorderOverlay(a.borderMgr.Widget()) | |||
| 532 | + | } | |||
| 533 | + | ||||
| 513 | // Store in map | 534 | // Store in map | ||
| 514 | a.workspaceViews[tab.ID] = wsView | 535 | a.workspaceViews[tab.ID] = wsView | ||
| 515 | 536 | ||||
| @@ -646,18 +667,32 @@ func (a *App) handleClosePane(ctx context.Context) error { | |||||
| 646 | func (a *App) handlePaneFocus(ctx context.Context, direction usecase.NavigateDirection) error { | 667 | func (a *App) handlePaneFocus(ctx context.Context, direction usecase.NavigateDirection) error { | ||
| 647 | log := logging.FromContext(ctx) | 668 | log := logging.FromContext(ctx) | ||
| 648 | 669 | ||||
| 649 | - | if a.panesUC == nil { | |||
| 650 | - | log.Warn().Msg("panes use case not available") | |||
| 651 | - | return nil | |||
| 652 | - | } | |||
| 653 | - | ||||
| 654 | ws := a.activeWorkspace() | 670 | ws := a.activeWorkspace() | ||
| 655 | if ws == nil { | 671 | if ws == nil { | ||
| 656 | log.Warn().Msg("no active workspace") | 672 | log.Warn().Msg("no active workspace") | ||
| 657 | return nil | 673 | return nil | ||
| 658 | } | 674 | } | ||
| 659 | 675 | ||||
| 660 | - | newPane, err := a.panesUC.NavigateFocus(ctx, ws, direction) | 676 | + | wsView := a.activeWorkspaceView() |
| 677 | + | if wsView == nil { | |||
| 678 | + | log.Warn().Msg("no active workspace view") | |||
| 679 | + | return nil | |||
| 680 | + | } | |||
| 681 | + | ||||
| 682 | + | // Use geometric navigation if focus manager is available | |||
| 683 | + | var newPane *entity.PaneNode | |||
| 684 | + | var err error | |||
| 685 | + | ||||
| 686 | + | if a.focusMgr != nil { | |||
| 687 | + | newPane, err = a.focusMgr.NavigateGeometric(ctx, ws, wsView, direction) | |||
| 688 | + | } else if a.panesUC != nil { | |||
| 689 | + | // Fallback to structural navigation | |||
| 690 | + | newPane, err = a.panesUC.NavigateFocus(ctx, ws, direction) | |||
| 691 | + | } else { | |||
| 692 | + | log.Warn().Msg("no navigation manager available") | |||
| 693 | + | return nil | |||
| 694 | + | } | |||
| 695 | + | ||||
| 661 | if err != nil { | 696 | if err != nil { | ||
| 662 | log.Error().Err(err).Str("direction", string(direction)).Msg("failed to navigate focus") | 697 | log.Error().Err(err).Str("direction", string(direction)).Msg("failed to navigate focus") | ||
| 663 | return err | 698 | return err | ||
| @@ -669,13 +704,10 @@ func (a *App) handlePaneFocus(ctx context.Context, direction usecase.NavigateDir | |||||
| 669 | } | 704 | } | ||
| 670 | 705 | ||||
| 671 | // Update the workspace view's active pane | 706 | // Update the workspace view's active pane | ||
| 672 | - | wsView := a.activeWorkspaceView() | 707 | + | if err := wsView.SetActivePaneID(newPane.Pane.ID); err != nil { |
| 673 | - | if wsView != nil { | 708 | + | log.Warn().Err(err).Msg("failed to update active pane in view") |
| 674 | - | if err := wsView.SetActivePaneID(newPane.Pane.ID); err != nil { | 709 | + | } else { |
| 675 | - | log.Warn().Err(err).Msg("failed to update active pane in view") | 710 | + | wsView.FocusPane(newPane.Pane.ID) |
| 676 | - | } else { | |||
| 677 | - | wsView.FocusPane(newPane.Pane.ID) | |||
| 678 | - | } | |||
| 679 | } | 711 | } | ||
| 680 | 712 | ||||
| 681 | log.Debug().Str("direction", string(direction)).Str("new_pane_id", newPane.ID).Msg("focus navigated") | 713 | log.Debug().Str("direction", string(direction)).Str("new_pane_id", newPane.ID).Msg("focus navigated") | ||
M internal/ui/component/pane_view.go +1 -1
| @@ -10,7 +10,7 @@ import ( | |||
| 10 | 10 | ||
| 11 | 11 | const ( | |
| 12 | 12 | // CSS class applied to active pane's border overlay | |
| 13 | - | activePaneClass = "active-pane" | |
| 13 | + | activePaneClass = "pane-active" | |
| 14 | 14 | ) | |
| 15 | 15 | ||
| 16 | 16 | // PaneView is a container for a single WebView with active state indication. | |
| @@ -10,7 +10,7 @@ import ( | |||||
| 10 | 10 | ||||
| 11 | const ( | 11 | const ( | ||
| 12 | // CSS class applied to active pane's border overlay | 12 | // CSS class applied to active pane's border overlay | ||
| 13 | - | activePaneClass = "active-pane" | 13 | + | activePaneClass = "pane-active" |
| 14 | ) | 14 | ) | ||
| 15 | 15 | ||||
| 16 | // PaneView is a container for a single WebView with active state indication. | 16 | // PaneView is a container for a single WebView with active state indication. | ||
M internal/ui/component/workspace_view.go +73 -3
| @@ -24,9 +24,13 @@ type WorkspaceView struct { | |||
| 24 | 24 | factory layout.WidgetFactory | |
| 25 | 25 | treeRenderer *layout.TreeRenderer | |
| 26 | 26 | container layout.BoxWidget | |
| 27 | - | rootWidget layout.Widget // Current root widget for removal on rebuild | |
| 27 | + | overlay layout.OverlayWidget // Wraps container for mode borders | |
| 28 | + | rootWidget layout.Widget // Current root widget for removal on rebuild | |
| 28 | 29 | logger zerolog.Logger | |
| 29 | 30 | ||
| 31 | + | // Mode border overlay slot | |
| 32 | + | modeBorderWidget layout.Widget | |
| 33 | + | ||
| 30 | 34 | workspace *entity.Workspace | |
| 31 | 35 | paneViews map[entity.PaneID]*PaneView | |
| 32 | 36 | activePaneID entity.PaneID | |
| @@ -78,9 +82,17 @@ func NewWorkspaceView(ctx context.Context, factory layout.WidgetFactory) *Worksp | |||
| 78 | 82 | container.SetVexpand(true) | |
| 79 | 83 | container.SetVisible(true) | |
| 80 | 84 | ||
| 85 | + | // Wrap container in overlay for mode borders | |
| 86 | + | overlay := factory.NewOverlay() | |
| 87 | + | overlay.SetHexpand(true) | |
| 88 | + | overlay.SetVexpand(true) | |
| 89 | + | overlay.SetChild(container) | |
| 90 | + | overlay.SetVisible(true) | |
| 91 | + | ||
| 81 | 92 | wv := &WorkspaceView{ | |
| 82 | 93 | factory: factory, | |
| 83 | 94 | container: container, | |
| 95 | + | overlay: overlay, | |
| 84 | 96 | logger: log.With().Str("component", "workspace-view").Logger(), | |
| 85 | 97 | paneViews: make(map[entity.PaneID]*PaneView), | |
| 86 | 98 | } | |
| @@ -248,9 +260,10 @@ func (wv *WorkspaceView) SetWebViewWidget(paneID entity.PaneID, widget layout.Wi | |||
| 248 | 260 | return nil | |
| 249 | 261 | } | |
| 250 | 262 | ||
| 251 | - | // Widget returns the underlying container widget for embedding in the UI. | |
| 263 | + | // Widget returns the overlay widget for embedding in the UI. | |
| 264 | + | // The overlay wraps the pane container and allows mode borders to be displayed. | |
| 252 | 265 | func (wv *WorkspaceView) Widget() layout.Widget { | |
| 253 | - | return wv.container | |
| 266 | + | return wv.overlay | |
| 254 | 267 | } | |
| 255 | 268 | ||
| 256 | 269 | // Container returns the underlying BoxWidget for direct access. | |
| @@ -322,3 +335,60 @@ func (wv *WorkspaceView) SetRootWidgetDirect(widget layout.Widget) { | |||
| 322 | 335 | func (wv *WorkspaceView) Factory() layout.WidgetFactory { | |
| 323 | 336 | return wv.factory | |
| 324 | 337 | } | |
| 338 | + | ||
| 339 | + | // SetModeBorderOverlay attaches a mode border overlay widget. | |
| 340 | + | // The widget will be displayed on top of the pane container when modes are active. | |
| 341 | + | func (wv *WorkspaceView) SetModeBorderOverlay(widget layout.Widget) { | |
| 342 | + | wv.mu.Lock() | |
| 343 | + | defer wv.mu.Unlock() | |
| 344 | + | ||
| 345 | + | // Remove old overlay if present | |
| 346 | + | if wv.modeBorderWidget != nil && wv.overlay != nil { | |
| 347 | + | wv.overlay.RemoveOverlay(wv.modeBorderWidget) | |
| 348 | + | } | |
| 349 | + | ||
| 350 | + | wv.modeBorderWidget = widget | |
| 351 | + | ||
| 352 | + | // Add new overlay | |
| 353 | + | if widget != nil && wv.overlay != nil { | |
| 354 | + | wv.overlay.AddOverlay(widget) | |
| 355 | + | // Don't clip or measure the overlay - it should fill the entire area | |
| 356 | + | wv.overlay.SetClipOverlay(widget, false) | |
| 357 | + | wv.overlay.SetMeasureOverlay(widget, false) | |
| 358 | + | } | |
| 359 | + | } | |
| 360 | + | ||
| 361 | + | // GetPaneWidget returns the widget for a pane ID. | |
| 362 | + | // Implements focus.PaneGeometryProvider. | |
| 363 | + | func (wv *WorkspaceView) GetPaneWidget(paneID entity.PaneID) layout.Widget { | |
| 364 | + | wv.mu.RLock() | |
| 365 | + | defer wv.mu.RUnlock() | |
| 366 | + | ||
| 367 | + | pv, ok := wv.paneViews[paneID] | |
| 368 | + | if !ok || pv == nil { | |
| 369 | + | return nil | |
| 370 | + | } | |
| 371 | + | return pv.Widget() | |
| 372 | + | } | |
| 373 | + | ||
| 374 | + | // GetStackContainerWidget returns the stack container widget for a stacked pane. | |
| 375 | + | // Returns nil if the pane is not in a stack. | |
| 376 | + | // Implements focus.PaneGeometryProvider. | |
| 377 | + | func (wv *WorkspaceView) GetStackContainerWidget(paneID entity.PaneID) layout.Widget { | |
| 378 | + | if wv.treeRenderer == nil { | |
| 379 | + | return nil | |
| 380 | + | } | |
| 381 | + | ||
| 382 | + | stackedView := wv.treeRenderer.GetStackedViewForPane(string(paneID)) | |
| 383 | + | if stackedView == nil { | |
| 384 | + | return nil | |
| 385 | + | } | |
| 386 | + | ||
| 387 | + | return stackedView.Widget() | |
| 388 | + | } | |
| 389 | + | ||
| 390 | + | // ContainerWidget returns the container widget for relative positioning. | |
| 391 | + | // Implements focus.PaneGeometryProvider. | |
| 392 | + | func (wv *WorkspaceView) ContainerWidget() layout.Widget { | |
| 393 | + | return wv.container | |
| 394 | + | } | |
| @@ -24,9 +24,13 @@ type WorkspaceView struct { | |||||
| 24 | factory layout.WidgetFactory | 24 | factory layout.WidgetFactory | ||
| 25 | treeRenderer *layout.TreeRenderer | 25 | treeRenderer *layout.TreeRenderer | ||
| 26 | container layout.BoxWidget | 26 | container layout.BoxWidget | ||
| 27 | - | rootWidget layout.Widget // Current root widget for removal on rebuild | 27 | + | overlay layout.OverlayWidget // Wraps container for mode borders |
| 28 | + | rootWidget layout.Widget // Current root widget for removal on rebuild | |||
| 28 | logger zerolog.Logger | 29 | logger zerolog.Logger | ||
| 29 | 30 | ||||
| 31 | + | // Mode border overlay slot | |||
| 32 | + | modeBorderWidget layout.Widget | |||
| 33 | + | ||||
| 30 | workspace *entity.Workspace | 34 | workspace *entity.Workspace | ||
| 31 | paneViews map[entity.PaneID]*PaneView | 35 | paneViews map[entity.PaneID]*PaneView | ||
| 32 | activePaneID entity.PaneID | 36 | activePaneID entity.PaneID | ||
| @@ -78,9 +82,17 @@ func NewWorkspaceView(ctx context.Context, factory layout.WidgetFactory) *Worksp | |||||
| 78 | container.SetVexpand(true) | 82 | container.SetVexpand(true) | ||
| 79 | container.SetVisible(true) | 83 | container.SetVisible(true) | ||
| 80 | 84 | ||||
| 85 | + | // Wrap container in overlay for mode borders | |||
| 86 | + | overlay := factory.NewOverlay() | |||
| 87 | + | overlay.SetHexpand(true) | |||
| 88 | + | overlay.SetVexpand(true) | |||
| 89 | + | overlay.SetChild(container) | |||
| 90 | + | overlay.SetVisible(true) | |||
| 91 | + | ||||
| 81 | wv := &WorkspaceView{ | 92 | wv := &WorkspaceView{ | ||
| 82 | factory: factory, | 93 | factory: factory, | ||
| 83 | container: container, | 94 | container: container, | ||
| 95 | + | overlay: overlay, | |||
| 84 | logger: log.With().Str("component", "workspace-view").Logger(), | 96 | logger: log.With().Str("component", "workspace-view").Logger(), | ||
| 85 | paneViews: make(map[entity.PaneID]*PaneView), | 97 | paneViews: make(map[entity.PaneID]*PaneView), | ||
| 86 | } | 98 | } | ||
| @@ -248,9 +260,10 @@ func (wv *WorkspaceView) SetWebViewWidget(paneID entity.PaneID, widget layout.Wi | |||||
| 248 | return nil | 260 | return nil | ||
| 249 | } | 261 | } | ||
| 250 | 262 | ||||
| 251 | - | // Widget returns the underlying container widget for embedding in the UI. | 263 | + | // Widget returns the overlay widget for embedding in the UI. |
| 264 | + | // The overlay wraps the pane container and allows mode borders to be displayed. | |||
| 252 | func (wv *WorkspaceView) Widget() layout.Widget { | 265 | func (wv *WorkspaceView) Widget() layout.Widget { | ||
| 253 | - | return wv.container | 266 | + | return wv.overlay |
| 254 | } | 267 | } | ||
| 255 | 268 | ||||
| 256 | // Container returns the underlying BoxWidget for direct access. | 269 | // Container returns the underlying BoxWidget for direct access. | ||
| @@ -322,3 +335,60 @@ func (wv *WorkspaceView) SetRootWidgetDirect(widget layout.Widget) { | |||||
| 322 | func (wv *WorkspaceView) Factory() layout.WidgetFactory { | 335 | func (wv *WorkspaceView) Factory() layout.WidgetFactory { | ||
| 323 | return wv.factory | 336 | return wv.factory | ||
| 324 | } | 337 | } | ||
| 338 | + | ||||
| 339 | + | // SetModeBorderOverlay attaches a mode border overlay widget. | |||
| 340 | + | // The widget will be displayed on top of the pane container when modes are active. | |||
| 341 | + | func (wv *WorkspaceView) SetModeBorderOverlay(widget layout.Widget) { | |||
| 342 | + | wv.mu.Lock() | |||
| 343 | + | defer wv.mu.Unlock() | |||
| 344 | + | ||||
| 345 | + | // Remove old overlay if present | |||
| 346 | + | if wv.modeBorderWidget != nil && wv.overlay != nil { | |||
| 347 | + | wv.overlay.RemoveOverlay(wv.modeBorderWidget) | |||
| 348 | + | } | |||
| 349 | + | ||||
| 350 | + | wv.modeBorderWidget = widget | |||
| 351 | + | ||||
| 352 | + | // Add new overlay | |||
| 353 | + | if widget != nil && wv.overlay != nil { | |||
| 354 | + | wv.overlay.AddOverlay(widget) | |||
| 355 | + | // Don't clip or measure the overlay - it should fill the entire area | |||
| 356 | + | wv.overlay.SetClipOverlay(widget, false) | |||
| 357 | + | wv.overlay.SetMeasureOverlay(widget, false) | |||
| 358 | + | } | |||
| 359 | + | } | |||
| 360 | + | ||||
| 361 | + | // GetPaneWidget returns the widget for a pane ID. | |||
| 362 | + | // Implements focus.PaneGeometryProvider. | |||
| 363 | + | func (wv *WorkspaceView) GetPaneWidget(paneID entity.PaneID) layout.Widget { | |||
| 364 | + | wv.mu.RLock() | |||
| 365 | + | defer wv.mu.RUnlock() | |||
| 366 | + | ||||
| 367 | + | pv, ok := wv.paneViews[paneID] | |||
| 368 | + | if !ok || pv == nil { | |||
| 369 | + | return nil | |||
| 370 | + | } | |||
| 371 | + | return pv.Widget() | |||
| 372 | + | } | |||
| 373 | + | ||||
| 374 | + | // GetStackContainerWidget returns the stack container widget for a stacked pane. | |||
| 375 | + | // Returns nil if the pane is not in a stack. | |||
| 376 | + | // Implements focus.PaneGeometryProvider. | |||
| 377 | + | func (wv *WorkspaceView) GetStackContainerWidget(paneID entity.PaneID) layout.Widget { | |||
| 378 | + | if wv.treeRenderer == nil { | |||
| 379 | + | return nil | |||
| 380 | + | } | |||
| 381 | + | ||||
| 382 | + | stackedView := wv.treeRenderer.GetStackedViewForPane(string(paneID)) | |||
| 383 | + | if stackedView == nil { | |||
| 384 | + | return nil | |||
| 385 | + | } | |||
| 386 | + | ||||
| 387 | + | return stackedView.Widget() | |||
| 388 | + | } | |||
| 389 | + | ||||
| 390 | + | // ContainerWidget returns the container widget for relative positioning. | |||
| 391 | + | // Implements focus.PaneGeometryProvider. | |||
| 392 | + | func (wv *WorkspaceView) ContainerWidget() layout.Widget { | |||
| 393 | + | return wv.container | |||
| 394 | + | } | |||
A internal/ui/focus/borders.go +81 -0
| @@ -0,0 +1,81 @@ | |||
| 1 | + | package focus | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/dumber/internal/logging" | |
| 7 | + | "github.com/bnema/dumber/internal/ui/input" | |
| 8 | + | "github.com/bnema/dumber/internal/ui/layout" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // CSS classes for mode borders. | |
| 12 | + | const ( | |
| 13 | + | paneModeClass = "pane-mode-active" | |
| 14 | + | tabModeClass = "tab-mode-active" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | // BorderManager manages mode indicator borders on the workspace container. | |
| 18 | + | // It creates a transparent overlay box that displays colored borders when | |
| 19 | + | // the user enters tab mode or pane mode. | |
| 20 | + | type BorderManager struct { | |
| 21 | + | borderOverlay layout.BoxWidget | |
| 22 | + | currentMode input.Mode | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | // NewBorderManager creates a border manager. | |
| 26 | + | // It creates a border overlay widget that should be added to the workspace container. | |
| 27 | + | func NewBorderManager(factory layout.WidgetFactory) *BorderManager { | |
| 28 | + | // Create overlay box for mode borders | |
| 29 | + | borderBox := factory.NewBox(layout.OrientationVertical, 0) | |
| 30 | + | borderBox.SetCanFocus(false) | |
| 31 | + | borderBox.SetCanTarget(false) // Don't intercept pointer events | |
| 32 | + | borderBox.SetHexpand(true) | |
| 33 | + | borderBox.SetVexpand(true) | |
| 34 | + | borderBox.SetVisible(false) // Hidden in normal mode | |
| 35 | + | ||
| 36 | + | return &BorderManager{ | |
| 37 | + | borderOverlay: borderBox, | |
| 38 | + | currentMode: input.ModeNormal, | |
| 39 | + | } | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | // Widget returns the border overlay widget for adding to workspace overlay. | |
| 43 | + | func (bm *BorderManager) Widget() layout.Widget { | |
| 44 | + | return bm.borderOverlay | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | // OnModeChange updates border visibility based on mode. | |
| 48 | + | func (bm *BorderManager) OnModeChange(ctx context.Context, from, to input.Mode) { | |
| 49 | + | log := logging.FromContext(ctx) | |
| 50 | + | log.Debug(). | |
| 51 | + | Str("from", from.String()). | |
| 52 | + | Str("to", to.String()). | |
| 53 | + | Msg("border manager: mode changed") | |
| 54 | + | ||
| 55 | + | // Remove old mode class | |
| 56 | + | switch from { | |
| 57 | + | case input.ModePane: | |
| 58 | + | bm.borderOverlay.RemoveCssClass(paneModeClass) | |
| 59 | + | case input.ModeTab: | |
| 60 | + | bm.borderOverlay.RemoveCssClass(tabModeClass) | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | // Add new mode class and set visibility | |
| 64 | + | switch to { | |
| 65 | + | case input.ModePane: | |
| 66 | + | bm.borderOverlay.AddCssClass(paneModeClass) | |
| 67 | + | bm.borderOverlay.SetVisible(true) | |
| 68 | + | case input.ModeTab: | |
| 69 | + | bm.borderOverlay.AddCssClass(tabModeClass) | |
| 70 | + | bm.borderOverlay.SetVisible(true) | |
| 71 | + | case input.ModeNormal: | |
| 72 | + | bm.borderOverlay.SetVisible(false) | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | bm.currentMode = to | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | // CurrentMode returns the current input mode. | |
| 79 | + | func (bm *BorderManager) CurrentMode() input.Mode { | |
| 80 | + | return bm.currentMode | |
| 81 | + | } | |
| @@ -0,0 +1,81 @@ | |||||
| 1 | + | package focus | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/dumber/internal/logging" | |||
| 7 | + | "github.com/bnema/dumber/internal/ui/input" | |||
| 8 | + | "github.com/bnema/dumber/internal/ui/layout" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // CSS classes for mode borders. | |||
| 12 | + | const ( | |||
| 13 | + | paneModeClass = "pane-mode-active" | |||
| 14 | + | tabModeClass = "tab-mode-active" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | // BorderManager manages mode indicator borders on the workspace container. | |||
| 18 | + | // It creates a transparent overlay box that displays colored borders when | |||
| 19 | + | // the user enters tab mode or pane mode. | |||
| 20 | + | type BorderManager struct { | |||
| 21 | + | borderOverlay layout.BoxWidget | |||
| 22 | + | currentMode input.Mode | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | // NewBorderManager creates a border manager. | |||
| 26 | + | // It creates a border overlay widget that should be added to the workspace container. | |||
| 27 | + | func NewBorderManager(factory layout.WidgetFactory) *BorderManager { | |||
| 28 | + | // Create overlay box for mode borders | |||
| 29 | + | borderBox := factory.NewBox(layout.OrientationVertical, 0) | |||
| 30 | + | borderBox.SetCanFocus(false) | |||
| 31 | + | borderBox.SetCanTarget(false) // Don't intercept pointer events | |||
| 32 | + | borderBox.SetHexpand(true) | |||
| 33 | + | borderBox.SetVexpand(true) | |||
| 34 | + | borderBox.SetVisible(false) // Hidden in normal mode | |||
| 35 | + | ||||
| 36 | + | return &BorderManager{ | |||
| 37 | + | borderOverlay: borderBox, | |||
| 38 | + | currentMode: input.ModeNormal, | |||
| 39 | + | } | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | // Widget returns the border overlay widget for adding to workspace overlay. | |||
| 43 | + | func (bm *BorderManager) Widget() layout.Widget { | |||
| 44 | + | return bm.borderOverlay | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | // OnModeChange updates border visibility based on mode. | |||
| 48 | + | func (bm *BorderManager) OnModeChange(ctx context.Context, from, to input.Mode) { | |||
| 49 | + | log := logging.FromContext(ctx) | |||
| 50 | + | log.Debug(). | |||
| 51 | + | Str("from", from.String()). | |||
| 52 | + | Str("to", to.String()). | |||
| 53 | + | Msg("border manager: mode changed") | |||
| 54 | + | ||||
| 55 | + | // Remove old mode class | |||
| 56 | + | switch from { | |||
| 57 | + | case input.ModePane: | |||
| 58 | + | bm.borderOverlay.RemoveCssClass(paneModeClass) | |||
| 59 | + | case input.ModeTab: | |||
| 60 | + | bm.borderOverlay.RemoveCssClass(tabModeClass) | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | // Add new mode class and set visibility | |||
| 64 | + | switch to { | |||
| 65 | + | case input.ModePane: | |||
| 66 | + | bm.borderOverlay.AddCssClass(paneModeClass) | |||
| 67 | + | bm.borderOverlay.SetVisible(true) | |||
| 68 | + | case input.ModeTab: | |||
| 69 | + | bm.borderOverlay.AddCssClass(tabModeClass) | |||
| 70 | + | bm.borderOverlay.SetVisible(true) | |||
| 71 | + | case input.ModeNormal: | |||
| 72 | + | bm.borderOverlay.SetVisible(false) | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | bm.currentMode = to | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | // CurrentMode returns the current input mode. | |||
| 79 | + | func (bm *BorderManager) CurrentMode() input.Mode { | |||
| 80 | + | return bm.currentMode | |||
| 81 | + | } | |||
A internal/ui/focus/manager.go +210 -0
| @@ -0,0 +1,210 @@ | |||
| 1 | + | // Package focus provides focus state management and geometric navigation for panes. | |
| 2 | + | package focus | |
| 3 | + | ||
| 4 | + | import ( | |
| 5 | + | "context" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/dumber/internal/application/usecase" | |
| 8 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 9 | + | "github.com/bnema/dumber/internal/logging" | |
| 10 | + | "github.com/bnema/dumber/internal/ui/layout" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // PaneGeometryProvider provides geometry information for panes. | |
| 14 | + | // This interface allows the focus manager to work without direct component dependencies. | |
| 15 | + | type PaneGeometryProvider interface { | |
| 16 | + | // GetPaneIDs returns all pane IDs in the provider. | |
| 17 | + | GetPaneIDs() []entity.PaneID | |
| 18 | + | // GetPaneWidget returns the widget for a pane, or nil if not found. | |
| 19 | + | GetPaneWidget(paneID entity.PaneID) layout.Widget | |
| 20 | + | // GetStackContainerWidget returns the stack container widget for a stacked pane. | |
| 21 | + | // Returns nil if the pane is not in a stack. | |
| 22 | + | GetStackContainerWidget(paneID entity.PaneID) layout.Widget | |
| 23 | + | // ContainerWidget returns the container widget for relative positioning. | |
| 24 | + | ContainerWidget() layout.Widget | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | // Manager handles focus state and geometric navigation. | |
| 28 | + | type Manager struct { | |
| 29 | + | panesUC *usecase.ManagePanesUseCase | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | // NewManager creates a focus manager. | |
| 33 | + | func NewManager(panesUC *usecase.ManagePanesUseCase) *Manager { | |
| 34 | + | return &Manager{panesUC: panesUC} | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // CollectPaneRects gathers geometry from all visible panes. | |
| 38 | + | // For panes in stacks, uses the stack container's geometry since individual | |
| 39 | + | // collapsed panes have no allocated size. | |
| 40 | + | func (m *Manager) CollectPaneRects(provider PaneGeometryProvider) []entity.PaneRect { | |
| 41 | + | var rects []entity.PaneRect | |
| 42 | + | ||
| 43 | + | container := provider.ContainerWidget() | |
| 44 | + | ||
| 45 | + | for _, paneID := range provider.GetPaneIDs() { | |
| 46 | + | // First check if pane is in a stack - use stack container geometry | |
| 47 | + | var widget layout.Widget | |
| 48 | + | if stackWidget := provider.GetStackContainerWidget(paneID); stackWidget != nil { | |
| 49 | + | widget = stackWidget | |
| 50 | + | } else { | |
| 51 | + | widget = provider.GetPaneWidget(paneID) | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | if widget == nil || !widget.IsVisible() { | |
| 55 | + | continue | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | // Get position relative to container | |
| 59 | + | x, y, ok := widget.ComputePoint(container) | |
| 60 | + | if !ok { | |
| 61 | + | continue | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | w := widget.GetAllocatedWidth() | |
| 65 | + | h := widget.GetAllocatedHeight() | |
| 66 | + | ||
| 67 | + | // Skip widgets with no size (collapsed/hidden) | |
| 68 | + | if w <= 0 || h <= 0 { | |
| 69 | + | continue | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | rects = append(rects, entity.PaneRect{ | |
| 73 | + | PaneID: paneID, | |
| 74 | + | X: int(x), | |
| 75 | + | Y: int(y), | |
| 76 | + | W: w, | |
| 77 | + | H: h, | |
| 78 | + | }) | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | return rects | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | // NavigateGeometric performs geometric navigation and returns the target pane. | |
| 85 | + | // It handles stack navigation internally: when navigating up/down and the current | |
| 86 | + | // pane is in a stack, it will try to navigate within the stack first before | |
| 87 | + | // escaping to external panes. | |
| 88 | + | func (m *Manager) NavigateGeometric( | |
| 89 | + | ctx context.Context, | |
| 90 | + | ws *entity.Workspace, | |
| 91 | + | provider PaneGeometryProvider, | |
| 92 | + | direction usecase.NavigateDirection, | |
| 93 | + | ) (*entity.PaneNode, error) { | |
| 94 | + | log := logging.FromContext(ctx) | |
| 95 | + | ||
| 96 | + | if ws == nil { | |
| 97 | + | return nil, nil | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | // Get active pane node | |
| 101 | + | activeNode := ws.ActivePane() | |
| 102 | + | if activeNode == nil { | |
| 103 | + | log.Debug().Msg("no active pane for navigation") | |
| 104 | + | return nil, nil | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | // Check if we should navigate within a stack first | |
| 108 | + | if direction == usecase.NavUp || direction == usecase.NavDown { | |
| 109 | + | if activeNode.Parent != nil && activeNode.Parent.IsStacked { | |
| 110 | + | // Try to navigate within the stack | |
| 111 | + | stackNode := activeNode.Parent | |
| 112 | + | canNavigate, newPaneID := m.navigateWithinStack(stackNode, activeNode, direction) | |
| 113 | + | if canNavigate { | |
| 114 | + | ws.ActivePaneID = newPaneID | |
| 115 | + | return ws.FindPane(newPaneID), nil | |
| 116 | + | } | |
| 117 | + | // At stack boundary - fall through to geometric navigation | |
| 118 | + | log.Debug().Msg("at stack boundary, using geometric navigation") | |
| 119 | + | } | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | // Collect geometry from visible panes | |
| 123 | + | rects := m.CollectPaneRects(provider) | |
| 124 | + | if len(rects) == 0 { | |
| 125 | + | return nil, nil | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | // Run geometric algorithm | |
| 129 | + | output, err := m.panesUC.NavigateFocusGeometric(ctx, usecase.GeometricNavigationInput{ | |
| 130 | + | ActivePaneID: ws.ActivePaneID, | |
| 131 | + | PaneRects: rects, | |
| 132 | + | Direction: direction, | |
| 133 | + | }) | |
| 134 | + | if err != nil { | |
| 135 | + | return nil, err | |
| 136 | + | } | |
| 137 | + | if !output.Found { | |
| 138 | + | return nil, nil | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | // Update workspace | |
| 142 | + | ws.ActivePaneID = output.TargetPaneID | |
| 143 | + | ||
| 144 | + | // Return target node | |
| 145 | + | return ws.FindPane(output.TargetPaneID), nil | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | // navigateWithinStack tries to navigate within a stack. | |
| 149 | + | // Returns (canNavigate, newPaneID). If canNavigate is false, the pane is at a | |
| 150 | + | // boundary and navigation should escape the stack. | |
| 151 | + | func (m *Manager) navigateWithinStack(stackNode, currentNode *entity.PaneNode, direction usecase.NavigateDirection) (bool, entity.PaneID) { | |
| 152 | + | if !stackNode.IsStacked || len(stackNode.Children) == 0 { | |
| 153 | + | return false, "" | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | // Find current index in stack | |
| 157 | + | currentIdx := -1 | |
| 158 | + | for i, child := range stackNode.Children { | |
| 159 | + | if child == currentNode { | |
| 160 | + | currentIdx = i | |
| 161 | + | break | |
| 162 | + | } | |
| 163 | + | } | |
| 164 | + | if currentIdx < 0 { | |
| 165 | + | return false, "" | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | var newIdx int | |
| 169 | + | switch direction { | |
| 170 | + | case usecase.NavUp: | |
| 171 | + | newIdx = currentIdx - 1 | |
| 172 | + | if newIdx < 0 { | |
| 173 | + | // At top boundary | |
| 174 | + | return false, "" | |
| 175 | + | } | |
| 176 | + | case usecase.NavDown: | |
| 177 | + | newIdx = currentIdx + 1 | |
| 178 | + | if newIdx >= len(stackNode.Children) { | |
| 179 | + | // At bottom boundary | |
| 180 | + | return false, "" | |
| 181 | + | } | |
| 182 | + | default: | |
| 183 | + | return false, "" | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | // Update stack active index | |
| 187 | + | stackNode.ActiveStackIndex = newIdx | |
| 188 | + | ||
| 189 | + | // Get the new pane | |
| 190 | + | targetNode := stackNode.Children[newIdx] | |
| 191 | + | if targetNode.Pane != nil { | |
| 192 | + | return true, targetNode.Pane.ID | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | // If the child is not a leaf, find the first leaf | |
| 196 | + | var leafPaneID entity.PaneID | |
| 197 | + | targetNode.Walk(func(n *entity.PaneNode) bool { | |
| 198 | + | if n.IsLeaf() && n.Pane != nil { | |
| 199 | + | leafPaneID = n.Pane.ID | |
| 200 | + | return false | |
| 201 | + | } | |
| 202 | + | return true | |
| 203 | + | }) | |
| 204 | + | ||
| 205 | + | if leafPaneID != "" { | |
| 206 | + | return true, leafPaneID | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | return false, "" | |
| 210 | + | } | |
| @@ -0,0 +1,210 @@ | |||||
| 1 | + | // Package focus provides focus state management and geometric navigation for panes. | |||
| 2 | + | package focus | |||
| 3 | + | ||||
| 4 | + | import ( | |||
| 5 | + | "context" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/dumber/internal/application/usecase" | |||
| 8 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 9 | + | "github.com/bnema/dumber/internal/logging" | |||
| 10 | + | "github.com/bnema/dumber/internal/ui/layout" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // PaneGeometryProvider provides geometry information for panes. | |||
| 14 | + | // This interface allows the focus manager to work without direct component dependencies. | |||
| 15 | + | type PaneGeometryProvider interface { | |||
| 16 | + | // GetPaneIDs returns all pane IDs in the provider. | |||
| 17 | + | GetPaneIDs() []entity.PaneID | |||
| 18 | + | // GetPaneWidget returns the widget for a pane, or nil if not found. | |||
| 19 | + | GetPaneWidget(paneID entity.PaneID) layout.Widget | |||
| 20 | + | // GetStackContainerWidget returns the stack container widget for a stacked pane. | |||
| 21 | + | // Returns nil if the pane is not in a stack. | |||
| 22 | + | GetStackContainerWidget(paneID entity.PaneID) layout.Widget | |||
| 23 | + | // ContainerWidget returns the container widget for relative positioning. | |||
| 24 | + | ContainerWidget() layout.Widget | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | // Manager handles focus state and geometric navigation. | |||
| 28 | + | type Manager struct { | |||
| 29 | + | panesUC *usecase.ManagePanesUseCase | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | // NewManager creates a focus manager. | |||
| 33 | + | func NewManager(panesUC *usecase.ManagePanesUseCase) *Manager { | |||
| 34 | + | return &Manager{panesUC: panesUC} | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | // CollectPaneRects gathers geometry from all visible panes. | |||
| 38 | + | // For panes in stacks, uses the stack container's geometry since individual | |||
| 39 | + | // collapsed panes have no allocated size. | |||
| 40 | + | func (m *Manager) CollectPaneRects(provider PaneGeometryProvider) []entity.PaneRect { | |||
| 41 | + | var rects []entity.PaneRect | |||
| 42 | + | ||||
| 43 | + | container := provider.ContainerWidget() | |||
| 44 | + | ||||
| 45 | + | for _, paneID := range provider.GetPaneIDs() { | |||
| 46 | + | // First check if pane is in a stack - use stack container geometry | |||
| 47 | + | var widget layout.Widget | |||
| 48 | + | if stackWidget := provider.GetStackContainerWidget(paneID); stackWidget != nil { | |||
| 49 | + | widget = stackWidget | |||
| 50 | + | } else { | |||
| 51 | + | widget = provider.GetPaneWidget(paneID) | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | if widget == nil || !widget.IsVisible() { | |||
| 55 | + | continue | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | // Get position relative to container | |||
| 59 | + | x, y, ok := widget.ComputePoint(container) | |||
| 60 | + | if !ok { | |||
| 61 | + | continue | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | w := widget.GetAllocatedWidth() | |||
| 65 | + | h := widget.GetAllocatedHeight() | |||
| 66 | + | ||||
| 67 | + | // Skip widgets with no size (collapsed/hidden) | |||
| 68 | + | if w <= 0 || h <= 0 { | |||
| 69 | + | continue | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | rects = append(rects, entity.PaneRect{ | |||
| 73 | + | PaneID: paneID, | |||
| 74 | + | X: int(x), | |||
| 75 | + | Y: int(y), | |||
| 76 | + | W: w, | |||
| 77 | + | H: h, | |||
| 78 | + | }) | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | return rects | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | // NavigateGeometric performs geometric navigation and returns the target pane. | |||
| 85 | + | // It handles stack navigation internally: when navigating up/down and the current | |||
| 86 | + | // pane is in a stack, it will try to navigate within the stack first before | |||
| 87 | + | // escaping to external panes. | |||
| 88 | + | func (m *Manager) NavigateGeometric( | |||
| 89 | + | ctx context.Context, | |||
| 90 | + | ws *entity.Workspace, | |||
| 91 | + | provider PaneGeometryProvider, | |||
| 92 | + | direction usecase.NavigateDirection, | |||
| 93 | + | ) (*entity.PaneNode, error) { | |||
| 94 | + | log := logging.FromContext(ctx) | |||
| 95 | + | ||||
| 96 | + | if ws == nil { | |||
| 97 | + | return nil, nil | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | // Get active pane node | |||
| 101 | + | activeNode := ws.ActivePane() | |||
| 102 | + | if activeNode == nil { | |||
| 103 | + | log.Debug().Msg("no active pane for navigation") | |||
| 104 | + | return nil, nil | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | // Check if we should navigate within a stack first | |||
| 108 | + | if direction == usecase.NavUp || direction == usecase.NavDown { | |||
| 109 | + | if activeNode.Parent != nil && activeNode.Parent.IsStacked { | |||
| 110 | + | // Try to navigate within the stack | |||
| 111 | + | stackNode := activeNode.Parent | |||
| 112 | + | canNavigate, newPaneID := m.navigateWithinStack(stackNode, activeNode, direction) | |||
| 113 | + | if canNavigate { | |||
| 114 | + | ws.ActivePaneID = newPaneID | |||
| 115 | + | return ws.FindPane(newPaneID), nil | |||
| 116 | + | } | |||
| 117 | + | // At stack boundary - fall through to geometric navigation | |||
| 118 | + | log.Debug().Msg("at stack boundary, using geometric navigation") | |||
| 119 | + | } | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | // Collect geometry from visible panes | |||
| 123 | + | rects := m.CollectPaneRects(provider) | |||
| 124 | + | if len(rects) == 0 { | |||
| 125 | + | return nil, nil | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | // Run geometric algorithm | |||
| 129 | + | output, err := m.panesUC.NavigateFocusGeometric(ctx, usecase.GeometricNavigationInput{ | |||
| 130 | + | ActivePaneID: ws.ActivePaneID, | |||
| 131 | + | PaneRects: rects, | |||
| 132 | + | Direction: direction, | |||
| 133 | + | }) | |||
| 134 | + | if err != nil { | |||
| 135 | + | return nil, err | |||
| 136 | + | } | |||
| 137 | + | if !output.Found { | |||
| 138 | + | return nil, nil | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | // Update workspace | |||
| 142 | + | ws.ActivePaneID = output.TargetPaneID | |||
| 143 | + | ||||
| 144 | + | // Return target node | |||
| 145 | + | return ws.FindPane(output.TargetPaneID), nil | |||
| 146 | + | } | |||
| 147 | + | ||||
| 148 | + | // navigateWithinStack tries to navigate within a stack. | |||
| 149 | + | // Returns (canNavigate, newPaneID). If canNavigate is false, the pane is at a | |||
| 150 | + | // boundary and navigation should escape the stack. | |||
| 151 | + | func (m *Manager) navigateWithinStack(stackNode, currentNode *entity.PaneNode, direction usecase.NavigateDirection) (bool, entity.PaneID) { | |||
| 152 | + | if !stackNode.IsStacked || len(stackNode.Children) == 0 { | |||
| 153 | + | return false, "" | |||
| 154 | + | } | |||
| 155 | + | ||||
| 156 | + | // Find current index in stack | |||
| 157 | + | currentIdx := -1 | |||
| 158 | + | for i, child := range stackNode.Children { | |||
| 159 | + | if child == currentNode { | |||
| 160 | + | currentIdx = i | |||
| 161 | + | break | |||
| 162 | + | } | |||
| 163 | + | } | |||
| 164 | + | if currentIdx < 0 { | |||
| 165 | + | return false, "" | |||
| 166 | + | } | |||
| 167 | + | ||||
| 168 | + | var newIdx int | |||
| 169 | + | switch direction { | |||
| 170 | + | case usecase.NavUp: | |||
| 171 | + | newIdx = currentIdx - 1 | |||
| 172 | + | if newIdx < 0 { | |||
| 173 | + | // At top boundary | |||
| 174 | + | return false, "" | |||
| 175 | + | } | |||
| 176 | + | case usecase.NavDown: | |||
| 177 | + | newIdx = currentIdx + 1 | |||
| 178 | + | if newIdx >= len(stackNode.Children) { | |||
| 179 | + | // At bottom boundary | |||
| 180 | + | return false, "" | |||
| 181 | + | } | |||
| 182 | + | default: | |||
| 183 | + | return false, "" | |||
| 184 | + | } | |||
| 185 | + | ||||
| 186 | + | // Update stack active index | |||
| 187 | + | stackNode.ActiveStackIndex = newIdx | |||
| 188 | + | ||||
| 189 | + | // Get the new pane | |||
| 190 | + | targetNode := stackNode.Children[newIdx] | |||
| 191 | + | if targetNode.Pane != nil { | |||
| 192 | + | return true, targetNode.Pane.ID | |||
| 193 | + | } | |||
| 194 | + | ||||
| 195 | + | // If the child is not a leaf, find the first leaf | |||
| 196 | + | var leafPaneID entity.PaneID | |||
| 197 | + | targetNode.Walk(func(n *entity.PaneNode) bool { | |||
| 198 | + | if n.IsLeaf() && n.Pane != nil { | |||
| 199 | + | leafPaneID = n.Pane.ID | |||
| 200 | + | return false | |||
| 201 | + | } | |||
| 202 | + | return true | |||
| 203 | + | }) | |||
| 204 | + | ||||
| 205 | + | if leafPaneID != "" { | |||
| 206 | + | return true, leafPaneID | |||
| 207 | + | } | |||
| 208 | + | ||||
| 209 | + | return false, "" | |||
| 210 | + | } | |||
M internal/ui/input/shortcuts.go +10 -0
| @@ -172,6 +172,16 @@ func (s *ShortcutSet) buildGlobalShortcuts(ctx context.Context, cfg *config.Work | |||
| 172 | 172 | s.Global[KeyBinding{uint(gdk.KEY_0), ModCtrl}] = ActionZoomReset | |
| 173 | 173 | s.Global[KeyBinding{uint(gdk.KEY_q), ModCtrl}] = ActionQuit | |
| 174 | 174 | s.Global[KeyBinding{uint(gdk.KEY_F11), ModNone}] = ActionToggleFullscreen | |
| 175 | + | ||
| 176 | + | // Pane navigation (Alt+HJKL like vim) | |
| 177 | + | s.Global[KeyBinding{uint(gdk.KEY_h), ModAlt}] = ActionFocusLeft | |
| 178 | + | s.Global[KeyBinding{uint(gdk.KEY_l), ModAlt}] = ActionFocusRight | |
| 179 | + | s.Global[KeyBinding{uint(gdk.KEY_k), ModAlt}] = ActionFocusUp | |
| 180 | + | s.Global[KeyBinding{uint(gdk.KEY_j), ModAlt}] = ActionFocusDown | |
| 181 | + | ||
| 182 | + | // Also support Alt+Arrow keys for pane navigation (except Left/Right used for back/forward) | |
| 183 | + | s.Global[KeyBinding{uint(gdk.KEY_Up), ModAlt}] = ActionFocusUp | |
| 184 | + | s.Global[KeyBinding{uint(gdk.KEY_Down), ModAlt}] = ActionFocusDown | |
| 175 | 185 | } | |
| 176 | 186 | ||
| 177 | 187 | // buildTabModeShortcuts populates tab mode shortcuts from config. | |
| @@ -172,6 +172,16 @@ func (s *ShortcutSet) buildGlobalShortcuts(ctx context.Context, cfg *config.Work | |||||
| 172 | s.Global[KeyBinding{uint(gdk.KEY_0), ModCtrl}] = ActionZoomReset | 172 | s.Global[KeyBinding{uint(gdk.KEY_0), ModCtrl}] = ActionZoomReset | ||
| 173 | s.Global[KeyBinding{uint(gdk.KEY_q), ModCtrl}] = ActionQuit | 173 | s.Global[KeyBinding{uint(gdk.KEY_q), ModCtrl}] = ActionQuit | ||
| 174 | s.Global[KeyBinding{uint(gdk.KEY_F11), ModNone}] = ActionToggleFullscreen | 174 | s.Global[KeyBinding{uint(gdk.KEY_F11), ModNone}] = ActionToggleFullscreen | ||
| 175 | + | ||||
| 176 | + | // Pane navigation (Alt+HJKL like vim) | |||
| 177 | + | s.Global[KeyBinding{uint(gdk.KEY_h), ModAlt}] = ActionFocusLeft | |||
| 178 | + | s.Global[KeyBinding{uint(gdk.KEY_l), ModAlt}] = ActionFocusRight | |||
| 179 | + | s.Global[KeyBinding{uint(gdk.KEY_k), ModAlt}] = ActionFocusUp | |||
| 180 | + | s.Global[KeyBinding{uint(gdk.KEY_j), ModAlt}] = ActionFocusDown | |||
| 181 | + | ||||
| 182 | + | // Also support Alt+Arrow keys for pane navigation (except Left/Right used for back/forward) | |||
| 183 | + | s.Global[KeyBinding{uint(gdk.KEY_Up), ModAlt}] = ActionFocusUp | |||
| 184 | + | s.Global[KeyBinding{uint(gdk.KEY_Down), ModAlt}] = ActionFocusDown | |||
| 175 | } | 185 | } | ||
| 176 | 186 | ||||
| 177 | // buildTabModeShortcuts populates tab mode shortcuts from config. | 187 | // buildTabModeShortcuts populates tab mode shortcuts from config. | ||
M internal/ui/layout/gtk_widgets.go +131 -0
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package layout | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "github.com/jwijenbergh/puregotk/v4/graphene" | |
| 4 | 5 | "github.com/jwijenbergh/puregotk/v4/gtk" | |
| 5 | 6 | "github.com/jwijenbergh/puregotk/v4/pango" | |
| 6 | 7 | ) | |
| @@ -52,6 +53,25 @@ func (w *gtkWidget) GetParent() Widget { | |||
| 52 | 53 | return >kWidget{inner: parent} | |
| 53 | 54 | } | |
| 54 | 55 | ||
| 56 | + | func (w *gtkWidget) GetAllocatedWidth() int { return w.inner.GetAllocatedWidth() } | |
| 57 | + | func (w *gtkWidget) GetAllocatedHeight() int { return w.inner.GetAllocatedHeight() } | |
| 58 | + | ||
| 59 | + | func (w *gtkWidget) ComputePoint(target Widget) (x, y float64, ok bool) { | |
| 60 | + | srcPoint := &graphene.Point{X: 0, Y: 0} | |
| 61 | + | outPoint := &graphene.Point{} | |
| 62 | + | ||
| 63 | + | var targetGtk *gtk.Widget | |
| 64 | + | if target != nil { | |
| 65 | + | targetGtk = target.GtkWidget() | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | ok = w.inner.ComputePoint(targetGtk, srcPoint, outPoint) | |
| 69 | + | if !ok { | |
| 70 | + | return 0, 0, false | |
| 71 | + | } | |
| 72 | + | return float64(outPoint.X), float64(outPoint.Y), true | |
| 73 | + | } | |
| 74 | + | ||
| 55 | 75 | // gtkPaned wraps gtk.Paned to implement PanedWidget. | |
| 56 | 76 | type gtkPaned struct { | |
| 57 | 77 | inner *gtk.Paned | |
| @@ -149,6 +169,22 @@ func (p *gtkPaned) AddTickCallback(callback func() bool) uint { | |||
| 149 | 169 | func (p *gtkPaned) GetAllocatedWidth() int { return p.inner.GetAllocatedWidth() } | |
| 150 | 170 | func (p *gtkPaned) GetAllocatedHeight() int { return p.inner.GetAllocatedHeight() } | |
| 151 | 171 | ||
| 172 | + | func (p *gtkPaned) ComputePoint(target Widget) (x, y float64, ok bool) { | |
| 173 | + | srcPoint := &graphene.Point{X: 0, Y: 0} | |
| 174 | + | outPoint := &graphene.Point{} | |
| 175 | + | ||
| 176 | + | var targetGtk *gtk.Widget | |
| 177 | + | if target != nil { | |
| 178 | + | targetGtk = target.GtkWidget() | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | ok = p.inner.ComputePoint(targetGtk, srcPoint, outPoint) | |
| 182 | + | if !ok { | |
| 183 | + | return 0, 0, false | |
| 184 | + | } | |
| 185 | + | return float64(outPoint.X), float64(outPoint.Y), true | |
| 186 | + | } | |
| 187 | + | ||
| 152 | 188 | // gtkBox wraps gtk.Box to implement BoxWidget. | |
| 153 | 189 | type gtkBox struct { | |
| 154 | 190 | inner *gtk.Box | |
| @@ -234,6 +270,25 @@ func (b *gtkBox) GetSpacing() int { return b.inner.GetSpacing() } | |||
| 234 | 270 | func (b *gtkBox) SetOrientation(o Orientation) { b.inner.SetOrientation(o) } | |
| 235 | 271 | func (b *gtkBox) GetOrientation() Orientation { return b.inner.GetOrientation() } | |
| 236 | 272 | ||
| 273 | + | func (b *gtkBox) GetAllocatedWidth() int { return b.inner.GetAllocatedWidth() } | |
| 274 | + | func (b *gtkBox) GetAllocatedHeight() int { return b.inner.GetAllocatedHeight() } | |
| 275 | + | ||
| 276 | + | func (b *gtkBox) ComputePoint(target Widget) (x, y float64, ok bool) { | |
| 277 | + | srcPoint := &graphene.Point{X: 0, Y: 0} | |
| 278 | + | outPoint := &graphene.Point{} | |
| 279 | + | ||
| 280 | + | var targetGtk *gtk.Widget | |
| 281 | + | if target != nil { | |
| 282 | + | targetGtk = target.GtkWidget() | |
| 283 | + | } | |
| 284 | + | ||
| 285 | + | ok = b.inner.ComputePoint(targetGtk, srcPoint, outPoint) | |
| 286 | + | if !ok { | |
| 287 | + | return 0, 0, false | |
| 288 | + | } | |
| 289 | + | return float64(outPoint.X), float64(outPoint.Y), true | |
| 290 | + | } | |
| 291 | + | ||
| 237 | 292 | // gtkOverlay wraps gtk.Overlay to implement OverlayWidget. | |
| 238 | 293 | type gtkOverlay struct { | |
| 239 | 294 | inner *gtk.Overlay | |
| @@ -327,6 +382,25 @@ func (o *gtkOverlay) GetMeasureOverlay(overlay Widget) bool { | |||
| 327 | 382 | return o.inner.GetMeasureOverlay(overlay.GtkWidget()) | |
| 328 | 383 | } | |
| 329 | 384 | ||
| 385 | + | func (o *gtkOverlay) GetAllocatedWidth() int { return o.inner.GetAllocatedWidth() } | |
| 386 | + | func (o *gtkOverlay) GetAllocatedHeight() int { return o.inner.GetAllocatedHeight() } | |
| 387 | + | ||
| 388 | + | func (o *gtkOverlay) ComputePoint(target Widget) (x, y float64, ok bool) { | |
| 389 | + | srcPoint := &graphene.Point{X: 0, Y: 0} | |
| 390 | + | outPoint := &graphene.Point{} | |
| 391 | + | ||
| 392 | + | var targetGtk *gtk.Widget | |
| 393 | + | if target != nil { | |
| 394 | + | targetGtk = target.GtkWidget() | |
| 395 | + | } | |
| 396 | + | ||
| 397 | + | ok = o.inner.ComputePoint(targetGtk, srcPoint, outPoint) | |
| 398 | + | if !ok { | |
| 399 | + | return 0, 0, false | |
| 400 | + | } | |
| 401 | + | return float64(outPoint.X), float64(outPoint.Y), true | |
| 402 | + | } | |
| 403 | + | ||
| 330 | 404 | // gtkLabel wraps gtk.Label to implement LabelWidget. | |
| 331 | 405 | type gtkLabel struct { | |
| 332 | 406 | inner *gtk.Label | |
| @@ -369,6 +443,25 @@ func (l *gtkLabel) SetEllipsize(mode EllipsizeMode) { l.inner.SetEllipsize(pango | |||
| 369 | 443 | func (l *gtkLabel) SetMaxWidthChars(n int) { l.inner.SetMaxWidthChars(n) } | |
| 370 | 444 | func (l *gtkLabel) SetXalign(x float32) { l.inner.SetXalign(x) } | |
| 371 | 445 | ||
| 446 | + | func (l *gtkLabel) GetAllocatedWidth() int { return l.inner.GetAllocatedWidth() } | |
| 447 | + | func (l *gtkLabel) GetAllocatedHeight() int { return l.inner.GetAllocatedHeight() } | |
| 448 | + | ||
| 449 | + | func (l *gtkLabel) ComputePoint(target Widget) (x, y float64, ok bool) { | |
| 450 | + | srcPoint := &graphene.Point{X: 0, Y: 0} | |
| 451 | + | outPoint := &graphene.Point{} | |
| 452 | + | ||
| 453 | + | var targetGtk *gtk.Widget | |
| 454 | + | if target != nil { | |
| 455 | + | targetGtk = target.GtkWidget() | |
| 456 | + | } | |
| 457 | + | ||
| 458 | + | ok = l.inner.ComputePoint(targetGtk, srcPoint, outPoint) | |
| 459 | + | if !ok { | |
| 460 | + | return 0, 0, false | |
| 461 | + | } | |
| 462 | + | return float64(outPoint.X), float64(outPoint.Y), true | |
| 463 | + | } | |
| 464 | + | ||
| 372 | 465 | // gtkButton wraps gtk.Button to implement ButtonWidget. | |
| 373 | 466 | type gtkButton struct { | |
| 374 | 467 | inner *gtk.Button | |
| @@ -430,6 +523,25 @@ func (btn *gtkButton) ConnectClicked(callback func()) uint32 { | |||
| 430 | 523 | return btn.inner.ConnectClicked(&cb) | |
| 431 | 524 | } | |
| 432 | 525 | ||
| 526 | + | func (btn *gtkButton) GetAllocatedWidth() int { return btn.inner.GetAllocatedWidth() } | |
| 527 | + | func (btn *gtkButton) GetAllocatedHeight() int { return btn.inner.GetAllocatedHeight() } | |
| 528 | + | ||
| 529 | + | func (btn *gtkButton) ComputePoint(target Widget) (x, y float64, ok bool) { | |
| 530 | + | srcPoint := &graphene.Point{X: 0, Y: 0} | |
| 531 | + | outPoint := &graphene.Point{} | |
| 532 | + | ||
| 533 | + | var targetGtk *gtk.Widget | |
| 534 | + | if target != nil { | |
| 535 | + | targetGtk = target.GtkWidget() | |
| 536 | + | } | |
| 537 | + | ||
| 538 | + | ok = btn.inner.ComputePoint(targetGtk, srcPoint, outPoint) | |
| 539 | + | if !ok { | |
| 540 | + | return 0, 0, false | |
| 541 | + | } | |
| 542 | + | return float64(outPoint.X), float64(outPoint.Y), true | |
| 543 | + | } | |
| 544 | + | ||
| 433 | 545 | // gtkImage wraps gtk.Image to implement ImageWidget. | |
| 434 | 546 | type gtkImage struct { | |
| 435 | 547 | inner *gtk.Image | |
| @@ -470,6 +582,25 @@ func (img *gtkImage) SetFromFile(filename string) { img.inner.SetFromFile(&filen | |||
| 470 | 582 | func (img *gtkImage) SetPixelSize(size int) { img.inner.SetPixelSize(size) } | |
| 471 | 583 | func (img *gtkImage) Clear() { img.inner.Clear() } | |
| 472 | 584 | ||
| 585 | + | func (img *gtkImage) GetAllocatedWidth() int { return img.inner.GetAllocatedWidth() } | |
| 586 | + | func (img *gtkImage) GetAllocatedHeight() int { return img.inner.GetAllocatedHeight() } | |
| 587 | + | ||
| 588 | + | func (img *gtkImage) ComputePoint(target Widget) (x, y float64, ok bool) { | |
| 589 | + | srcPoint := &graphene.Point{X: 0, Y: 0} | |
| 590 | + | outPoint := &graphene.Point{} | |
| 591 | + | ||
| 592 | + | var targetGtk *gtk.Widget | |
| 593 | + | if target != nil { | |
| 594 | + | targetGtk = target.GtkWidget() | |
| 595 | + | } | |
| 596 | + | ||
| 597 | + | ok = img.inner.ComputePoint(targetGtk, srcPoint, outPoint) | |
| 598 | + | if !ok { | |
| 599 | + | return 0, 0, false | |
| 600 | + | } | |
| 601 | + | return float64(outPoint.X), float64(outPoint.Y), true | |
| 602 | + | } | |
| 603 | + | ||
| 473 | 604 | // GtkWidgetFactory creates real GTK widgets. | |
| 474 | 605 | type GtkWidgetFactory struct{} | |
| 475 | 606 | ||
| @@ -1,6 +1,7 @@ | |||||
| 1 | package layout | 1 | package layout | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "github.com/jwijenbergh/puregotk/v4/graphene" | |||
| 4 | "github.com/jwijenbergh/puregotk/v4/gtk" | 5 | "github.com/jwijenbergh/puregotk/v4/gtk" | ||
| 5 | "github.com/jwijenbergh/puregotk/v4/pango" | 6 | "github.com/jwijenbergh/puregotk/v4/pango" | ||
| 6 | ) | 7 | ) | ||
| @@ -52,6 +53,25 @@ func (w *gtkWidget) GetParent() Widget { | |||||
| 52 | return >kWidget{inner: parent} | 53 | return >kWidget{inner: parent} | ||
| 53 | } | 54 | } | ||
| 54 | 55 | ||||
| 56 | + | func (w *gtkWidget) GetAllocatedWidth() int { return w.inner.GetAllocatedWidth() } | |||
| 57 | + | func (w *gtkWidget) GetAllocatedHeight() int { return w.inner.GetAllocatedHeight() } | |||
| 58 | + | ||||
| 59 | + | func (w *gtkWidget) ComputePoint(target Widget) (x, y float64, ok bool) { | |||
| 60 | + | srcPoint := &graphene.Point{X: 0, Y: 0} | |||
| 61 | + | outPoint := &graphene.Point{} | |||
| 62 | + | ||||
| 63 | + | var targetGtk *gtk.Widget | |||
| 64 | + | if target != nil { | |||
| 65 | + | targetGtk = target.GtkWidget() | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | ok = w.inner.ComputePoint(targetGtk, srcPoint, outPoint) | |||
| 69 | + | if !ok { | |||
| 70 | + | return 0, 0, false | |||
| 71 | + | } | |||
| 72 | + | return float64(outPoint.X), float64(outPoint.Y), true | |||
| 73 | + | } | |||
| 74 | + | ||||
| 55 | // gtkPaned wraps gtk.Paned to implement PanedWidget. | 75 | // gtkPaned wraps gtk.Paned to implement PanedWidget. | ||
| 56 | type gtkPaned struct { | 76 | type gtkPaned struct { | ||
| 57 | inner *gtk.Paned | 77 | inner *gtk.Paned | ||
| @@ -149,6 +169,22 @@ func (p *gtkPaned) AddTickCallback(callback func() bool) uint { | |||||
| 149 | func (p *gtkPaned) GetAllocatedWidth() int { return p.inner.GetAllocatedWidth() } | 169 | func (p *gtkPaned) GetAllocatedWidth() int { return p.inner.GetAllocatedWidth() } | ||
| 150 | func (p *gtkPaned) GetAllocatedHeight() int { return p.inner.GetAllocatedHeight() } | 170 | func (p *gtkPaned) GetAllocatedHeight() int { return p.inner.GetAllocatedHeight() } | ||
| 151 | 171 | ||||
| 172 | + | func (p *gtkPaned) ComputePoint(target Widget) (x, y float64, ok bool) { | |||
| 173 | + | srcPoint := &graphene.Point{X: 0, Y: 0} | |||
| 174 | + | outPoint := &graphene.Point{} | |||
| 175 | + | ||||
| 176 | + | var targetGtk *gtk.Widget | |||
| 177 | + | if target != nil { | |||
| 178 | + | targetGtk = target.GtkWidget() | |||
| 179 | + | } | |||
| 180 | + | ||||
| 181 | + | ok = p.inner.ComputePoint(targetGtk, srcPoint, outPoint) | |||
| 182 | + | if !ok { | |||
| 183 | + | return 0, 0, false | |||
| 184 | + | } | |||
| 185 | + | return float64(outPoint.X), float64(outPoint.Y), true | |||
| 186 | + | } | |||
| 187 | + | ||||
| 152 | // gtkBox wraps gtk.Box to implement BoxWidget. | 188 | // gtkBox wraps gtk.Box to implement BoxWidget. | ||
| 153 | type gtkBox struct { | 189 | type gtkBox struct { | ||
| 154 | inner *gtk.Box | 190 | inner *gtk.Box | ||
| @@ -234,6 +270,25 @@ func (b *gtkBox) GetSpacing() int { return b.inner.GetSpacing() } | |||||
| 234 | func (b *gtkBox) SetOrientation(o Orientation) { b.inner.SetOrientation(o) } | 270 | func (b *gtkBox) SetOrientation(o Orientation) { b.inner.SetOrientation(o) } | ||
| 235 | func (b *gtkBox) GetOrientation() Orientation { return b.inner.GetOrientation() } | 271 | func (b *gtkBox) GetOrientation() Orientation { return b.inner.GetOrientation() } | ||
| 236 | 272 | ||||
| 273 | + | func (b *gtkBox) GetAllocatedWidth() int { return b.inner.GetAllocatedWidth() } | |||
| 274 | + | func (b *gtkBox) GetAllocatedHeight() int { return b.inner.GetAllocatedHeight() } | |||
| 275 | + | ||||
| 276 | + | func (b *gtkBox) ComputePoint(target Widget) (x, y float64, ok bool) { | |||
| 277 | + | srcPoint := &graphene.Point{X: 0, Y: 0} | |||
| 278 | + | outPoint := &graphene.Point{} | |||
| 279 | + | ||||
| 280 | + | var targetGtk *gtk.Widget | |||
| 281 | + | if target != nil { | |||
| 282 | + | targetGtk = target.GtkWidget() | |||
| 283 | + | } | |||
| 284 | + | ||||
| 285 | + | ok = b.inner.ComputePoint(targetGtk, srcPoint, outPoint) | |||
| 286 | + | if !ok { | |||
| 287 | + | return 0, 0, false | |||
| 288 | + | } | |||
| 289 | + | return float64(outPoint.X), float64(outPoint.Y), true | |||
| 290 | + | } | |||
| 291 | + | ||||
| 237 | // gtkOverlay wraps gtk.Overlay to implement OverlayWidget. | 292 | // gtkOverlay wraps gtk.Overlay to implement OverlayWidget. | ||
| 238 | type gtkOverlay struct { | 293 | type gtkOverlay struct { | ||
| 239 | inner *gtk.Overlay | 294 | inner *gtk.Overlay | ||
| @@ -327,6 +382,25 @@ func (o *gtkOverlay) GetMeasureOverlay(overlay Widget) bool { | |||||
| 327 | return o.inner.GetMeasureOverlay(overlay.GtkWidget()) | 382 | return o.inner.GetMeasureOverlay(overlay.GtkWidget()) | ||
| 328 | } | 383 | } | ||
| 329 | 384 | ||||
| 385 | + | func (o *gtkOverlay) GetAllocatedWidth() int { return o.inner.GetAllocatedWidth() } | |||
| 386 | + | func (o *gtkOverlay) GetAllocatedHeight() int { return o.inner.GetAllocatedHeight() } | |||
| 387 | + | ||||
| 388 | + | func (o *gtkOverlay) ComputePoint(target Widget) (x, y float64, ok bool) { | |||
| 389 | + | srcPoint := &graphene.Point{X: 0, Y: 0} | |||
| 390 | + | outPoint := &graphene.Point{} | |||
| 391 | + | ||||
| 392 | + | var targetGtk *gtk.Widget | |||
| 393 | + | if target != nil { | |||
| 394 | + | targetGtk = target.GtkWidget() | |||
| 395 | + | } | |||
| 396 | + | ||||
| 397 | + | ok = o.inner.ComputePoint(targetGtk, srcPoint, outPoint) | |||
| 398 | + | if !ok { | |||
| 399 | + | return 0, 0, false | |||
| 400 | + | } | |||
| 401 | + | return float64(outPoint.X), float64(outPoint.Y), true | |||
| 402 | + | } | |||
| 403 | + | ||||
| 330 | // gtkLabel wraps gtk.Label to implement LabelWidget. | 404 | // gtkLabel wraps gtk.Label to implement LabelWidget. | ||
| 331 | type gtkLabel struct { | 405 | type gtkLabel struct { | ||
| 332 | inner *gtk.Label | 406 | inner *gtk.Label | ||
| @@ -369,6 +443,25 @@ func (l *gtkLabel) SetEllipsize(mode EllipsizeMode) { l.inner.SetEllipsize(pango | |||||
| 369 | func (l *gtkLabel) SetMaxWidthChars(n int) { l.inner.SetMaxWidthChars(n) } | 443 | func (l *gtkLabel) SetMaxWidthChars(n int) { l.inner.SetMaxWidthChars(n) } | ||
| 370 | func (l *gtkLabel) SetXalign(x float32) { l.inner.SetXalign(x) } | 444 | func (l *gtkLabel) SetXalign(x float32) { l.inner.SetXalign(x) } | ||
| 371 | 445 | ||||
| 446 | + | func (l *gtkLabel) GetAllocatedWidth() int { return l.inner.GetAllocatedWidth() } | |||
| 447 | + | func (l *gtkLabel) GetAllocatedHeight() int { return l.inner.GetAllocatedHeight() } | |||
| 448 | + | ||||
| 449 | + | func (l *gtkLabel) ComputePoint(target Widget) (x, y float64, ok bool) { | |||
| 450 | + | srcPoint := &graphene.Point{X: 0, Y: 0} | |||
| 451 | + | outPoint := &graphene.Point{} | |||
| 452 | + | ||||
| 453 | + | var targetGtk *gtk.Widget | |||
| 454 | + | if target != nil { | |||
| 455 | + | targetGtk = target.GtkWidget() | |||
| 456 | + | } | |||
| 457 | + | ||||
| 458 | + | ok = l.inner.ComputePoint(targetGtk, srcPoint, outPoint) | |||
| 459 | + | if !ok { | |||
| 460 | + | return 0, 0, false | |||
| 461 | + | } | |||
| 462 | + | return float64(outPoint.X), float64(outPoint.Y), true | |||
| 463 | + | } | |||
| 464 | + | ||||
| 372 | // gtkButton wraps gtk.Button to implement ButtonWidget. | 465 | // gtkButton wraps gtk.Button to implement ButtonWidget. | ||
| 373 | type gtkButton struct { | 466 | type gtkButton struct { | ||
| 374 | inner *gtk.Button | 467 | inner *gtk.Button | ||
| @@ -430,6 +523,25 @@ func (btn *gtkButton) ConnectClicked(callback func()) uint32 { | |||||
| 430 | return btn.inner.ConnectClicked(&cb) | 523 | return btn.inner.ConnectClicked(&cb) | ||
| 431 | } | 524 | } | ||
| 432 | 525 | ||||
| 526 | + | func (btn *gtkButton) GetAllocatedWidth() int { return btn.inner.GetAllocatedWidth() } | |||
| 527 | + | func (btn *gtkButton) GetAllocatedHeight() int { return btn.inner.GetAllocatedHeight() } | |||
| 528 | + | ||||
| 529 | + | func (btn *gtkButton) ComputePoint(target Widget) (x, y float64, ok bool) { | |||
| 530 | + | srcPoint := &graphene.Point{X: 0, Y: 0} | |||
| 531 | + | outPoint := &graphene.Point{} | |||
| 532 | + | ||||
| 533 | + | var targetGtk *gtk.Widget | |||
| 534 | + | if target != nil { | |||
| 535 | + | targetGtk = target.GtkWidget() | |||
| 536 | + | } | |||
| 537 | + | ||||
| 538 | + | ok = btn.inner.ComputePoint(targetGtk, srcPoint, outPoint) | |||
| 539 | + | if !ok { | |||
| 540 | + | return 0, 0, false | |||
| 541 | + | } | |||
| 542 | + | return float64(outPoint.X), float64(outPoint.Y), true | |||
| 543 | + | } | |||
| 544 | + | ||||
| 433 | // gtkImage wraps gtk.Image to implement ImageWidget. | 545 | // gtkImage wraps gtk.Image to implement ImageWidget. | ||
| 434 | type gtkImage struct { | 546 | type gtkImage struct { | ||
| 435 | inner *gtk.Image | 547 | inner *gtk.Image | ||
| @@ -470,6 +582,25 @@ func (img *gtkImage) SetFromFile(filename string) { img.inner.SetFromFile(&filen | |||||
| 470 | func (img *gtkImage) SetPixelSize(size int) { img.inner.SetPixelSize(size) } | 582 | func (img *gtkImage) SetPixelSize(size int) { img.inner.SetPixelSize(size) } | ||
| 471 | func (img *gtkImage) Clear() { img.inner.Clear() } | 583 | func (img *gtkImage) Clear() { img.inner.Clear() } | ||
| 472 | 584 | ||||
| 585 | + | func (img *gtkImage) GetAllocatedWidth() int { return img.inner.GetAllocatedWidth() } | |||
| 586 | + | func (img *gtkImage) GetAllocatedHeight() int { return img.inner.GetAllocatedHeight() } | |||
| 587 | + | ||||
| 588 | + | func (img *gtkImage) ComputePoint(target Widget) (x, y float64, ok bool) { | |||
| 589 | + | srcPoint := &graphene.Point{X: 0, Y: 0} | |||
| 590 | + | outPoint := &graphene.Point{} | |||
| 591 | + | ||||
| 592 | + | var targetGtk *gtk.Widget | |||
| 593 | + | if target != nil { | |||
| 594 | + | targetGtk = target.GtkWidget() | |||
| 595 | + | } | |||
| 596 | + | ||||
| 597 | + | ok = img.inner.ComputePoint(targetGtk, srcPoint, outPoint) | |||
| 598 | + | if !ok { | |||
| 599 | + | return 0, 0, false | |||
| 600 | + | } | |||
| 601 | + | return float64(outPoint.X), float64(outPoint.Y), true | |||
| 602 | + | } | |||
| 603 | + | ||||
| 473 | // GtkWidgetFactory creates real GTK widgets. | 604 | // GtkWidgetFactory creates real GTK widgets. | ||
| 474 | type GtkWidgetFactory struct{} | 605 | type GtkWidgetFactory struct{} | ||
| 475 | 606 | ||||
M internal/ui/layout/widgets.go +8 -4
| @@ -42,6 +42,14 @@ type Widget interface { | |||
| 42 | 42 | SetValign(align gtk.Align) | |
| 43 | 43 | SetSizeRequest(width, height int) | |
| 44 | 44 | ||
| 45 | + | // Geometry - for focus navigation | |
| 46 | + | GetAllocatedWidth() int | |
| 47 | + | GetAllocatedHeight() int | |
| 48 | + | // ComputePoint returns the position of this widget's origin (0,0) relative to | |
| 49 | + | // the target widget's coordinate space. If target is nil, returns position | |
| 50 | + | // relative to the native window. Returns ok=false if computation fails. | |
| 51 | + | ComputePoint(target Widget) (x, y float64, ok bool) | |
| 52 | + | ||
| 45 | 53 | // CSS styling | |
| 46 | 54 | AddCssClass(cssClass string) | |
| 47 | 55 | RemoveCssClass(cssClass string) | |
| @@ -89,10 +97,6 @@ type PanedWidget interface { | |||
| 89 | 97 | // Callback returns true to continue, false to stop | |
| 90 | 98 | AddTickCallback(callback func() bool) uint | |
| 91 | 99 | ||
| 92 | - | // Size information (for ratio calculation) | |
| 93 | - | GetAllocatedWidth() int | |
| 94 | - | GetAllocatedHeight() int | |
| 95 | - | ||
| 96 | 100 | // Handle appearance | |
| 97 | 101 | SetWideHandle(wide bool) | |
| 98 | 102 | GetWideHandle() bool | |
| @@ -42,6 +42,14 @@ type Widget interface { | |||||
| 42 | SetValign(align gtk.Align) | 42 | SetValign(align gtk.Align) | ||
| 43 | SetSizeRequest(width, height int) | 43 | SetSizeRequest(width, height int) | ||
| 44 | 44 | ||||
| 45 | + | // Geometry - for focus navigation | |||
| 46 | + | GetAllocatedWidth() int | |||
| 47 | + | GetAllocatedHeight() int | |||
| 48 | + | // ComputePoint returns the position of this widget's origin (0,0) relative to | |||
| 49 | + | // the target widget's coordinate space. If target is nil, returns position | |||
| 50 | + | // relative to the native window. Returns ok=false if computation fails. | |||
| 51 | + | ComputePoint(target Widget) (x, y float64, ok bool) | |||
| 52 | + | ||||
| 45 | // CSS styling | 53 | // CSS styling | ||
| 46 | AddCssClass(cssClass string) | 54 | AddCssClass(cssClass string) | ||
| 47 | RemoveCssClass(cssClass string) | 55 | RemoveCssClass(cssClass string) | ||
| @@ -89,10 +97,6 @@ type PanedWidget interface { | |||||
| 89 | // Callback returns true to continue, false to stop | 97 | // Callback returns true to continue, false to stop | ||
| 90 | AddTickCallback(callback func() bool) uint | 98 | AddTickCallback(callback func() bool) uint | ||
| 91 | 99 | ||||
| 92 | - | // Size information (for ratio calculation) | |||
| 93 | - | GetAllocatedWidth() int | |||
| 94 | - | GetAllocatedHeight() int | |||
| 95 | - | ||||
| 96 | // Handle appearance | 100 | // Handle appearance | ||
| 97 | SetWideHandle(wide bool) | 101 | SetWideHandle(wide bool) | ||
| 98 | GetWideHandle() bool | 102 | GetWideHandle() bool | ||
M internal/ui/theme/css.go +8 -4
| @@ -227,14 +227,18 @@ func generatePaneCSS(p Palette) string { | |||
| 227 | 227 | border: 1px solid var(--accent); | |
| 228 | 228 | } | |
| 229 | 229 | ||
| 230 | - | /* Pane mode active - thick blue border */ | |
| 230 | + | /* Pane mode active - thick blue inset border (for overlay) */ | |
| 231 | 231 | .pane-mode-active { | |
| 232 | - | border: 4px solid #4A90E2; | |
| 232 | + | background-color: transparent; | |
| 233 | + | box-shadow: inset 0 0 0 4px #4A90E2; | |
| 234 | + | border-radius: 0; | |
| 233 | 235 | } | |
| 234 | 236 | ||
| 235 | - | /* Tab mode active - thick orange border */ | |
| 237 | + | /* Tab mode active - thick orange inset border (for overlay) */ | |
| 236 | 238 | .tab-mode-active { | |
| 237 | - | border: 4px solid #FFA500; | |
| 239 | + | background-color: transparent; | |
| 240 | + | box-shadow: inset 0 0 0 4px #FFA500; | |
| 241 | + | border-radius: 0; | |
| 238 | 242 | } | |
| 239 | 243 | ` | |
| 240 | 244 | } | |
| @@ -227,14 +227,18 @@ func generatePaneCSS(p Palette) string { | |||||
| 227 | border: 1px solid var(--accent); | 227 | border: 1px solid var(--accent); | ||
| 228 | } | 228 | } | ||
| 229 | 229 | ||||
| 230 | - | /* Pane mode active - thick blue border */ | 230 | + | /* Pane mode active - thick blue inset border (for overlay) */ |
| 231 | .pane-mode-active { | 231 | .pane-mode-active { | ||
| 232 | - | border: 4px solid #4A90E2; | 232 | + | background-color: transparent; |
| 233 | + | box-shadow: inset 0 0 0 4px #4A90E2; | |||
| 234 | + | border-radius: 0; | |||
| 233 | } | 235 | } | ||
| 234 | 236 | ||||
| 235 | - | /* Tab mode active - thick orange border */ | 237 | + | /* Tab mode active - thick orange inset border (for overlay) */ |
| 236 | .tab-mode-active { | 238 | .tab-mode-active { | ||
| 237 | - | border: 4px solid #FFA500; | 239 | + | background-color: transparent; |
| 240 | + | box-shadow: inset 0 0 0 4px #FFA500; | |||
| 241 | + | border-radius: 0; | |||
| 238 | } | 242 | } | ||
| 239 | ` | 243 | ` | ||
| 240 | } | 244 | } | ||