feat(ui): implement incremental stacked pane operations with title tracking
- Add dynamic pane title tracking with paneTitles map and RWMutex for thread-safe access - Implement handleStackPane to convert leaf panes to stacked containers incrementally - Wire title bar click callback to switch between collapsed panes in stacked views - Add handleStackNavigate for stack navigation (up/down actions) - Create StackedPaneManager for incremental widget operations on stacked panes - Fix terminology: change 'New Tab' to 'Untitled' since tabs hold workspaces - Fix split pane regression: default new panes from splits to 'about:blank' URI - Update ManagePanesUseCase with CreateStack and AddToStack returning structured output - Register title tracking with keyboard handler and add stack navigation actions
- commit
- 739acba460ecc551a935ff5198db380059b398dc
- author
- brice · · 2025-12-16 06:21 UTC
- parent
- 385e5a2
- signature
- unsigned
8 files · +978 -63
M internal/application/usecase/manage_panes.go +94 -29
| @@ -87,6 +87,8 @@ func (uc *ManagePanesUseCase) Split(ctx context.Context, input SplitPaneInput) ( | |||
| 87 | 87 | newPane = entity.NewPane(paneID) | |
| 88 | 88 | if input.InitialURL != "" { | |
| 89 | 89 | newPane.URI = input.InitialURL | |
| 90 | + | } else { | |
| 91 | + | newPane.URI = "about:blank" | |
| 90 | 92 | } | |
| 91 | 93 | } | |
| 92 | 94 | ||
| @@ -381,63 +383,119 @@ func (uc *ManagePanesUseCase) findLeafInDirection(node *entity.PaneNode, fromEnd | |||
| 381 | 383 | return nil | |
| 382 | 384 | } | |
| 383 | 385 | ||
| 384 | - | // CreateStack converts a pane into a stacked container. | |
| 385 | - | func (uc *ManagePanesUseCase) CreateStack(ctx context.Context, ws *entity.Workspace, paneNode *entity.PaneNode) error { | |
| 386 | + | // CreateStackOutput contains the result of creating a stack from a pane. | |
| 387 | + | type CreateStackOutput struct { | |
| 388 | + | StackNode *entity.PaneNode // The stack container node | |
| 389 | + | OriginalNode *entity.PaneNode // The original pane, now a child of the stack | |
| 390 | + | NewPaneNode *entity.PaneNode // The new pane added to the stack | |
| 391 | + | NewPane *entity.Pane // The new pane entity | |
| 392 | + | } | |
| 393 | + | ||
| 394 | + | // CreateStack converts a pane into a stacked container and adds a new pane. | |
| 395 | + | // The original pane becomes the first child, a new pane becomes the second child. | |
| 396 | + | // Returns the stack node and the new pane node for UI operations. | |
| 397 | + | // | |
| 398 | + | // Domain tree transformation: | |
| 399 | + | // | |
| 400 | + | // Before: parent -> targetNode (leaf with pane) | |
| 401 | + | // After: parent -> targetNode (stack) -> [originalChild (leaf), newChild (leaf)] | |
| 402 | + | // | |
| 403 | + | // The targetNode keeps its ID but becomes a stack container. | |
| 404 | + | // This allows the UI layer to update widget mappings incrementally. | |
| 405 | + | func (uc *ManagePanesUseCase) CreateStack(ctx context.Context, ws *entity.Workspace, paneNode *entity.PaneNode) (*CreateStackOutput, error) { | |
| 386 | 406 | log := logging.FromContext(ctx) | |
| 387 | 407 | log.Debug().Str("pane_id", paneNode.ID).Msg("creating stack from pane") | |
| 388 | 408 | ||
| 389 | 409 | if ws == nil { | |
| 390 | - | return fmt.Errorf("workspace is required") | |
| 410 | + | return nil, fmt.Errorf("workspace is required") | |
| 391 | 411 | } | |
| 392 | 412 | if paneNode == nil { | |
| 393 | - | return fmt.Errorf("pane node is required") | |
| 413 | + | return nil, fmt.Errorf("pane node is required") | |
| 394 | 414 | } | |
| 395 | 415 | if !paneNode.IsLeaf() { | |
| 396 | - | return fmt.Errorf("can only create stack from leaf pane") | |
| 416 | + | return nil, fmt.Errorf("can only create stack from leaf pane") | |
| 397 | 417 | } | |
| 398 | 418 | if paneNode.IsStacked { | |
| 399 | - | return fmt.Errorf("pane is already stacked") | |
| 419 | + | return nil, fmt.Errorf("pane is already stacked") | |
| 400 | 420 | } | |
| 401 | 421 | ||
| 402 | - | // Convert to stacked container | |
| 403 | - | paneNode.IsStacked = true | |
| 404 | - | paneNode.ActiveStackIndex = 0 | |
| 422 | + | // Save original pane reference | |
| 423 | + | originalPane := paneNode.Pane | |
| 424 | + | originalPaneID := paneNode.ID | |
| 405 | 425 | ||
| 406 | - | // The pane becomes a child of itself conceptually | |
| 407 | - | // Create a child node for the actual pane | |
| 408 | - | childNode := &entity.PaneNode{ | |
| 409 | - | ID: paneNode.ID + "_child", | |
| 410 | - | Pane: paneNode.Pane, | |
| 426 | + | // Create child node for the original pane | |
| 427 | + | originalChildNode := &entity.PaneNode{ | |
| 428 | + | ID: originalPaneID + "_0", | |
| 429 | + | Pane: originalPane, | |
| 411 | 430 | Parent: paneNode, | |
| 412 | 431 | } | |
| 432 | + | ||
| 433 | + | // Create new pane | |
| 434 | + | newPaneID := entity.PaneID(uc.idGenerator()) | |
| 435 | + | newPane := entity.NewPane(newPaneID) | |
| 436 | + | ||
| 437 | + | // Create child node for the new pane | |
| 438 | + | newChildNode := &entity.PaneNode{ | |
| 439 | + | ID: string(newPaneID), | |
| 440 | + | Pane: newPane, | |
| 441 | + | Parent: paneNode, | |
| 442 | + | } | |
| 443 | + | ||
| 444 | + | // Convert targetNode to stack container | |
| 413 | 445 | paneNode.Pane = nil | |
| 414 | - | paneNode.Children = []*entity.PaneNode{childNode} | |
| 446 | + | paneNode.IsStacked = true | |
| 447 | + | paneNode.ActiveStackIndex = 1 // New pane is active | |
| 448 | + | paneNode.Children = []*entity.PaneNode{originalChildNode, newChildNode} | |
| 415 | 449 | ||
| 416 | - | log.Info().Str("stack_id", paneNode.ID).Msg("stack created") | |
| 417 | - | return nil | |
| 450 | + | // Update workspace active pane | |
| 451 | + | ws.ActivePaneID = newPaneID | |
| 452 | + | ||
| 453 | + | log.Info(). | |
| 454 | + | Str("stack_id", paneNode.ID). | |
| 455 | + | Str("original_pane", string(originalPane.ID)). | |
| 456 | + | Str("new_pane", string(newPaneID)). | |
| 457 | + | Msg("stack created") | |
| 458 | + | ||
| 459 | + | return &CreateStackOutput{ | |
| 460 | + | StackNode: paneNode, | |
| 461 | + | OriginalNode: originalChildNode, | |
| 462 | + | NewPaneNode: newChildNode, | |
| 463 | + | NewPane: newPane, | |
| 464 | + | }, nil | |
| 418 | 465 | } | |
| 419 | 466 | ||
| 420 | - | // AddToStack adds a pane to an existing stack. | |
| 421 | - | func (uc *ManagePanesUseCase) AddToStack(ctx context.Context, ws *entity.Workspace, stackNode *entity.PaneNode, pane *entity.Pane) error { | |
| 467 | + | // AddToStackOutput contains the result of adding a pane to a stack. | |
| 468 | + | type AddToStackOutput struct { | |
| 469 | + | NewPaneNode *entity.PaneNode // The new pane node added to the stack | |
| 470 | + | StackIndex int // Index of the new pane in the stack | |
| 471 | + | } | |
| 472 | + | ||
| 473 | + | // AddToStack adds a new pane to an existing stack. | |
| 474 | + | // Optionally pass a pre-created pane, or nil to create a new one. | |
| 475 | + | func (uc *ManagePanesUseCase) AddToStack(ctx context.Context, ws *entity.Workspace, stackNode *entity.PaneNode, pane *entity.Pane) (*AddToStackOutput, error) { | |
| 422 | 476 | log := logging.FromContext(ctx) | |
| 423 | - | log.Debug(). | |
| 424 | - | Str("stack_id", stackNode.ID). | |
| 425 | - | Str("pane_id", string(pane.ID)). | |
| 426 | - | Msg("adding pane to stack") | |
| 427 | 477 | ||
| 428 | 478 | if ws == nil { | |
| 429 | - | return fmt.Errorf("workspace is required") | |
| 479 | + | return nil, fmt.Errorf("workspace is required") | |
| 430 | 480 | } | |
| 431 | 481 | if stackNode == nil { | |
| 432 | - | return fmt.Errorf("stack node is required") | |
| 482 | + | return nil, fmt.Errorf("stack node is required") | |
| 433 | 483 | } | |
| 434 | 484 | if !stackNode.IsStacked { | |
| 435 | - | return fmt.Errorf("node is not a stack") | |
| 485 | + | return nil, fmt.Errorf("node is not a stack") | |
| 436 | 486 | } | |
| 487 | + | ||
| 488 | + | // Create pane if not provided | |
| 437 | 489 | if pane == nil { | |
| 438 | - | return fmt.Errorf("pane is required") | |
| 490 | + | paneID := entity.PaneID(uc.idGenerator()) | |
| 491 | + | pane = entity.NewPane(paneID) | |
| 439 | 492 | } | |
| 440 | 493 | ||
| 494 | + | log.Debug(). | |
| 495 | + | Str("stack_id", stackNode.ID). | |
| 496 | + | Str("pane_id", string(pane.ID)). | |
| 497 | + | Msg("adding pane to stack") | |
| 498 | + | ||
| 441 | 499 | // Create node for the new pane | |
| 442 | 500 | newNode := &entity.PaneNode{ | |
| 443 | 501 | ID: string(pane.ID), | |
| @@ -447,7 +505,11 @@ func (uc *ManagePanesUseCase) AddToStack(ctx context.Context, ws *entity.Workspa | |||
| 447 | 505 | ||
| 448 | 506 | // Add to stack | |
| 449 | 507 | stackNode.Children = append(stackNode.Children, newNode) | |
| 450 | - | stackNode.ActiveStackIndex = len(stackNode.Children) - 1 | |
| 508 | + | newIndex := len(stackNode.Children) - 1 | |
| 509 | + | stackNode.ActiveStackIndex = newIndex | |
| 510 | + | ||
| 511 | + | // Update workspace active pane | |
| 512 | + | ws.ActivePaneID = pane.ID | |
| 451 | 513 | ||
| 452 | 514 | log.Info(). | |
| 453 | 515 | Str("stack_id", stackNode.ID). | |
| @@ -455,7 +517,10 @@ func (uc *ManagePanesUseCase) AddToStack(ctx context.Context, ws *entity.Workspa | |||
| 455 | 517 | Int("stack_size", len(stackNode.Children)). | |
| 456 | 518 | Msg("pane added to stack") | |
| 457 | 519 | ||
| 458 | - | return nil | |
| 520 | + | return &AddToStackOutput{ | |
| 521 | + | NewPaneNode: newNode, | |
| 522 | + | StackIndex: newIndex, | |
| 523 | + | }, nil | |
| 459 | 524 | } | |
| 460 | 525 | ||
| 461 | 526 | // NavigateStack cycles through stacked panes. | |
| @@ -87,6 +87,8 @@ func (uc *ManagePanesUseCase) Split(ctx context.Context, input SplitPaneInput) ( | |||||
| 87 | newPane = entity.NewPane(paneID) | 87 | newPane = entity.NewPane(paneID) | ||
| 88 | if input.InitialURL != "" { | 88 | if input.InitialURL != "" { | ||
| 89 | newPane.URI = input.InitialURL | 89 | newPane.URI = input.InitialURL | ||
| 90 | + | } else { | |||
| 91 | + | newPane.URI = "about:blank" | |||
| 90 | } | 92 | } | ||
| 91 | } | 93 | } | ||
| 92 | 94 | ||||
| @@ -381,63 +383,119 @@ func (uc *ManagePanesUseCase) findLeafInDirection(node *entity.PaneNode, fromEnd | |||||
| 381 | return nil | 383 | return nil | ||
| 382 | } | 384 | } | ||
| 383 | 385 | ||||
| 384 | - | // CreateStack converts a pane into a stacked container. | 386 | + | // CreateStackOutput contains the result of creating a stack from a pane. |
| 385 | - | func (uc *ManagePanesUseCase) CreateStack(ctx context.Context, ws *entity.Workspace, paneNode *entity.PaneNode) error { | 387 | + | type CreateStackOutput struct { |
| 388 | + | StackNode *entity.PaneNode // The stack container node | |||
| 389 | + | OriginalNode *entity.PaneNode // The original pane, now a child of the stack | |||
| 390 | + | NewPaneNode *entity.PaneNode // The new pane added to the stack | |||
| 391 | + | NewPane *entity.Pane // The new pane entity | |||
| 392 | + | } | |||
| 393 | + | ||||
| 394 | + | // CreateStack converts a pane into a stacked container and adds a new pane. | |||
| 395 | + | // The original pane becomes the first child, a new pane becomes the second child. | |||
| 396 | + | // Returns the stack node and the new pane node for UI operations. | |||
| 397 | + | // | |||
| 398 | + | // Domain tree transformation: | |||
| 399 | + | // | |||
| 400 | + | // Before: parent -> targetNode (leaf with pane) | |||
| 401 | + | // After: parent -> targetNode (stack) -> [originalChild (leaf), newChild (leaf)] | |||
| 402 | + | // | |||
| 403 | + | // The targetNode keeps its ID but becomes a stack container. | |||
| 404 | + | // This allows the UI layer to update widget mappings incrementally. | |||
| 405 | + | func (uc *ManagePanesUseCase) CreateStack(ctx context.Context, ws *entity.Workspace, paneNode *entity.PaneNode) (*CreateStackOutput, error) { | |||
| 386 | log := logging.FromContext(ctx) | 406 | log := logging.FromContext(ctx) | ||
| 387 | log.Debug().Str("pane_id", paneNode.ID).Msg("creating stack from pane") | 407 | log.Debug().Str("pane_id", paneNode.ID).Msg("creating stack from pane") | ||
| 388 | 408 | ||||
| 389 | if ws == nil { | 409 | if ws == nil { | ||
| 390 | - | return fmt.Errorf("workspace is required") | 410 | + | return nil, fmt.Errorf("workspace is required") |
| 391 | } | 411 | } | ||
| 392 | if paneNode == nil { | 412 | if paneNode == nil { | ||
| 393 | - | return fmt.Errorf("pane node is required") | 413 | + | return nil, fmt.Errorf("pane node is required") |
| 394 | } | 414 | } | ||
| 395 | if !paneNode.IsLeaf() { | 415 | if !paneNode.IsLeaf() { | ||
| 396 | - | return fmt.Errorf("can only create stack from leaf pane") | 416 | + | return nil, fmt.Errorf("can only create stack from leaf pane") |
| 397 | } | 417 | } | ||
| 398 | if paneNode.IsStacked { | 418 | if paneNode.IsStacked { | ||
| 399 | - | return fmt.Errorf("pane is already stacked") | 419 | + | return nil, fmt.Errorf("pane is already stacked") |
| 400 | } | 420 | } | ||
| 401 | 421 | ||||
| 402 | - | // Convert to stacked container | 422 | + | // Save original pane reference |
| 403 | - | paneNode.IsStacked = true | 423 | + | originalPane := paneNode.Pane |
| 404 | - | paneNode.ActiveStackIndex = 0 | 424 | + | originalPaneID := paneNode.ID |
| 405 | 425 | ||||
| 406 | - | // The pane becomes a child of itself conceptually | 426 | + | // Create child node for the original pane |
| 407 | - | // Create a child node for the actual pane | 427 | + | originalChildNode := &entity.PaneNode{ |
| 408 | - | childNode := &entity.PaneNode{ | 428 | + | ID: originalPaneID + "_0", |
| 409 | - | ID: paneNode.ID + "_child", | 429 | + | Pane: originalPane, |
| 410 | - | Pane: paneNode.Pane, | |||
| 411 | Parent: paneNode, | 430 | Parent: paneNode, | ||
| 412 | } | 431 | } | ||
| 432 | + | ||||
| 433 | + | // Create new pane | |||
| 434 | + | newPaneID := entity.PaneID(uc.idGenerator()) | |||
| 435 | + | newPane := entity.NewPane(newPaneID) | |||
| 436 | + | ||||
| 437 | + | // Create child node for the new pane | |||
| 438 | + | newChildNode := &entity.PaneNode{ | |||
| 439 | + | ID: string(newPaneID), | |||
| 440 | + | Pane: newPane, | |||
| 441 | + | Parent: paneNode, | |||
| 442 | + | } | |||
| 443 | + | ||||
| 444 | + | // Convert targetNode to stack container | |||
| 413 | paneNode.Pane = nil | 445 | paneNode.Pane = nil | ||
| 414 | - | paneNode.Children = []*entity.PaneNode{childNode} | 446 | + | paneNode.IsStacked = true |
| 447 | + | paneNode.ActiveStackIndex = 1 // New pane is active | |||
| 448 | + | paneNode.Children = []*entity.PaneNode{originalChildNode, newChildNode} | |||
| 415 | 449 | ||||
| 416 | - | log.Info().Str("stack_id", paneNode.ID).Msg("stack created") | 450 | + | // Update workspace active pane |
| 417 | - | return nil | 451 | + | ws.ActivePaneID = newPaneID |
| 452 | + | ||||
| 453 | + | log.Info(). | |||
| 454 | + | Str("stack_id", paneNode.ID). | |||
| 455 | + | Str("original_pane", string(originalPane.ID)). | |||
| 456 | + | Str("new_pane", string(newPaneID)). | |||
| 457 | + | Msg("stack created") | |||
| 458 | + | ||||
| 459 | + | return &CreateStackOutput{ | |||
| 460 | + | StackNode: paneNode, | |||
| 461 | + | OriginalNode: originalChildNode, | |||
| 462 | + | NewPaneNode: newChildNode, | |||
| 463 | + | NewPane: newPane, | |||
| 464 | + | }, nil | |||
| 418 | } | 465 | } | ||
| 419 | 466 | ||||
| 420 | - | // AddToStack adds a pane to an existing stack. | 467 | + | // AddToStackOutput contains the result of adding a pane to a stack. |
| 421 | - | func (uc *ManagePanesUseCase) AddToStack(ctx context.Context, ws *entity.Workspace, stackNode *entity.PaneNode, pane *entity.Pane) error { | 468 | + | type AddToStackOutput struct { |
| 469 | + | NewPaneNode *entity.PaneNode // The new pane node added to the stack | |||
| 470 | + | StackIndex int // Index of the new pane in the stack | |||
| 471 | + | } | |||
| 472 | + | ||||
| 473 | + | // AddToStack adds a new pane to an existing stack. | |||
| 474 | + | // Optionally pass a pre-created pane, or nil to create a new one. | |||
| 475 | + | func (uc *ManagePanesUseCase) AddToStack(ctx context.Context, ws *entity.Workspace, stackNode *entity.PaneNode, pane *entity.Pane) (*AddToStackOutput, error) { | |||
| 422 | log := logging.FromContext(ctx) | 476 | log := logging.FromContext(ctx) | ||
| 423 | - | log.Debug(). | |||
| 424 | - | Str("stack_id", stackNode.ID). | |||
| 425 | - | Str("pane_id", string(pane.ID)). | |||
| 426 | - | Msg("adding pane to stack") | |||
| 427 | 477 | ||||
| 428 | if ws == nil { | 478 | if ws == nil { | ||
| 429 | - | return fmt.Errorf("workspace is required") | 479 | + | return nil, fmt.Errorf("workspace is required") |
| 430 | } | 480 | } | ||
| 431 | if stackNode == nil { | 481 | if stackNode == nil { | ||
| 432 | - | return fmt.Errorf("stack node is required") | 482 | + | return nil, fmt.Errorf("stack node is required") |
| 433 | } | 483 | } | ||
| 434 | if !stackNode.IsStacked { | 484 | if !stackNode.IsStacked { | ||
| 435 | - | return fmt.Errorf("node is not a stack") | 485 | + | return nil, fmt.Errorf("node is not a stack") |
| 436 | } | 486 | } | ||
| 487 | + | ||||
| 488 | + | // Create pane if not provided | |||
| 437 | if pane == nil { | 489 | if pane == nil { | ||
| 438 | - | return fmt.Errorf("pane is required") | 490 | + | paneID := entity.PaneID(uc.idGenerator()) |
| 491 | + | pane = entity.NewPane(paneID) | |||
| 439 | } | 492 | } | ||
| 440 | 493 | ||||
| 494 | + | log.Debug(). | |||
| 495 | + | Str("stack_id", stackNode.ID). | |||
| 496 | + | Str("pane_id", string(pane.ID)). | |||
| 497 | + | Msg("adding pane to stack") | |||
| 498 | + | ||||
| 441 | // Create node for the new pane | 499 | // Create node for the new pane | ||
| 442 | newNode := &entity.PaneNode{ | 500 | newNode := &entity.PaneNode{ | ||
| 443 | ID: string(pane.ID), | 501 | ID: string(pane.ID), | ||
| @@ -447,7 +505,11 @@ func (uc *ManagePanesUseCase) AddToStack(ctx context.Context, ws *entity.Workspa | |||||
| 447 | 505 | ||||
| 448 | // Add to stack | 506 | // Add to stack | ||
| 449 | stackNode.Children = append(stackNode.Children, newNode) | 507 | stackNode.Children = append(stackNode.Children, newNode) | ||
| 450 | - | stackNode.ActiveStackIndex = len(stackNode.Children) - 1 | 508 | + | newIndex := len(stackNode.Children) - 1 |
| 509 | + | stackNode.ActiveStackIndex = newIndex | |||
| 510 | + | ||||
| 511 | + | // Update workspace active pane | |||
| 512 | + | ws.ActivePaneID = pane.ID | |||
| 451 | 513 | ||||
| 452 | log.Info(). | 514 | log.Info(). | ||
| 453 | Str("stack_id", stackNode.ID). | 515 | Str("stack_id", stackNode.ID). | ||
| @@ -455,7 +517,10 @@ func (uc *ManagePanesUseCase) AddToStack(ctx context.Context, ws *entity.Workspa | |||||
| 455 | Int("stack_size", len(stackNode.Children)). | 517 | Int("stack_size", len(stackNode.Children)). | ||
| 456 | Msg("pane added to stack") | 518 | Msg("pane added to stack") | ||
| 457 | 519 | ||||
| 458 | - | return nil | 520 | + | return &AddToStackOutput{ |
| 521 | + | NewPaneNode: newNode, | |||
| 522 | + | StackIndex: newIndex, | |||
| 523 | + | }, nil | |||
| 459 | } | 524 | } | ||
| 460 | 525 | ||||
| 461 | // NavigateStack cycles through stacked panes. | 526 | // NavigateStack cycles through stacked panes. | ||
M internal/ui/app.go +414 -13
| @@ -39,6 +39,7 @@ type App struct { | |||
| 39 | 39 | panesUC *usecase.ManagePanesUseCase | |
| 40 | 40 | workspaceViews map[entity.TabID]*component.WorkspaceView | |
| 41 | 41 | widgetFactory layout.WidgetFactory | |
| 42 | + | stackedPaneMgr *component.StackedPaneManager | |
| 42 | 43 | ||
| 43 | 44 | // Input handling | |
| 44 | 45 | keyboardHandler *input.KeyboardHandler | |
| @@ -47,11 +48,13 @@ type App struct { | |||
| 47 | 48 | omnibox *component.Omnibox | |
| 48 | 49 | ||
| 49 | 50 | // Web content | |
| 50 | - | pool *webkit.WebViewPool | |
| 51 | - | injector *webkit.ContentInjector | |
| 52 | - | router *webkit.MessageRouter | |
| 53 | - | settings *webkit.SettingsManager | |
| 54 | - | webViews map[entity.PaneID]*webkit.WebView | |
| 51 | + | pool *webkit.WebViewPool | |
| 52 | + | injector *webkit.ContentInjector | |
| 53 | + | router *webkit.MessageRouter | |
| 54 | + | settings *webkit.SettingsManager | |
| 55 | + | webViews map[entity.PaneID]*webkit.WebView | |
| 56 | + | paneTitles map[entity.PaneID]string // Dynamic title tracking | |
| 57 | + | titleMu sync.RWMutex | |
| 55 | 58 | ||
| 56 | 59 | // ID generator for tabs/panes | |
| 57 | 60 | idCounter uint64 | |
| @@ -80,6 +83,7 @@ func New(deps *Dependencies) (*App, error) { | |||
| 80 | 83 | router: deps.MessageRouter, | |
| 81 | 84 | settings: deps.Settings, | |
| 82 | 85 | webViews: make(map[entity.PaneID]*webkit.WebView), | |
| 86 | + | paneTitles: make(map[entity.PaneID]string), | |
| 83 | 87 | cancel: cancel, | |
| 84 | 88 | } | |
| 85 | 89 | if app.router == nil { | |
| @@ -149,6 +153,9 @@ func (a *App) onActivate(ctx context.Context) { | |||
| 149 | 153 | // Initialize widget factory for pane layout | |
| 150 | 154 | a.widgetFactory = layout.NewGtkWidgetFactory() | |
| 151 | 155 | ||
| 156 | + | // Initialize stacked pane manager for incremental widget operations | |
| 157 | + | a.stackedPaneMgr = component.NewStackedPaneManager(a.widgetFactory) | |
| 158 | + | ||
| 152 | 159 | // Create keyboard handler | |
| 153 | 160 | a.keyboardHandler = input.NewKeyboardHandler( | |
| 154 | 161 | ctx, | |
| @@ -311,6 +318,12 @@ func (a *App) handleKeyboardAction(ctx context.Context, action input.Action) err | |||
| 311 | 318 | case input.ActionFocusDown: | |
| 312 | 319 | return a.handlePaneFocus(ctx, usecase.NavDown) | |
| 313 | 320 | ||
| 321 | + | // Stack navigation | |
| 322 | + | case input.ActionStackNavUp: | |
| 323 | + | return a.handleStackNavigate(ctx, "up") | |
| 324 | + | case input.ActionStackNavDown: | |
| 325 | + | return a.handleStackNavigate(ctx, "down") | |
| 326 | + | ||
| 314 | 327 | // Navigation (stub implementations for now) | |
| 315 | 328 | case input.ActionGoBack: | |
| 316 | 329 | log.Debug().Msg("go back action (not yet implemented)") | |
| @@ -670,20 +683,322 @@ func (a *App) handlePaneFocus(ctx context.Context, direction usecase.NavigateDir | |||
| 670 | 683 | return nil | |
| 671 | 684 | } | |
| 672 | 685 | ||
| 673 | - | // handleStackPane converts the active pane into a stacked container. | |
| 674 | - | // TODO: Implement incremental widget operations for stacked panes (like old repo). | |
| 675 | - | // Currently, stacked panes require incremental GTK widget manipulation rather than | |
| 676 | - | // full tree rebuilds because WebViews maintain state and parent references. | |
| 686 | + | // handleStackPane adds a new pane stacked on top of the active pane. | |
| 687 | + | // This modifies both the domain tree and the UI StackedView. | |
| 677 | 688 | func (a *App) handleStackPane(ctx context.Context) error { | |
| 678 | 689 | log := logging.FromContext(ctx) | |
| 679 | 690 | ||
| 680 | - | // Stacked panes are not yet fully supported in the new architecture. | |
| 681 | - | // The rebuild approach conflicts with WebView's stateful widget lifecycle. | |
| 682 | - | // See dumber-refs/internal/app/browser/stacked_panes.go for incremental approach. | |
| 683 | - | log.Warn().Msg("stacked panes not yet implemented in clean architecture - requires incremental widget operations") | |
| 691 | + | if a.stackedPaneMgr == nil { | |
| 692 | + | log.Warn().Msg("stacked pane manager not available") | |
| 693 | + | return nil | |
| 694 | + | } | |
| 695 | + | ||
| 696 | + | ws := a.activeWorkspace() | |
| 697 | + | if ws == nil { | |
| 698 | + | log.Warn().Msg("no active workspace") | |
| 699 | + | return nil | |
| 700 | + | } | |
| 701 | + | ||
| 702 | + | activeNode := ws.ActivePane() | |
| 703 | + | if activeNode == nil || activeNode.Pane == nil { | |
| 704 | + | log.Warn().Msg("no active pane") | |
| 705 | + | return nil | |
| 706 | + | } | |
| 707 | + | ||
| 708 | + | wsView := a.activeWorkspaceView() | |
| 709 | + | if wsView == nil { | |
| 710 | + | log.Warn().Msg("no workspace view") | |
| 711 | + | return nil | |
| 712 | + | } | |
| 713 | + | ||
| 714 | + | activePaneID := activeNode.Pane.ID | |
| 715 | + | ||
| 716 | + | // Create a new pane entity | |
| 717 | + | newPaneID := entity.PaneID(a.generateID()) | |
| 718 | + | newPane := entity.NewPane(newPaneID) | |
| 719 | + | newPane.URI = "about:blank" | |
| 720 | + | newPane.Title = "Untitled" | |
| 721 | + | ||
| 722 | + | // Get the original pane's current title | |
| 723 | + | originalTitle := a.getPaneTitle(activePaneID) | |
| 724 | + | if originalTitle == "" { | |
| 725 | + | originalTitle = activeNode.Pane.Title | |
| 726 | + | } | |
| 727 | + | if originalTitle == "" { | |
| 728 | + | originalTitle = "Untitled" | |
| 729 | + | } | |
| 730 | + | ||
| 731 | + | // Update domain model: convert leaf to stacked if needed, add new pane | |
| 732 | + | var stackNode *entity.PaneNode | |
| 733 | + | var needsFirstPaneTitleUpdate bool | |
| 734 | + | if activeNode.IsStacked { | |
| 735 | + | // Already stacked, just add to it | |
| 736 | + | stackNode = activeNode | |
| 737 | + | } else { | |
| 738 | + | // Convert leaf node to stacked container | |
| 739 | + | // Move current pane to a child node | |
| 740 | + | originalPane := activeNode.Pane | |
| 741 | + | originalPane.Title = originalTitle // Ensure title is set | |
| 742 | + | originalPaneChild := &entity.PaneNode{ | |
| 743 | + | ID: activeNode.ID + "_0", | |
| 744 | + | Pane: originalPane, | |
| 745 | + | Parent: activeNode, | |
| 746 | + | } | |
| 747 | + | ||
| 748 | + | activeNode.Pane = nil // No longer a leaf | |
| 749 | + | activeNode.IsStacked = true | |
| 750 | + | activeNode.Children = []*entity.PaneNode{originalPaneChild} | |
| 751 | + | stackNode = activeNode | |
| 752 | + | needsFirstPaneTitleUpdate = true | |
| 753 | + | ||
| 754 | + | log.Debug(). | |
| 755 | + | Str("node_id", activeNode.ID). | |
| 756 | + | Str("original_pane", string(originalPane.ID)). | |
| 757 | + | Str("original_title", originalTitle). | |
| 758 | + | Msg("converted leaf to stacked node") | |
| 759 | + | } | |
| 760 | + | ||
| 761 | + | // Create new child node for the new pane | |
| 762 | + | newChildNode := &entity.PaneNode{ | |
| 763 | + | ID: stackNode.ID + "_" + string(newPaneID), | |
| 764 | + | Pane: newPane, | |
| 765 | + | Parent: stackNode, | |
| 766 | + | } | |
| 767 | + | stackNode.Children = append(stackNode.Children, newChildNode) | |
| 768 | + | stackNode.ActiveStackIndex = len(stackNode.Children) - 1 | |
| 769 | + | ||
| 770 | + | log.Debug(). | |
| 771 | + | Int("stack_size", len(stackNode.Children)). | |
| 772 | + | Int("active_index", stackNode.ActiveStackIndex). | |
| 773 | + | Msg("domain tree updated") | |
| 774 | + | ||
| 775 | + | // Create PaneView for the new pane | |
| 776 | + | newPaneView := component.NewPaneView(a.widgetFactory, newPaneID, nil) | |
| 777 | + | wsView.RegisterPaneView(newPaneID, newPaneView) | |
| 778 | + | ||
| 779 | + | // Add to the UI StackedView | |
| 780 | + | if err := a.stackedPaneMgr.AddPaneToStack(ctx, wsView, activePaneID, newPaneView, "Untitled"); err != nil { | |
| 781 | + | log.Error().Err(err).Msg("failed to add pane to stack") | |
| 782 | + | return err | |
| 783 | + | } | |
| 784 | + | ||
| 785 | + | // Update the first pane's title if we just converted from leaf to stacked | |
| 786 | + | if needsFirstPaneTitleUpdate { | |
| 787 | + | tr := wsView.TreeRenderer() | |
| 788 | + | if tr != nil { | |
| 789 | + | stackedView := tr.GetStackedViewForPane(string(activePaneID)) | |
| 790 | + | if stackedView != nil { | |
| 791 | + | if err := stackedView.UpdateTitle(0, originalTitle); err != nil { | |
| 792 | + | log.Warn().Err(err).Str("title", originalTitle).Msg("failed to update first pane title") | |
| 793 | + | } else { | |
| 794 | + | log.Debug().Str("title", originalTitle).Msg("updated first pane title in StackedView") | |
| 795 | + | } | |
| 796 | + | } | |
| 797 | + | } | |
| 798 | + | } | |
| 799 | + | ||
| 800 | + | // Get WebView and attach | |
| 801 | + | wv, err := a.ensureWebViewForPane(ctx, newPaneID) | |
| 802 | + | if err != nil { | |
| 803 | + | log.Warn().Err(err).Msg("failed to get webview for new pane") | |
| 804 | + | } else { | |
| 805 | + | widget := a.wrapWebViewWidget(ctx, wv) | |
| 806 | + | if widget != nil { | |
| 807 | + | newPaneView.SetWebViewWidget(widget) | |
| 808 | + | } | |
| 809 | + | // Load blank page | |
| 810 | + | if err := wv.LoadURI(ctx, "about:blank"); err != nil { | |
| 811 | + | log.Warn().Err(err).Msg("failed to load blank page") | |
| 812 | + | } | |
| 813 | + | } | |
| 814 | + | ||
| 815 | + | // Update workspace active pane ID | |
| 816 | + | ws.ActivePaneID = newPaneID | |
| 817 | + | ||
| 818 | + | // Update workspace view | |
| 819 | + | if err := wsView.SetActivePaneID(newPaneID); err != nil { | |
| 820 | + | log.Warn().Err(err).Msg("failed to set active pane") | |
| 821 | + | } | |
| 822 | + | ||
| 823 | + | // Set up title bar click callback | |
| 824 | + | tr := wsView.TreeRenderer() | |
| 825 | + | if tr != nil { | |
| 826 | + | stackedView := tr.GetStackedViewForPane(string(activePaneID)) | |
| 827 | + | if stackedView != nil { | |
| 828 | + | // Capture stackNode for the callback | |
| 829 | + | capturedStackNode := stackNode | |
| 830 | + | stackedView.SetOnActivate(func(index int) { | |
| 831 | + | a.handleTitleBarClick(ctx, capturedStackNode, stackedView, index) | |
| 832 | + | }) | |
| 833 | + | } | |
| 834 | + | } | |
| 835 | + | ||
| 836 | + | log.Info(). | |
| 837 | + | Str("original_pane", string(activePaneID)). | |
| 838 | + | Str("new_pane", string(newPaneID)). | |
| 839 | + | Int("stack_size", len(stackNode.Children)). | |
| 840 | + | Msg("stacked new pane") | |
| 841 | + | ||
| 842 | + | return nil | |
| 843 | + | } | |
| 844 | + | ||
| 845 | + | // handleStackNavigate navigates up or down within a stacked pane container. | |
| 846 | + | // With the new architecture, every pane is in a StackedView - we just navigate within it. | |
| 847 | + | func (a *App) handleStackNavigate(ctx context.Context, direction string) error { | |
| 848 | + | log := logging.FromContext(ctx) | |
| 849 | + | ||
| 850 | + | if a.stackedPaneMgr == nil { | |
| 851 | + | return nil | |
| 852 | + | } | |
| 853 | + | ||
| 854 | + | ws := a.activeWorkspace() | |
| 855 | + | if ws == nil { | |
| 856 | + | return nil | |
| 857 | + | } | |
| 858 | + | ||
| 859 | + | activePane := ws.ActivePane() | |
| 860 | + | if activePane == nil || activePane.Pane == nil { | |
| 861 | + | return nil | |
| 862 | + | } | |
| 863 | + | ||
| 864 | + | wsView := a.activeWorkspaceView() | |
| 865 | + | if wsView == nil { | |
| 866 | + | return nil | |
| 867 | + | } | |
| 868 | + | ||
| 869 | + | currentPaneID := activePane.Pane.ID | |
| 870 | + | ||
| 871 | + | // Check if we're actually in a multi-pane stack | |
| 872 | + | if !a.stackedPaneMgr.IsStacked(wsView, currentPaneID) { | |
| 873 | + | log.Debug().Msg("current pane is not in a multi-pane stack") | |
| 874 | + | return nil | |
| 875 | + | } | |
| 876 | + | ||
| 877 | + | // Navigate within the stack | |
| 878 | + | _, err := a.stackedPaneMgr.NavigateStack(ctx, wsView, currentPaneID, direction) | |
| 879 | + | if err != nil { | |
| 880 | + | log.Warn().Err(err).Str("direction", direction).Msg("failed to navigate stack") | |
| 881 | + | return err | |
| 882 | + | } | |
| 883 | + | ||
| 884 | + | // Note: With the current StackedView navigation, the new pane ID is not returned | |
| 885 | + | // because the mapping from visual index to pane ID requires additional tracking. | |
| 886 | + | // For now, the StackedView handles the visual navigation. | |
| 887 | + | // TODO: Add pane ID tracking to StackedView for proper domain model sync | |
| 888 | + | ||
| 889 | + | log.Debug(). | |
| 890 | + | Str("direction", direction). | |
| 891 | + | Str("current_pane", string(currentPaneID)). | |
| 892 | + | Msg("navigated stack") | |
| 893 | + | ||
| 684 | 894 | return nil | |
| 685 | 895 | } | |
| 686 | 896 | ||
| 897 | + | // handleStackPaneActivated is called when a pane in a stack is activated via title bar click. | |
| 898 | + | // This is a stub for future use when title bar click callbacks are wired up. | |
| 899 | + | // The paneID parameter would come from the StackedView's OnActivate callback. | |
| 900 | + | func (a *App) handleStackPaneActivated(ctx context.Context, paneID entity.PaneID) { | |
| 901 | + | log := logging.FromContext(ctx) | |
| 902 | + | ||
| 903 | + | if paneID == "" { | |
| 904 | + | return | |
| 905 | + | } | |
| 906 | + | ||
| 907 | + | // Update workspace active pane | |
| 908 | + | ws := a.activeWorkspace() | |
| 909 | + | if ws != nil { | |
| 910 | + | ws.ActivePaneID = paneID | |
| 911 | + | } | |
| 912 | + | ||
| 913 | + | // Update workspace view | |
| 914 | + | wsView := a.activeWorkspaceView() | |
| 915 | + | if wsView != nil { | |
| 916 | + | if err := wsView.SetActivePaneID(paneID); err != nil { | |
| 917 | + | log.Warn().Err(err).Msg("failed to set active pane after stack activation") | |
| 918 | + | } | |
| 919 | + | } | |
| 920 | + | ||
| 921 | + | log.Debug(). | |
| 922 | + | Str("pane_id", string(paneID)). | |
| 923 | + | Msg("stack pane activated") | |
| 924 | + | } | |
| 925 | + | ||
| 926 | + | // handleTitleBarClick handles clicks on title bars to switch the active pane in a stack. | |
| 927 | + | func (a *App) handleTitleBarClick(ctx context.Context, stackNode *entity.PaneNode, sv *layout.StackedView, clickedIndex int) { | |
| 928 | + | log := logging.FromContext(ctx) | |
| 929 | + | ||
| 930 | + | if stackNode == nil || sv == nil { | |
| 931 | + | return | |
| 932 | + | } | |
| 933 | + | ||
| 934 | + | // Validate index | |
| 935 | + | if clickedIndex < 0 || clickedIndex >= len(stackNode.Children) { | |
| 936 | + | log.Warn().Int("index", clickedIndex).Int("children", len(stackNode.Children)).Msg("invalid stack index clicked") | |
| 937 | + | return | |
| 938 | + | } | |
| 939 | + | ||
| 940 | + | // Get the current active index | |
| 941 | + | currentIndex := sv.ActiveIndex() | |
| 942 | + | if clickedIndex == currentIndex { | |
| 943 | + | log.Debug().Int("index", clickedIndex).Msg("clicked pane is already active") | |
| 944 | + | return | |
| 945 | + | } | |
| 946 | + | ||
| 947 | + | // Update the outgoing pane's title bar with its current webpage title | |
| 948 | + | if currentIndex >= 0 && currentIndex < len(stackNode.Children) { | |
| 949 | + | outgoingChild := stackNode.Children[currentIndex] | |
| 950 | + | if outgoingChild.Pane != nil { | |
| 951 | + | outgoingPaneID := outgoingChild.Pane.ID | |
| 952 | + | outgoingTitle := a.getPaneTitle(outgoingPaneID) | |
| 953 | + | if outgoingTitle == "" { | |
| 954 | + | outgoingTitle = outgoingChild.Pane.Title | |
| 955 | + | } | |
| 956 | + | if outgoingTitle != "" { | |
| 957 | + | if err := sv.UpdateTitle(currentIndex, outgoingTitle); err != nil { | |
| 958 | + | log.Warn().Err(err).Msg("failed to update outgoing pane title") | |
| 959 | + | } | |
| 960 | + | } | |
| 961 | + | } | |
| 962 | + | } | |
| 963 | + | ||
| 964 | + | // Get the pane ID at the clicked index | |
| 965 | + | clickedChild := stackNode.Children[clickedIndex] | |
| 966 | + | if clickedChild.Pane == nil { | |
| 967 | + | log.Warn().Int("index", clickedIndex).Msg("clicked child has no pane") | |
| 968 | + | return | |
| 969 | + | } | |
| 970 | + | clickedPaneID := clickedChild.Pane.ID | |
| 971 | + | ||
| 972 | + | // Update StackedView active index | |
| 973 | + | if err := sv.SetActive(ctx, clickedIndex); err != nil { | |
| 974 | + | log.Warn().Err(err).Int("index", clickedIndex).Msg("failed to set active pane in stack") | |
| 975 | + | return | |
| 976 | + | } | |
| 977 | + | ||
| 978 | + | // Update domain model | |
| 979 | + | stackNode.ActiveStackIndex = clickedIndex | |
| 980 | + | ||
| 981 | + | // Update workspace active pane | |
| 982 | + | ws := a.activeWorkspace() | |
| 983 | + | if ws != nil { | |
| 984 | + | ws.ActivePaneID = clickedPaneID | |
| 985 | + | } | |
| 986 | + | ||
| 987 | + | // Update workspace view | |
| 988 | + | wsView := a.activeWorkspaceView() | |
| 989 | + | if wsView != nil { | |
| 990 | + | if err := wsView.SetActivePaneID(clickedPaneID); err != nil { | |
| 991 | + | log.Warn().Err(err).Msg("failed to set active pane in workspace view") | |
| 992 | + | } | |
| 993 | + | } | |
| 994 | + | ||
| 995 | + | log.Info(). | |
| 996 | + | Int("from_index", currentIndex). | |
| 997 | + | Int("to_index", clickedIndex). | |
| 998 | + | Str("pane_id", string(clickedPaneID)). | |
| 999 | + | Msg("switched active pane via title bar click") | |
| 1000 | + | } | |
| 1001 | + | ||
| 687 | 1002 | // attachWorkspaceWebViews ensures each pane has a WebView widget attached. | |
| 688 | 1003 | func (a *App) attachWorkspaceWebViews(ctx context.Context, ws *entity.Workspace, wsView *component.WorkspaceView) { | |
| 689 | 1004 | log := logging.FromContext(ctx) | |
| @@ -736,9 +1051,95 @@ func (a *App) ensureWebViewForPane(ctx context.Context, paneID entity.PaneID) (* | |||
| 736 | 1051 | } | |
| 737 | 1052 | ||
| 738 | 1053 | a.webViews[paneID] = wv | |
| 1054 | + | ||
| 1055 | + | // Set up title change callback to track dynamic titles | |
| 1056 | + | wv.OnTitleChanged = func(title string) { | |
| 1057 | + | a.handlePaneTitleChanged(ctx, paneID, title) | |
| 1058 | + | } | |
| 1059 | + | ||
| 739 | 1060 | return wv, nil | |
| 740 | 1061 | } | |
| 741 | 1062 | ||
| 1063 | + | // handlePaneTitleChanged updates title tracking when a WebView's title changes. | |
| 1064 | + | func (a *App) handlePaneTitleChanged(ctx context.Context, paneID entity.PaneID, title string) { | |
| 1065 | + | log := logging.FromContext(ctx) | |
| 1066 | + | ||
| 1067 | + | // Update title map | |
| 1068 | + | a.titleMu.Lock() | |
| 1069 | + | a.paneTitles[paneID] = title | |
| 1070 | + | a.titleMu.Unlock() | |
| 1071 | + | ||
| 1072 | + | // Update domain model | |
| 1073 | + | ws := a.activeWorkspace() | |
| 1074 | + | if ws != nil { | |
| 1075 | + | paneNode := ws.FindPane(paneID) | |
| 1076 | + | if paneNode != nil && paneNode.Pane != nil { | |
| 1077 | + | paneNode.Pane.Title = title | |
| 1078 | + | } | |
| 1079 | + | } | |
| 1080 | + | ||
| 1081 | + | // Update StackedView title bar if this pane is in a stack | |
| 1082 | + | wsView := a.activeWorkspaceView() | |
| 1083 | + | if wsView != nil { | |
| 1084 | + | tr := wsView.TreeRenderer() | |
| 1085 | + | if tr != nil { | |
| 1086 | + | stackedView := tr.GetStackedViewForPane(string(paneID)) | |
| 1087 | + | if stackedView != nil { | |
| 1088 | + | // Find the pane's index in the stack and update title | |
| 1089 | + | a.updateStackedPaneTitle(ctx, stackedView, paneID, title) | |
| 1090 | + | } | |
| 1091 | + | } | |
| 1092 | + | } | |
| 1093 | + | ||
| 1094 | + | log.Debug(). | |
| 1095 | + | Str("pane_id", string(paneID)). | |
| 1096 | + | Str("title", title). | |
| 1097 | + | Msg("pane title updated") | |
| 1098 | + | } | |
| 1099 | + | ||
| 1100 | + | // updateStackedPaneTitle updates the title of a pane in a StackedView. | |
| 1101 | + | func (a *App) updateStackedPaneTitle(ctx context.Context, sv *layout.StackedView, paneID entity.PaneID, title string) { | |
| 1102 | + | // We need to find which index this pane is at in the stack | |
| 1103 | + | // For now, we track by iterating - could be optimized with reverse mapping | |
| 1104 | + | wsView := a.activeWorkspaceView() | |
| 1105 | + | if wsView == nil { | |
| 1106 | + | return | |
| 1107 | + | } | |
| 1108 | + | ||
| 1109 | + | tr := wsView.TreeRenderer() | |
| 1110 | + | if tr == nil { | |
| 1111 | + | return | |
| 1112 | + | } | |
| 1113 | + | ||
| 1114 | + | // Find the stack node in the domain to get the index | |
| 1115 | + | ws := a.activeWorkspace() | |
| 1116 | + | if ws == nil { | |
| 1117 | + | return | |
| 1118 | + | } | |
| 1119 | + | ||
| 1120 | + | paneNode := ws.FindPane(paneID) | |
| 1121 | + | if paneNode == nil { | |
| 1122 | + | return | |
| 1123 | + | } | |
| 1124 | + | ||
| 1125 | + | // If the pane is in a stacked parent, find its index | |
| 1126 | + | if paneNode.Parent != nil && paneNode.Parent.IsStacked { | |
| 1127 | + | for i, child := range paneNode.Parent.Children { | |
| 1128 | + | if child.Pane != nil && child.Pane.ID == paneID { | |
| 1129 | + | _ = sv.UpdateTitle(i, title) | |
| 1130 | + | return | |
| 1131 | + | } | |
| 1132 | + | } | |
| 1133 | + | } | |
| 1134 | + | } | |
| 1135 | + | ||
| 1136 | + | // getPaneTitle returns the current title for a pane. | |
| 1137 | + | func (a *App) getPaneTitle(paneID entity.PaneID) string { | |
| 1138 | + | a.titleMu.RLock() | |
| 1139 | + | defer a.titleMu.RUnlock() | |
| 1140 | + | return a.paneTitles[paneID] | |
| 1141 | + | } | |
| 1142 | + | ||
| 742 | 1143 | // releasePaneWebView returns the WebView for a pane to the pool. | |
| 743 | 1144 | func (a *App) releasePaneWebView(ctx context.Context, paneID entity.PaneID) { | |
| 744 | 1145 | wv, ok := a.webViews[paneID] | |
| @@ -39,6 +39,7 @@ type App struct { | |||||
| 39 | panesUC *usecase.ManagePanesUseCase | 39 | panesUC *usecase.ManagePanesUseCase | ||
| 40 | workspaceViews map[entity.TabID]*component.WorkspaceView | 40 | workspaceViews map[entity.TabID]*component.WorkspaceView | ||
| 41 | widgetFactory layout.WidgetFactory | 41 | widgetFactory layout.WidgetFactory | ||
| 42 | + | stackedPaneMgr *component.StackedPaneManager | |||
| 42 | 43 | ||||
| 43 | // Input handling | 44 | // Input handling | ||
| 44 | keyboardHandler *input.KeyboardHandler | 45 | keyboardHandler *input.KeyboardHandler | ||
| @@ -47,11 +48,13 @@ type App struct { | |||||
| 47 | omnibox *component.Omnibox | 48 | omnibox *component.Omnibox | ||
| 48 | 49 | ||||
| 49 | // Web content | 50 | // Web content | ||
| 50 | - | pool *webkit.WebViewPool | 51 | + | pool *webkit.WebViewPool |
| 51 | - | injector *webkit.ContentInjector | 52 | + | injector *webkit.ContentInjector |
| 52 | - | router *webkit.MessageRouter | 53 | + | router *webkit.MessageRouter |
| 53 | - | settings *webkit.SettingsManager | 54 | + | settings *webkit.SettingsManager |
| 54 | - | webViews map[entity.PaneID]*webkit.WebView | 55 | + | webViews map[entity.PaneID]*webkit.WebView |
| 56 | + | paneTitles map[entity.PaneID]string // Dynamic title tracking | |||
| 57 | + | titleMu sync.RWMutex | |||
| 55 | 58 | ||||
| 56 | // ID generator for tabs/panes | 59 | // ID generator for tabs/panes | ||
| 57 | idCounter uint64 | 60 | idCounter uint64 | ||
| @@ -80,6 +83,7 @@ func New(deps *Dependencies) (*App, error) { | |||||
| 80 | router: deps.MessageRouter, | 83 | router: deps.MessageRouter, | ||
| 81 | settings: deps.Settings, | 84 | settings: deps.Settings, | ||
| 82 | webViews: make(map[entity.PaneID]*webkit.WebView), | 85 | webViews: make(map[entity.PaneID]*webkit.WebView), | ||
| 86 | + | paneTitles: make(map[entity.PaneID]string), | |||
| 83 | cancel: cancel, | 87 | cancel: cancel, | ||
| 84 | } | 88 | } | ||
| 85 | if app.router == nil { | 89 | if app.router == nil { | ||
| @@ -149,6 +153,9 @@ func (a *App) onActivate(ctx context.Context) { | |||||
| 149 | // Initialize widget factory for pane layout | 153 | // Initialize widget factory for pane layout | ||
| 150 | a.widgetFactory = layout.NewGtkWidgetFactory() | 154 | a.widgetFactory = layout.NewGtkWidgetFactory() | ||
| 151 | 155 | ||||
| 156 | + | // Initialize stacked pane manager for incremental widget operations | |||
| 157 | + | a.stackedPaneMgr = component.NewStackedPaneManager(a.widgetFactory) | |||
| 158 | + | ||||
| 152 | // Create keyboard handler | 159 | // Create keyboard handler | ||
| 153 | a.keyboardHandler = input.NewKeyboardHandler( | 160 | a.keyboardHandler = input.NewKeyboardHandler( | ||
| 154 | ctx, | 161 | ctx, | ||
| @@ -311,6 +318,12 @@ func (a *App) handleKeyboardAction(ctx context.Context, action input.Action) err | |||||
| 311 | case input.ActionFocusDown: | 318 | case input.ActionFocusDown: | ||
| 312 | return a.handlePaneFocus(ctx, usecase.NavDown) | 319 | return a.handlePaneFocus(ctx, usecase.NavDown) | ||
| 313 | 320 | ||||
| 321 | + | // Stack navigation | |||
| 322 | + | case input.ActionStackNavUp: | |||
| 323 | + | return a.handleStackNavigate(ctx, "up") | |||
| 324 | + | case input.ActionStackNavDown: | |||
| 325 | + | return a.handleStackNavigate(ctx, "down") | |||
| 326 | + | ||||
| 314 | // Navigation (stub implementations for now) | 327 | // Navigation (stub implementations for now) | ||
| 315 | case input.ActionGoBack: | 328 | case input.ActionGoBack: | ||
| 316 | log.Debug().Msg("go back action (not yet implemented)") | 329 | log.Debug().Msg("go back action (not yet implemented)") | ||
| @@ -670,20 +683,322 @@ func (a *App) handlePaneFocus(ctx context.Context, direction usecase.NavigateDir | |||||
| 670 | return nil | 683 | return nil | ||
| 671 | } | 684 | } | ||
| 672 | 685 | ||||
| 673 | - | // handleStackPane converts the active pane into a stacked container. | 686 | + | // handleStackPane adds a new pane stacked on top of the active pane. |
| 674 | - | // TODO: Implement incremental widget operations for stacked panes (like old repo). | 687 | + | // This modifies both the domain tree and the UI StackedView. |
| 675 | - | // Currently, stacked panes require incremental GTK widget manipulation rather than | |||
| 676 | - | // full tree rebuilds because WebViews maintain state and parent references. | |||
| 677 | func (a *App) handleStackPane(ctx context.Context) error { | 688 | func (a *App) handleStackPane(ctx context.Context) error { | ||
| 678 | log := logging.FromContext(ctx) | 689 | log := logging.FromContext(ctx) | ||
| 679 | 690 | ||||
| 680 | - | // Stacked panes are not yet fully supported in the new architecture. | 691 | + | if a.stackedPaneMgr == nil { |
| 681 | - | // The rebuild approach conflicts with WebView's stateful widget lifecycle. | 692 | + | log.Warn().Msg("stacked pane manager not available") |
| 682 | - | // See dumber-refs/internal/app/browser/stacked_panes.go for incremental approach. | 693 | + | return nil |
| 683 | - | log.Warn().Msg("stacked panes not yet implemented in clean architecture - requires incremental widget operations") | 694 | + | } |
| 695 | + | ||||
| 696 | + | ws := a.activeWorkspace() | |||
| 697 | + | if ws == nil { | |||
| 698 | + | log.Warn().Msg("no active workspace") | |||
| 699 | + | return nil | |||
| 700 | + | } | |||
| 701 | + | ||||
| 702 | + | activeNode := ws.ActivePane() | |||
| 703 | + | if activeNode == nil || activeNode.Pane == nil { | |||
| 704 | + | log.Warn().Msg("no active pane") | |||
| 705 | + | return nil | |||
| 706 | + | } | |||
| 707 | + | ||||
| 708 | + | wsView := a.activeWorkspaceView() | |||
| 709 | + | if wsView == nil { | |||
| 710 | + | log.Warn().Msg("no workspace view") | |||
| 711 | + | return nil | |||
| 712 | + | } | |||
| 713 | + | ||||
| 714 | + | activePaneID := activeNode.Pane.ID | |||
| 715 | + | ||||
| 716 | + | // Create a new pane entity | |||
| 717 | + | newPaneID := entity.PaneID(a.generateID()) | |||
| 718 | + | newPane := entity.NewPane(newPaneID) | |||
| 719 | + | newPane.URI = "about:blank" | |||
| 720 | + | newPane.Title = "Untitled" | |||
| 721 | + | ||||
| 722 | + | // Get the original pane's current title | |||
| 723 | + | originalTitle := a.getPaneTitle(activePaneID) | |||
| 724 | + | if originalTitle == "" { | |||
| 725 | + | originalTitle = activeNode.Pane.Title | |||
| 726 | + | } | |||
| 727 | + | if originalTitle == "" { | |||
| 728 | + | originalTitle = "Untitled" | |||
| 729 | + | } | |||
| 730 | + | ||||
| 731 | + | // Update domain model: convert leaf to stacked if needed, add new pane | |||
| 732 | + | var stackNode *entity.PaneNode | |||
| 733 | + | var needsFirstPaneTitleUpdate bool | |||
| 734 | + | if activeNode.IsStacked { | |||
| 735 | + | // Already stacked, just add to it | |||
| 736 | + | stackNode = activeNode | |||
| 737 | + | } else { | |||
| 738 | + | // Convert leaf node to stacked container | |||
| 739 | + | // Move current pane to a child node | |||
| 740 | + | originalPane := activeNode.Pane | |||
| 741 | + | originalPane.Title = originalTitle // Ensure title is set | |||
| 742 | + | originalPaneChild := &entity.PaneNode{ | |||
| 743 | + | ID: activeNode.ID + "_0", | |||
| 744 | + | Pane: originalPane, | |||
| 745 | + | Parent: activeNode, | |||
| 746 | + | } | |||
| 747 | + | ||||
| 748 | + | activeNode.Pane = nil // No longer a leaf | |||
| 749 | + | activeNode.IsStacked = true | |||
| 750 | + | activeNode.Children = []*entity.PaneNode{originalPaneChild} | |||
| 751 | + | stackNode = activeNode | |||
| 752 | + | needsFirstPaneTitleUpdate = true | |||
| 753 | + | ||||
| 754 | + | log.Debug(). | |||
| 755 | + | Str("node_id", activeNode.ID). | |||
| 756 | + | Str("original_pane", string(originalPane.ID)). | |||
| 757 | + | Str("original_title", originalTitle). | |||
| 758 | + | Msg("converted leaf to stacked node") | |||
| 759 | + | } | |||
| 760 | + | ||||
| 761 | + | // Create new child node for the new pane | |||
| 762 | + | newChildNode := &entity.PaneNode{ | |||
| 763 | + | ID: stackNode.ID + "_" + string(newPaneID), | |||
| 764 | + | Pane: newPane, | |||
| 765 | + | Parent: stackNode, | |||
| 766 | + | } | |||
| 767 | + | stackNode.Children = append(stackNode.Children, newChildNode) | |||
| 768 | + | stackNode.ActiveStackIndex = len(stackNode.Children) - 1 | |||
| 769 | + | ||||
| 770 | + | log.Debug(). | |||
| 771 | + | Int("stack_size", len(stackNode.Children)). | |||
| 772 | + | Int("active_index", stackNode.ActiveStackIndex). | |||
| 773 | + | Msg("domain tree updated") | |||
| 774 | + | ||||
| 775 | + | // Create PaneView for the new pane | |||
| 776 | + | newPaneView := component.NewPaneView(a.widgetFactory, newPaneID, nil) | |||
| 777 | + | wsView.RegisterPaneView(newPaneID, newPaneView) | |||
| 778 | + | ||||
| 779 | + | // Add to the UI StackedView | |||
| 780 | + | if err := a.stackedPaneMgr.AddPaneToStack(ctx, wsView, activePaneID, newPaneView, "Untitled"); err != nil { | |||
| 781 | + | log.Error().Err(err).Msg("failed to add pane to stack") | |||
| 782 | + | return err | |||
| 783 | + | } | |||
| 784 | + | ||||
| 785 | + | // Update the first pane's title if we just converted from leaf to stacked | |||
| 786 | + | if needsFirstPaneTitleUpdate { | |||
| 787 | + | tr := wsView.TreeRenderer() | |||
| 788 | + | if tr != nil { | |||
| 789 | + | stackedView := tr.GetStackedViewForPane(string(activePaneID)) | |||
| 790 | + | if stackedView != nil { | |||
| 791 | + | if err := stackedView.UpdateTitle(0, originalTitle); err != nil { | |||
| 792 | + | log.Warn().Err(err).Str("title", originalTitle).Msg("failed to update first pane title") | |||
| 793 | + | } else { | |||
| 794 | + | log.Debug().Str("title", originalTitle).Msg("updated first pane title in StackedView") | |||
| 795 | + | } | |||
| 796 | + | } | |||
| 797 | + | } | |||
| 798 | + | } | |||
| 799 | + | ||||
| 800 | + | // Get WebView and attach | |||
| 801 | + | wv, err := a.ensureWebViewForPane(ctx, newPaneID) | |||
| 802 | + | if err != nil { | |||
| 803 | + | log.Warn().Err(err).Msg("failed to get webview for new pane") | |||
| 804 | + | } else { | |||
| 805 | + | widget := a.wrapWebViewWidget(ctx, wv) | |||
| 806 | + | if widget != nil { | |||
| 807 | + | newPaneView.SetWebViewWidget(widget) | |||
| 808 | + | } | |||
| 809 | + | // Load blank page | |||
| 810 | + | if err := wv.LoadURI(ctx, "about:blank"); err != nil { | |||
| 811 | + | log.Warn().Err(err).Msg("failed to load blank page") | |||
| 812 | + | } | |||
| 813 | + | } | |||
| 814 | + | ||||
| 815 | + | // Update workspace active pane ID | |||
| 816 | + | ws.ActivePaneID = newPaneID | |||
| 817 | + | ||||
| 818 | + | // Update workspace view | |||
| 819 | + | if err := wsView.SetActivePaneID(newPaneID); err != nil { | |||
| 820 | + | log.Warn().Err(err).Msg("failed to set active pane") | |||
| 821 | + | } | |||
| 822 | + | ||||
| 823 | + | // Set up title bar click callback | |||
| 824 | + | tr := wsView.TreeRenderer() | |||
| 825 | + | if tr != nil { | |||
| 826 | + | stackedView := tr.GetStackedViewForPane(string(activePaneID)) | |||
| 827 | + | if stackedView != nil { | |||
| 828 | + | // Capture stackNode for the callback | |||
| 829 | + | capturedStackNode := stackNode | |||
| 830 | + | stackedView.SetOnActivate(func(index int) { | |||
| 831 | + | a.handleTitleBarClick(ctx, capturedStackNode, stackedView, index) | |||
| 832 | + | }) | |||
| 833 | + | } | |||
| 834 | + | } | |||
| 835 | + | ||||
| 836 | + | log.Info(). | |||
| 837 | + | Str("original_pane", string(activePaneID)). | |||
| 838 | + | Str("new_pane", string(newPaneID)). | |||
| 839 | + | Int("stack_size", len(stackNode.Children)). | |||
| 840 | + | Msg("stacked new pane") | |||
| 841 | + | ||||
| 842 | + | return nil | |||
| 843 | + | } | |||
| 844 | + | ||||
| 845 | + | // handleStackNavigate navigates up or down within a stacked pane container. | |||
| 846 | + | // With the new architecture, every pane is in a StackedView - we just navigate within it. | |||
| 847 | + | func (a *App) handleStackNavigate(ctx context.Context, direction string) error { | |||
| 848 | + | log := logging.FromContext(ctx) | |||
| 849 | + | ||||
| 850 | + | if a.stackedPaneMgr == nil { | |||
| 851 | + | return nil | |||
| 852 | + | } | |||
| 853 | + | ||||
| 854 | + | ws := a.activeWorkspace() | |||
| 855 | + | if ws == nil { | |||
| 856 | + | return nil | |||
| 857 | + | } | |||
| 858 | + | ||||
| 859 | + | activePane := ws.ActivePane() | |||
| 860 | + | if activePane == nil || activePane.Pane == nil { | |||
| 861 | + | return nil | |||
| 862 | + | } | |||
| 863 | + | ||||
| 864 | + | wsView := a.activeWorkspaceView() | |||
| 865 | + | if wsView == nil { | |||
| 866 | + | return nil | |||
| 867 | + | } | |||
| 868 | + | ||||
| 869 | + | currentPaneID := activePane.Pane.ID | |||
| 870 | + | ||||
| 871 | + | // Check if we're actually in a multi-pane stack | |||
| 872 | + | if !a.stackedPaneMgr.IsStacked(wsView, currentPaneID) { | |||
| 873 | + | log.Debug().Msg("current pane is not in a multi-pane stack") | |||
| 874 | + | return nil | |||
| 875 | + | } | |||
| 876 | + | ||||
| 877 | + | // Navigate within the stack | |||
| 878 | + | _, err := a.stackedPaneMgr.NavigateStack(ctx, wsView, currentPaneID, direction) | |||
| 879 | + | if err != nil { | |||
| 880 | + | log.Warn().Err(err).Str("direction", direction).Msg("failed to navigate stack") | |||
| 881 | + | return err | |||
| 882 | + | } | |||
| 883 | + | ||||
| 884 | + | // Note: With the current StackedView navigation, the new pane ID is not returned | |||
| 885 | + | // because the mapping from visual index to pane ID requires additional tracking. | |||
| 886 | + | // For now, the StackedView handles the visual navigation. | |||
| 887 | + | // TODO: Add pane ID tracking to StackedView for proper domain model sync | |||
| 888 | + | ||||
| 889 | + | log.Debug(). | |||
| 890 | + | Str("direction", direction). | |||
| 891 | + | Str("current_pane", string(currentPaneID)). | |||
| 892 | + | Msg("navigated stack") | |||
| 893 | + | ||||
| 684 | return nil | 894 | return nil | ||
| 685 | } | 895 | } | ||
| 686 | 896 | ||||
| 897 | + | // handleStackPaneActivated is called when a pane in a stack is activated via title bar click. | |||
| 898 | + | // This is a stub for future use when title bar click callbacks are wired up. | |||
| 899 | + | // The paneID parameter would come from the StackedView's OnActivate callback. | |||
| 900 | + | func (a *App) handleStackPaneActivated(ctx context.Context, paneID entity.PaneID) { | |||
| 901 | + | log := logging.FromContext(ctx) | |||
| 902 | + | ||||
| 903 | + | if paneID == "" { | |||
| 904 | + | return | |||
| 905 | + | } | |||
| 906 | + | ||||
| 907 | + | // Update workspace active pane | |||
| 908 | + | ws := a.activeWorkspace() | |||
| 909 | + | if ws != nil { | |||
| 910 | + | ws.ActivePaneID = paneID | |||
| 911 | + | } | |||
| 912 | + | ||||
| 913 | + | // Update workspace view | |||
| 914 | + | wsView := a.activeWorkspaceView() | |||
| 915 | + | if wsView != nil { | |||
| 916 | + | if err := wsView.SetActivePaneID(paneID); err != nil { | |||
| 917 | + | log.Warn().Err(err).Msg("failed to set active pane after stack activation") | |||
| 918 | + | } | |||
| 919 | + | } | |||
| 920 | + | ||||
| 921 | + | log.Debug(). | |||
| 922 | + | Str("pane_id", string(paneID)). | |||
| 923 | + | Msg("stack pane activated") | |||
| 924 | + | } | |||
| 925 | + | ||||
| 926 | + | // handleTitleBarClick handles clicks on title bars to switch the active pane in a stack. | |||
| 927 | + | func (a *App) handleTitleBarClick(ctx context.Context, stackNode *entity.PaneNode, sv *layout.StackedView, clickedIndex int) { | |||
| 928 | + | log := logging.FromContext(ctx) | |||
| 929 | + | ||||
| 930 | + | if stackNode == nil || sv == nil { | |||
| 931 | + | return | |||
| 932 | + | } | |||
| 933 | + | ||||
| 934 | + | // Validate index | |||
| 935 | + | if clickedIndex < 0 || clickedIndex >= len(stackNode.Children) { | |||
| 936 | + | log.Warn().Int("index", clickedIndex).Int("children", len(stackNode.Children)).Msg("invalid stack index clicked") | |||
| 937 | + | return | |||
| 938 | + | } | |||
| 939 | + | ||||
| 940 | + | // Get the current active index | |||
| 941 | + | currentIndex := sv.ActiveIndex() | |||
| 942 | + | if clickedIndex == currentIndex { | |||
| 943 | + | log.Debug().Int("index", clickedIndex).Msg("clicked pane is already active") | |||
| 944 | + | return | |||
| 945 | + | } | |||
| 946 | + | ||||
| 947 | + | // Update the outgoing pane's title bar with its current webpage title | |||
| 948 | + | if currentIndex >= 0 && currentIndex < len(stackNode.Children) { | |||
| 949 | + | outgoingChild := stackNode.Children[currentIndex] | |||
| 950 | + | if outgoingChild.Pane != nil { | |||
| 951 | + | outgoingPaneID := outgoingChild.Pane.ID | |||
| 952 | + | outgoingTitle := a.getPaneTitle(outgoingPaneID) | |||
| 953 | + | if outgoingTitle == "" { | |||
| 954 | + | outgoingTitle = outgoingChild.Pane.Title | |||
| 955 | + | } | |||
| 956 | + | if outgoingTitle != "" { | |||
| 957 | + | if err := sv.UpdateTitle(currentIndex, outgoingTitle); err != nil { | |||
| 958 | + | log.Warn().Err(err).Msg("failed to update outgoing pane title") | |||
| 959 | + | } | |||
| 960 | + | } | |||
| 961 | + | } | |||
| 962 | + | } | |||
| 963 | + | ||||
| 964 | + | // Get the pane ID at the clicked index | |||
| 965 | + | clickedChild := stackNode.Children[clickedIndex] | |||
| 966 | + | if clickedChild.Pane == nil { | |||
| 967 | + | log.Warn().Int("index", clickedIndex).Msg("clicked child has no pane") | |||
| 968 | + | return | |||
| 969 | + | } | |||
| 970 | + | clickedPaneID := clickedChild.Pane.ID | |||
| 971 | + | ||||
| 972 | + | // Update StackedView active index | |||
| 973 | + | if err := sv.SetActive(ctx, clickedIndex); err != nil { | |||
| 974 | + | log.Warn().Err(err).Int("index", clickedIndex).Msg("failed to set active pane in stack") | |||
| 975 | + | return | |||
| 976 | + | } | |||
| 977 | + | ||||
| 978 | + | // Update domain model | |||
| 979 | + | stackNode.ActiveStackIndex = clickedIndex | |||
| 980 | + | ||||
| 981 | + | // Update workspace active pane | |||
| 982 | + | ws := a.activeWorkspace() | |||
| 983 | + | if ws != nil { | |||
| 984 | + | ws.ActivePaneID = clickedPaneID | |||
| 985 | + | } | |||
| 986 | + | ||||
| 987 | + | // Update workspace view | |||
| 988 | + | wsView := a.activeWorkspaceView() | |||
| 989 | + | if wsView != nil { | |||
| 990 | + | if err := wsView.SetActivePaneID(clickedPaneID); err != nil { | |||
| 991 | + | log.Warn().Err(err).Msg("failed to set active pane in workspace view") | |||
| 992 | + | } | |||
| 993 | + | } | |||
| 994 | + | ||||
| 995 | + | log.Info(). | |||
| 996 | + | Int("from_index", currentIndex). | |||
| 997 | + | Int("to_index", clickedIndex). | |||
| 998 | + | Str("pane_id", string(clickedPaneID)). | |||
| 999 | + | Msg("switched active pane via title bar click") | |||
| 1000 | + | } | |||
| 1001 | + | ||||
| 687 | // attachWorkspaceWebViews ensures each pane has a WebView widget attached. | 1002 | // attachWorkspaceWebViews ensures each pane has a WebView widget attached. | ||
| 688 | func (a *App) attachWorkspaceWebViews(ctx context.Context, ws *entity.Workspace, wsView *component.WorkspaceView) { | 1003 | func (a *App) attachWorkspaceWebViews(ctx context.Context, ws *entity.Workspace, wsView *component.WorkspaceView) { | ||
| 689 | log := logging.FromContext(ctx) | 1004 | log := logging.FromContext(ctx) | ||
| @@ -736,9 +1051,95 @@ func (a *App) ensureWebViewForPane(ctx context.Context, paneID entity.PaneID) (* | |||||
| 736 | } | 1051 | } | ||
| 737 | 1052 | ||||
| 738 | a.webViews[paneID] = wv | 1053 | a.webViews[paneID] = wv | ||
| 1054 | + | ||||
| 1055 | + | // Set up title change callback to track dynamic titles | |||
| 1056 | + | wv.OnTitleChanged = func(title string) { | |||
| 1057 | + | a.handlePaneTitleChanged(ctx, paneID, title) | |||
| 1058 | + | } | |||
| 1059 | + | ||||
| 739 | return wv, nil | 1060 | return wv, nil | ||
| 740 | } | 1061 | } | ||
| 741 | 1062 | ||||
| 1063 | + | // handlePaneTitleChanged updates title tracking when a WebView's title changes. | |||
| 1064 | + | func (a *App) handlePaneTitleChanged(ctx context.Context, paneID entity.PaneID, title string) { | |||
| 1065 | + | log := logging.FromContext(ctx) | |||
| 1066 | + | ||||
| 1067 | + | // Update title map | |||
| 1068 | + | a.titleMu.Lock() | |||
| 1069 | + | a.paneTitles[paneID] = title | |||
| 1070 | + | a.titleMu.Unlock() | |||
| 1071 | + | ||||
| 1072 | + | // Update domain model | |||
| 1073 | + | ws := a.activeWorkspace() | |||
| 1074 | + | if ws != nil { | |||
| 1075 | + | paneNode := ws.FindPane(paneID) | |||
| 1076 | + | if paneNode != nil && paneNode.Pane != nil { | |||
| 1077 | + | paneNode.Pane.Title = title | |||
| 1078 | + | } | |||
| 1079 | + | } | |||
| 1080 | + | ||||
| 1081 | + | // Update StackedView title bar if this pane is in a stack | |||
| 1082 | + | wsView := a.activeWorkspaceView() | |||
| 1083 | + | if wsView != nil { | |||
| 1084 | + | tr := wsView.TreeRenderer() | |||
| 1085 | + | if tr != nil { | |||
| 1086 | + | stackedView := tr.GetStackedViewForPane(string(paneID)) | |||
| 1087 | + | if stackedView != nil { | |||
| 1088 | + | // Find the pane's index in the stack and update title | |||
| 1089 | + | a.updateStackedPaneTitle(ctx, stackedView, paneID, title) | |||
| 1090 | + | } | |||
| 1091 | + | } | |||
| 1092 | + | } | |||
| 1093 | + | ||||
| 1094 | + | log.Debug(). | |||
| 1095 | + | Str("pane_id", string(paneID)). | |||
| 1096 | + | Str("title", title). | |||
| 1097 | + | Msg("pane title updated") | |||
| 1098 | + | } | |||
| 1099 | + | ||||
| 1100 | + | // updateStackedPaneTitle updates the title of a pane in a StackedView. | |||
| 1101 | + | func (a *App) updateStackedPaneTitle(ctx context.Context, sv *layout.StackedView, paneID entity.PaneID, title string) { | |||
| 1102 | + | // We need to find which index this pane is at in the stack | |||
| 1103 | + | // For now, we track by iterating - could be optimized with reverse mapping | |||
| 1104 | + | wsView := a.activeWorkspaceView() | |||
| 1105 | + | if wsView == nil { | |||
| 1106 | + | return | |||
| 1107 | + | } | |||
| 1108 | + | ||||
| 1109 | + | tr := wsView.TreeRenderer() | |||
| 1110 | + | if tr == nil { | |||
| 1111 | + | return | |||
| 1112 | + | } | |||
| 1113 | + | ||||
| 1114 | + | // Find the stack node in the domain to get the index | |||
| 1115 | + | ws := a.activeWorkspace() | |||
| 1116 | + | if ws == nil { | |||
| 1117 | + | return | |||
| 1118 | + | } | |||
| 1119 | + | ||||
| 1120 | + | paneNode := ws.FindPane(paneID) | |||
| 1121 | + | if paneNode == nil { | |||
| 1122 | + | return | |||
| 1123 | + | } | |||
| 1124 | + | ||||
| 1125 | + | // If the pane is in a stacked parent, find its index | |||
| 1126 | + | if paneNode.Parent != nil && paneNode.Parent.IsStacked { | |||
| 1127 | + | for i, child := range paneNode.Parent.Children { | |||
| 1128 | + | if child.Pane != nil && child.Pane.ID == paneID { | |||
| 1129 | + | _ = sv.UpdateTitle(i, title) | |||
| 1130 | + | return | |||
| 1131 | + | } | |||
| 1132 | + | } | |||
| 1133 | + | } | |||
| 1134 | + | } | |||
| 1135 | + | ||||
| 1136 | + | // getPaneTitle returns the current title for a pane. | |||
| 1137 | + | func (a *App) getPaneTitle(paneID entity.PaneID) string { | |||
| 1138 | + | a.titleMu.RLock() | |||
| 1139 | + | defer a.titleMu.RUnlock() | |||
| 1140 | + | return a.paneTitles[paneID] | |||
| 1141 | + | } | |||
| 1142 | + | ||||
| 742 | // releasePaneWebView returns the WebView for a pane to the pool. | 1143 | // releasePaneWebView returns the WebView for a pane to the pool. | ||
| 743 | func (a *App) releasePaneWebView(ctx context.Context, paneID entity.PaneID) { | 1144 | func (a *App) releasePaneWebView(ctx context.Context, paneID entity.PaneID) { | ||
| 744 | wv, ok := a.webViews[paneID] | 1145 | wv, ok := a.webViews[paneID] | ||
A internal/ui/component/stacked_pane_manager.go +176 -0
| @@ -0,0 +1,176 @@ | |||
| 1 | + | // Package component provides UI components for the browser. | |
| 2 | + | package component | |
| 3 | + | ||
| 4 | + | import ( | |
| 5 | + | "context" | |
| 6 | + | "errors" | |
| 7 | + | ||
| 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 | + | // ErrStackNotFound is returned when a StackedView cannot be found for a pane. | |
| 14 | + | var ErrStackNotFound = errors.New("stacked view not found for pane") | |
| 15 | + | ||
| 16 | + | // StackedPaneManager handles stacked pane operations. | |
| 17 | + | // With the new architecture, every pane is wrapped in a StackedView from the start, | |
| 18 | + | // making stacking operations trivial - we just add panes to the existing StackedView. | |
| 19 | + | type StackedPaneManager struct { | |
| 20 | + | factory layout.WidgetFactory | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | // NewStackedPaneManager creates a new stacked pane manager. | |
| 24 | + | func NewStackedPaneManager(factory layout.WidgetFactory) *StackedPaneManager { | |
| 25 | + | return &StackedPaneManager{ | |
| 26 | + | factory: factory, | |
| 27 | + | } | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | // AddPaneToStack adds a new pane to the stack containing the active pane. | |
| 31 | + | // The StackedView is looked up from the TreeRenderer. | |
| 32 | + | func (spm *StackedPaneManager) AddPaneToStack( | |
| 33 | + | ctx context.Context, | |
| 34 | + | wsView *WorkspaceView, | |
| 35 | + | activePaneID entity.PaneID, | |
| 36 | + | newPaneView *PaneView, | |
| 37 | + | title string, | |
| 38 | + | ) error { | |
| 39 | + | log := logging.FromContext(ctx) | |
| 40 | + | ||
| 41 | + | // Get the StackedView containing the active pane | |
| 42 | + | tr := wsView.TreeRenderer() | |
| 43 | + | if tr == nil { | |
| 44 | + | return errors.New("tree renderer not available") | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | stackedView := tr.GetStackedViewForPane(string(activePaneID)) | |
| 48 | + | if stackedView == nil { | |
| 49 | + | return ErrStackNotFound | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | // Get the new pane's widget | |
| 53 | + | widget := newPaneView.Widget() | |
| 54 | + | if widget == nil { | |
| 55 | + | return errors.New("new pane has no widget") | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | // Ensure widget is unparented | |
| 59 | + | if widget.GetParent() != nil { | |
| 60 | + | widget.Unparent() | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | // Add to the stack (this makes it active automatically) | |
| 64 | + | if title == "" { | |
| 65 | + | title = "Untitled" | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | log.Debug(). | |
| 69 | + | Str("active_pane", string(activePaneID)). | |
| 70 | + | Str("new_pane", string(newPaneView.PaneID())). | |
| 71 | + | Str("title", title). | |
| 72 | + | Int("stack_size_before", stackedView.Count()). | |
| 73 | + | Msg("StackedPaneManager: adding pane to stack") | |
| 74 | + | ||
| 75 | + | stackedView.AddPane(ctx, title, "", widget) | |
| 76 | + | ||
| 77 | + | // Register the new pane in the TreeRenderer's tracking | |
| 78 | + | tr.RegisterPaneInStack(string(newPaneView.PaneID()), stackedView) | |
| 79 | + | ||
| 80 | + | log.Info(). | |
| 81 | + | Str("active_pane", string(activePaneID)). | |
| 82 | + | Str("new_pane", string(newPaneView.PaneID())). | |
| 83 | + | Int("stack_size", stackedView.Count()). | |
| 84 | + | Msg("added pane to stack") | |
| 85 | + | ||
| 86 | + | return nil | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | // NavigateStack moves to the next or previous pane in a stack. | |
| 90 | + | // Returns the pane ID that became active. | |
| 91 | + | func (spm *StackedPaneManager) NavigateStack( | |
| 92 | + | ctx context.Context, | |
| 93 | + | wsView *WorkspaceView, | |
| 94 | + | currentPaneID entity.PaneID, | |
| 95 | + | direction string, | |
| 96 | + | ) (entity.PaneID, error) { | |
| 97 | + | log := logging.FromContext(ctx) | |
| 98 | + | ||
| 99 | + | tr := wsView.TreeRenderer() | |
| 100 | + | if tr == nil { | |
| 101 | + | return "", errors.New("tree renderer not available") | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | stackedView := tr.GetStackedViewForPane(string(currentPaneID)) | |
| 105 | + | if stackedView == nil { | |
| 106 | + | return "", ErrStackNotFound | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | // Only navigate if there are multiple panes | |
| 110 | + | if stackedView.Count() <= 1 { | |
| 111 | + | log.Debug().Msg("only one pane in stack, nothing to navigate") | |
| 112 | + | return currentPaneID, nil | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | var err error | |
| 116 | + | switch direction { | |
| 117 | + | case "up": | |
| 118 | + | err = stackedView.NavigatePrevious(ctx) | |
| 119 | + | case "down": | |
| 120 | + | err = stackedView.NavigateNext(ctx) | |
| 121 | + | default: | |
| 122 | + | return "", errors.New("invalid direction: use 'up' or 'down'") | |
| 123 | + | } | |
| 124 | + | if err != nil { | |
| 125 | + | return "", err | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | // Get the newly active pane's widget to find its ID | |
| 129 | + | // We need to find which pane is now at the active index | |
| 130 | + | activeIndex := stackedView.ActiveIndex() | |
| 131 | + | ||
| 132 | + | // Find the pane ID at this index by looking through registered panes | |
| 133 | + | // This is a bit indirect but works with the current architecture | |
| 134 | + | log.Debug(). | |
| 135 | + | Str("current_pane", string(currentPaneID)). | |
| 136 | + | Str("direction", direction). | |
| 137 | + | Int("new_index", activeIndex). | |
| 138 | + | Msg("navigated stack") | |
| 139 | + | ||
| 140 | + | // Return empty - caller should update based on domain model | |
| 141 | + | return "", nil | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | // SetStackActiveCallback sets up the callback for when a stack pane is clicked. | |
| 145 | + | func (spm *StackedPaneManager) SetStackActiveCallback( | |
| 146 | + | wsView *WorkspaceView, | |
| 147 | + | paneID entity.PaneID, | |
| 148 | + | callback func(index int), | |
| 149 | + | ) { | |
| 150 | + | tr := wsView.TreeRenderer() | |
| 151 | + | if tr == nil { | |
| 152 | + | return | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | stackedView := tr.GetStackedViewForPane(string(paneID)) | |
| 156 | + | if stackedView == nil { | |
| 157 | + | return | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | stackedView.SetOnActivate(callback) | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | // GetStackedView returns the StackedView for a pane. | |
| 164 | + | func (spm *StackedPaneManager) GetStackedView(wsView *WorkspaceView, paneID entity.PaneID) *layout.StackedView { | |
| 165 | + | tr := wsView.TreeRenderer() | |
| 166 | + | if tr == nil { | |
| 167 | + | return nil | |
| 168 | + | } | |
| 169 | + | return tr.GetStackedViewForPane(string(paneID)) | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | // IsStacked returns true if the pane is in a stack with multiple panes. | |
| 173 | + | func (spm *StackedPaneManager) IsStacked(wsView *WorkspaceView, paneID entity.PaneID) bool { | |
| 174 | + | sv := spm.GetStackedView(wsView, paneID) | |
| 175 | + | return sv != nil && sv.Count() > 1 | |
| 176 | + | } | |
| @@ -0,0 +1,176 @@ | |||||
| 1 | + | // Package component provides UI components for the browser. | |||
| 2 | + | package component | |||
| 3 | + | ||||
| 4 | + | import ( | |||
| 5 | + | "context" | |||
| 6 | + | "errors" | |||
| 7 | + | ||||
| 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 | + | // ErrStackNotFound is returned when a StackedView cannot be found for a pane. | |||
| 14 | + | var ErrStackNotFound = errors.New("stacked view not found for pane") | |||
| 15 | + | ||||
| 16 | + | // StackedPaneManager handles stacked pane operations. | |||
| 17 | + | // With the new architecture, every pane is wrapped in a StackedView from the start, | |||
| 18 | + | // making stacking operations trivial - we just add panes to the existing StackedView. | |||
| 19 | + | type StackedPaneManager struct { | |||
| 20 | + | factory layout.WidgetFactory | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | // NewStackedPaneManager creates a new stacked pane manager. | |||
| 24 | + | func NewStackedPaneManager(factory layout.WidgetFactory) *StackedPaneManager { | |||
| 25 | + | return &StackedPaneManager{ | |||
| 26 | + | factory: factory, | |||
| 27 | + | } | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | // AddPaneToStack adds a new pane to the stack containing the active pane. | |||
| 31 | + | // The StackedView is looked up from the TreeRenderer. | |||
| 32 | + | func (spm *StackedPaneManager) AddPaneToStack( | |||
| 33 | + | ctx context.Context, | |||
| 34 | + | wsView *WorkspaceView, | |||
| 35 | + | activePaneID entity.PaneID, | |||
| 36 | + | newPaneView *PaneView, | |||
| 37 | + | title string, | |||
| 38 | + | ) error { | |||
| 39 | + | log := logging.FromContext(ctx) | |||
| 40 | + | ||||
| 41 | + | // Get the StackedView containing the active pane | |||
| 42 | + | tr := wsView.TreeRenderer() | |||
| 43 | + | if tr == nil { | |||
| 44 | + | return errors.New("tree renderer not available") | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | stackedView := tr.GetStackedViewForPane(string(activePaneID)) | |||
| 48 | + | if stackedView == nil { | |||
| 49 | + | return ErrStackNotFound | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | // Get the new pane's widget | |||
| 53 | + | widget := newPaneView.Widget() | |||
| 54 | + | if widget == nil { | |||
| 55 | + | return errors.New("new pane has no widget") | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | // Ensure widget is unparented | |||
| 59 | + | if widget.GetParent() != nil { | |||
| 60 | + | widget.Unparent() | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | // Add to the stack (this makes it active automatically) | |||
| 64 | + | if title == "" { | |||
| 65 | + | title = "Untitled" | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | log.Debug(). | |||
| 69 | + | Str("active_pane", string(activePaneID)). | |||
| 70 | + | Str("new_pane", string(newPaneView.PaneID())). | |||
| 71 | + | Str("title", title). | |||
| 72 | + | Int("stack_size_before", stackedView.Count()). | |||
| 73 | + | Msg("StackedPaneManager: adding pane to stack") | |||
| 74 | + | ||||
| 75 | + | stackedView.AddPane(ctx, title, "", widget) | |||
| 76 | + | ||||
| 77 | + | // Register the new pane in the TreeRenderer's tracking | |||
| 78 | + | tr.RegisterPaneInStack(string(newPaneView.PaneID()), stackedView) | |||
| 79 | + | ||||
| 80 | + | log.Info(). | |||
| 81 | + | Str("active_pane", string(activePaneID)). | |||
| 82 | + | Str("new_pane", string(newPaneView.PaneID())). | |||
| 83 | + | Int("stack_size", stackedView.Count()). | |||
| 84 | + | Msg("added pane to stack") | |||
| 85 | + | ||||
| 86 | + | return nil | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | // NavigateStack moves to the next or previous pane in a stack. | |||
| 90 | + | // Returns the pane ID that became active. | |||
| 91 | + | func (spm *StackedPaneManager) NavigateStack( | |||
| 92 | + | ctx context.Context, | |||
| 93 | + | wsView *WorkspaceView, | |||
| 94 | + | currentPaneID entity.PaneID, | |||
| 95 | + | direction string, | |||
| 96 | + | ) (entity.PaneID, error) { | |||
| 97 | + | log := logging.FromContext(ctx) | |||
| 98 | + | ||||
| 99 | + | tr := wsView.TreeRenderer() | |||
| 100 | + | if tr == nil { | |||
| 101 | + | return "", errors.New("tree renderer not available") | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | stackedView := tr.GetStackedViewForPane(string(currentPaneID)) | |||
| 105 | + | if stackedView == nil { | |||
| 106 | + | return "", ErrStackNotFound | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | // Only navigate if there are multiple panes | |||
| 110 | + | if stackedView.Count() <= 1 { | |||
| 111 | + | log.Debug().Msg("only one pane in stack, nothing to navigate") | |||
| 112 | + | return currentPaneID, nil | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | var err error | |||
| 116 | + | switch direction { | |||
| 117 | + | case "up": | |||
| 118 | + | err = stackedView.NavigatePrevious(ctx) | |||
| 119 | + | case "down": | |||
| 120 | + | err = stackedView.NavigateNext(ctx) | |||
| 121 | + | default: | |||
| 122 | + | return "", errors.New("invalid direction: use 'up' or 'down'") | |||
| 123 | + | } | |||
| 124 | + | if err != nil { | |||
| 125 | + | return "", err | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | // Get the newly active pane's widget to find its ID | |||
| 129 | + | // We need to find which pane is now at the active index | |||
| 130 | + | activeIndex := stackedView.ActiveIndex() | |||
| 131 | + | ||||
| 132 | + | // Find the pane ID at this index by looking through registered panes | |||
| 133 | + | // This is a bit indirect but works with the current architecture | |||
| 134 | + | log.Debug(). | |||
| 135 | + | Str("current_pane", string(currentPaneID)). | |||
| 136 | + | Str("direction", direction). | |||
| 137 | + | Int("new_index", activeIndex). | |||
| 138 | + | Msg("navigated stack") | |||
| 139 | + | ||||
| 140 | + | // Return empty - caller should update based on domain model | |||
| 141 | + | return "", nil | |||
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | // SetStackActiveCallback sets up the callback for when a stack pane is clicked. | |||
| 145 | + | func (spm *StackedPaneManager) SetStackActiveCallback( | |||
| 146 | + | wsView *WorkspaceView, | |||
| 147 | + | paneID entity.PaneID, | |||
| 148 | + | callback func(index int), | |||
| 149 | + | ) { | |||
| 150 | + | tr := wsView.TreeRenderer() | |||
| 151 | + | if tr == nil { | |||
| 152 | + | return | |||
| 153 | + | } | |||
| 154 | + | ||||
| 155 | + | stackedView := tr.GetStackedViewForPane(string(paneID)) | |||
| 156 | + | if stackedView == nil { | |||
| 157 | + | return | |||
| 158 | + | } | |||
| 159 | + | ||||
| 160 | + | stackedView.SetOnActivate(callback) | |||
| 161 | + | } | |||
| 162 | + | ||||
| 163 | + | // GetStackedView returns the StackedView for a pane. | |||
| 164 | + | func (spm *StackedPaneManager) GetStackedView(wsView *WorkspaceView, paneID entity.PaneID) *layout.StackedView { | |||
| 165 | + | tr := wsView.TreeRenderer() | |||
| 166 | + | if tr == nil { | |||
| 167 | + | return nil | |||
| 168 | + | } | |||
| 169 | + | return tr.GetStackedViewForPane(string(paneID)) | |||
| 170 | + | } | |||
| 171 | + | ||||
| 172 | + | // IsStacked returns true if the pane is in a stack with multiple panes. | |||
| 173 | + | func (spm *StackedPaneManager) IsStacked(wsView *WorkspaceView, paneID entity.PaneID) bool { | |||
| 174 | + | sv := spm.GetStackedView(wsView, paneID) | |||
| 175 | + | return sv != nil && sv.Count() > 1 | |||
| 176 | + | } | |||
M internal/ui/component/workspace_view.go +52 -0
| @@ -270,3 +270,55 @@ func (wv *WorkspaceView) Workspace() *entity.Workspace { | |||
| 270 | 270 | ||
| 271 | 271 | return wv.workspace | |
| 272 | 272 | } | |
| 273 | + | ||
| 274 | + | // RegisterPaneView adds a PaneView to the tracking map without rebuilding. | |
| 275 | + | // Use this for incremental operations like stacked panes. | |
| 276 | + | func (wv *WorkspaceView) RegisterPaneView(paneID entity.PaneID, pv *PaneView) { | |
| 277 | + | wv.mu.Lock() | |
| 278 | + | defer wv.mu.Unlock() | |
| 279 | + | ||
| 280 | + | wv.paneViews[paneID] = pv | |
| 281 | + | } | |
| 282 | + | ||
| 283 | + | // UnregisterPaneView removes a PaneView from the tracking map. | |
| 284 | + | // Use this when closing a pane incrementally. | |
| 285 | + | func (wv *WorkspaceView) UnregisterPaneView(paneID entity.PaneID) { | |
| 286 | + | wv.mu.Lock() | |
| 287 | + | defer wv.mu.Unlock() | |
| 288 | + | ||
| 289 | + | delete(wv.paneViews, paneID) | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | // GetRootWidget returns the current root widget of the workspace. | |
| 293 | + | // This is useful for incremental operations that need to modify the tree. | |
| 294 | + | func (wv *WorkspaceView) GetRootWidget() layout.Widget { | |
| 295 | + | wv.mu.RLock() | |
| 296 | + | defer wv.mu.RUnlock() | |
| 297 | + | ||
| 298 | + | return wv.rootWidget | |
| 299 | + | } | |
| 300 | + | ||
| 301 | + | // SetRootWidgetDirect replaces the root widget without rebuilding the entire tree. | |
| 302 | + | // Use this for incremental operations when converting to/from stacked panes. | |
| 303 | + | func (wv *WorkspaceView) SetRootWidgetDirect(widget layout.Widget) { | |
| 304 | + | wv.mu.Lock() | |
| 305 | + | defer wv.mu.Unlock() | |
| 306 | + | ||
| 307 | + | // Remove old root if present | |
| 308 | + | if wv.rootWidget != nil { | |
| 309 | + | wv.container.Remove(wv.rootWidget) | |
| 310 | + | } | |
| 311 | + | ||
| 312 | + | // Add new root | |
| 313 | + | if widget != nil { | |
| 314 | + | widget.SetVisible(true) | |
| 315 | + | wv.container.Append(widget) | |
| 316 | + | } | |
| 317 | + | ||
| 318 | + | wv.rootWidget = widget | |
| 319 | + | } | |
| 320 | + | ||
| 321 | + | // Factory returns the widget factory used by this workspace view. | |
| 322 | + | func (wv *WorkspaceView) Factory() layout.WidgetFactory { | |
| 323 | + | return wv.factory | |
| 324 | + | } | |
| @@ -270,3 +270,55 @@ func (wv *WorkspaceView) Workspace() *entity.Workspace { | |||||
| 270 | 270 | ||||
| 271 | return wv.workspace | 271 | return wv.workspace | ||
| 272 | } | 272 | } | ||
| 273 | + | ||||
| 274 | + | // RegisterPaneView adds a PaneView to the tracking map without rebuilding. | |||
| 275 | + | // Use this for incremental operations like stacked panes. | |||
| 276 | + | func (wv *WorkspaceView) RegisterPaneView(paneID entity.PaneID, pv *PaneView) { | |||
| 277 | + | wv.mu.Lock() | |||
| 278 | + | defer wv.mu.Unlock() | |||
| 279 | + | ||||
| 280 | + | wv.paneViews[paneID] = pv | |||
| 281 | + | } | |||
| 282 | + | ||||
| 283 | + | // UnregisterPaneView removes a PaneView from the tracking map. | |||
| 284 | + | // Use this when closing a pane incrementally. | |||
| 285 | + | func (wv *WorkspaceView) UnregisterPaneView(paneID entity.PaneID) { | |||
| 286 | + | wv.mu.Lock() | |||
| 287 | + | defer wv.mu.Unlock() | |||
| 288 | + | ||||
| 289 | + | delete(wv.paneViews, paneID) | |||
| 290 | + | } | |||
| 291 | + | ||||
| 292 | + | // GetRootWidget returns the current root widget of the workspace. | |||
| 293 | + | // This is useful for incremental operations that need to modify the tree. | |||
| 294 | + | func (wv *WorkspaceView) GetRootWidget() layout.Widget { | |||
| 295 | + | wv.mu.RLock() | |||
| 296 | + | defer wv.mu.RUnlock() | |||
| 297 | + | ||||
| 298 | + | return wv.rootWidget | |||
| 299 | + | } | |||
| 300 | + | ||||
| 301 | + | // SetRootWidgetDirect replaces the root widget without rebuilding the entire tree. | |||
| 302 | + | // Use this for incremental operations when converting to/from stacked panes. | |||
| 303 | + | func (wv *WorkspaceView) SetRootWidgetDirect(widget layout.Widget) { | |||
| 304 | + | wv.mu.Lock() | |||
| 305 | + | defer wv.mu.Unlock() | |||
| 306 | + | ||||
| 307 | + | // Remove old root if present | |||
| 308 | + | if wv.rootWidget != nil { | |||
| 309 | + | wv.container.Remove(wv.rootWidget) | |||
| 310 | + | } | |||
| 311 | + | ||||
| 312 | + | // Add new root | |||
| 313 | + | if widget != nil { | |||
| 314 | + | widget.SetVisible(true) | |||
| 315 | + | wv.container.Append(widget) | |||
| 316 | + | } | |||
| 317 | + | ||||
| 318 | + | wv.rootWidget = widget | |||
| 319 | + | } | |||
| 320 | + | ||||
| 321 | + | // Factory returns the widget factory used by this workspace view. | |||
| 322 | + | func (wv *WorkspaceView) Factory() layout.WidgetFactory { | |||
| 323 | + | return wv.factory | |||
| 324 | + | } | |||
M internal/ui/input/shortcuts.go +10 -0
| @@ -64,6 +64,10 @@ const ( | |||
| 64 | 64 | ActionFocusUp Action = "focus_up" | |
| 65 | 65 | ActionFocusDown Action = "focus_down" | |
| 66 | 66 | ||
| 67 | + | // Stack navigation (within stacked panes) | |
| 68 | + | ActionStackNavUp Action = "stack_nav_up" | |
| 69 | + | ActionStackNavDown Action = "stack_nav_down" | |
| 70 | + | ||
| 67 | 71 | // Page navigation | |
| 68 | 72 | ActionGoBack Action = "go_back" | |
| 69 | 73 | ActionGoForward Action = "go_forward" | |
| @@ -259,6 +263,12 @@ func mapConfigAction(configAction string) Action { | |||
| 259 | 263 | case "focus-down": | |
| 260 | 264 | return ActionFocusDown | |
| 261 | 265 | ||
| 266 | + | // Stack navigation | |
| 267 | + | case "stack-nav-up", "stack-up": | |
| 268 | + | return ActionStackNavUp | |
| 269 | + | case "stack-nav-down", "stack-down": | |
| 270 | + | return ActionStackNavDown | |
| 271 | + | ||
| 262 | 272 | default: | |
| 263 | 273 | return "" | |
| 264 | 274 | } | |
| @@ -64,6 +64,10 @@ const ( | |||||
| 64 | ActionFocusUp Action = "focus_up" | 64 | ActionFocusUp Action = "focus_up" | ||
| 65 | ActionFocusDown Action = "focus_down" | 65 | ActionFocusDown Action = "focus_down" | ||
| 66 | 66 | ||||
| 67 | + | // Stack navigation (within stacked panes) | |||
| 68 | + | ActionStackNavUp Action = "stack_nav_up" | |||
| 69 | + | ActionStackNavDown Action = "stack_nav_down" | |||
| 70 | + | ||||
| 67 | // Page navigation | 71 | // Page navigation | ||
| 68 | ActionGoBack Action = "go_back" | 72 | ActionGoBack Action = "go_back" | ||
| 69 | ActionGoForward Action = "go_forward" | 73 | ActionGoForward Action = "go_forward" | ||
| @@ -259,6 +263,12 @@ func mapConfigAction(configAction string) Action { | |||||
| 259 | case "focus-down": | 263 | case "focus-down": | ||
| 260 | return ActionFocusDown | 264 | return ActionFocusDown | ||
| 261 | 265 | ||||
| 266 | + | // Stack navigation | |||
| 267 | + | case "stack-nav-up", "stack-up": | |||
| 268 | + | return ActionStackNavUp | |||
| 269 | + | case "stack-nav-down", "stack-down": | |||
| 270 | + | return ActionStackNavDown | |||
| 271 | + | ||||
| 262 | default: | 272 | default: | ||
| 263 | return "" | 273 | return "" | ||
| 264 | } | 274 | } | ||
M internal/ui/layout/stacked.go +77 -15
| @@ -1,8 +1,11 @@ | |||
| 1 | 1 | package layout | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 4 | 5 | "errors" | |
| 5 | 6 | "sync" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/dumber/internal/logging" | |
| 6 | 9 | ) | |
| 7 | 10 | ||
| 8 | 11 | // ErrStackEmpty is returned when operating on an empty stack. | |
| @@ -53,13 +56,21 @@ func NewStackedView(factory WidgetFactory) *StackedView { | |||
| 53 | 56 | ||
| 54 | 57 | // AddPane adds a new pane to the stack. | |
| 55 | 58 | // The new pane becomes active (visible). | |
| 56 | - | func (sv *StackedView) AddPane(title, faviconIconName string, container Widget) int { | |
| 59 | + | func (sv *StackedView) AddPane(ctx context.Context, title, faviconIconName string, container Widget) int { | |
| 60 | + | log := logging.FromContext(ctx) | |
| 57 | 61 | sv.mu.Lock() | |
| 58 | 62 | defer sv.mu.Unlock() | |
| 59 | 63 | ||
| 60 | - | // Create title bar | |
| 64 | + | log.Debug(). | |
| 65 | + | Str("title", title). | |
| 66 | + | Bool("container_nil", container == nil). | |
| 67 | + | Int("current_count", len(sv.panes)). | |
| 68 | + | Msg("StackedView.AddPane called") | |
| 69 | + | ||
| 70 | + | // Create title bar - must not expand vertically | |
| 61 | 71 | titleBar := sv.factory.NewBox(OrientationHorizontal, 4) | |
| 62 | 72 | titleBar.AddCssClass("stacked-pane-titlebar") | |
| 73 | + | titleBar.SetVexpand(false) | |
| 63 | 74 | ||
| 64 | 75 | // Create favicon image | |
| 65 | 76 | favicon := sv.factory.NewImage() | |
| @@ -79,11 +90,13 @@ func (sv *StackedView) AddPane(title, faviconIconName string, container Widget) | |||
| 79 | 90 | label.SetXalign(0.0) | |
| 80 | 91 | titleBar.Append(label) | |
| 81 | 92 | ||
| 82 | - | // Make title bar clickable | |
| 93 | + | // Make title bar clickable - ensure it doesn't expand vertically | |
| 83 | 94 | titleButton := sv.factory.NewButton() | |
| 84 | 95 | titleButton.SetChild(titleBar) | |
| 85 | 96 | titleButton.AddCssClass("stacked-pane-title-button") | |
| 86 | 97 | titleButton.SetFocusOnClick(false) | |
| 98 | + | titleButton.SetVexpand(false) // Critical: don't let title bar expand vertically | |
| 99 | + | titleButton.SetHexpand(true) // But fill horizontal space | |
| 87 | 100 | ||
| 88 | 101 | pane := &stackedPane{ | |
| 89 | 102 | titleBar: titleBar, | |
| @@ -109,23 +122,37 @@ func (sv *StackedView) AddPane(title, faviconIconName string, container Widget) | |||
| 109 | 122 | }) | |
| 110 | 123 | ||
| 111 | 124 | // Add to container | |
| 125 | + | log.Debug(). | |
| 126 | + | Int("index", index). | |
| 127 | + | Msg("StackedView: appending titleButton and container to box") | |
| 112 | 128 | sv.box.Append(titleButton) | |
| 113 | 129 | if container != nil { | |
| 114 | 130 | sv.box.Append(container) | |
| 115 | 131 | } | |
| 116 | 132 | ||
| 117 | 133 | // Set this pane as active | |
| 118 | - | sv.setActiveInternal(index) | |
| 134 | + | sv.setActiveInternal(ctx, index) | |
| 135 | + | ||
| 136 | + | log.Debug(). | |
| 137 | + | Int("new_index", index). | |
| 138 | + | Int("new_count", len(sv.panes)). | |
| 139 | + | Msg("StackedView.AddPane completed") | |
| 119 | 140 | ||
| 120 | 141 | return index | |
| 121 | 142 | } | |
| 122 | 143 | ||
| 123 | 144 | // RemovePane removes a pane from the stack by index. | |
| 124 | 145 | // Returns an error if trying to remove the last pane. | |
| 125 | - | func (sv *StackedView) RemovePane(index int) error { | |
| 146 | + | func (sv *StackedView) RemovePane(ctx context.Context, index int) error { | |
| 147 | + | log := logging.FromContext(ctx) | |
| 126 | 148 | sv.mu.Lock() | |
| 127 | 149 | defer sv.mu.Unlock() | |
| 128 | 150 | ||
| 151 | + | log.Debug(). | |
| 152 | + | Int("index", index). | |
| 153 | + | Int("pane_count", len(sv.panes)). | |
| 154 | + | Msg("StackedView.RemovePane called") | |
| 155 | + | ||
| 129 | 156 | if len(sv.panes) == 0 { | |
| 130 | 157 | return ErrStackEmpty | |
| 131 | 158 | } | |
| @@ -162,7 +189,7 @@ func (sv *StackedView) RemovePane(index int) error { | |||
| 162 | 189 | ||
| 163 | 190 | // Update visibility | |
| 164 | 191 | if sv.activeIndex >= 0 { | |
| 165 | - | sv.updateVisibilityInternal() | |
| 192 | + | sv.updateVisibilityInternal(ctx) | |
| 166 | 193 | } | |
| 167 | 194 | ||
| 168 | 195 | return nil | |
| @@ -170,10 +197,16 @@ func (sv *StackedView) RemovePane(index int) error { | |||
| 170 | 197 | ||
| 171 | 198 | // SetActive activates the pane at the given index. | |
| 172 | 199 | // The active pane's container is shown; inactive panes show only title bars. | |
| 173 | - | func (sv *StackedView) SetActive(index int) error { | |
| 200 | + | func (sv *StackedView) SetActive(ctx context.Context, index int) error { | |
| 201 | + | log := logging.FromContext(ctx) | |
| 174 | 202 | sv.mu.Lock() | |
| 175 | 203 | defer sv.mu.Unlock() | |
| 176 | 204 | ||
| 205 | + | log.Debug(). | |
| 206 | + | Int("index", index). | |
| 207 | + | Int("pane_count", len(sv.panes)). | |
| 208 | + | Msg("StackedView.SetActive called") | |
| 209 | + | ||
| 177 | 210 | if len(sv.panes) == 0 { | |
| 178 | 211 | return ErrStackEmpty | |
| 179 | 212 | } | |
| @@ -181,18 +214,25 @@ func (sv *StackedView) SetActive(index int) error { | |||
| 181 | 214 | return ErrIndexOutOfBounds | |
| 182 | 215 | } | |
| 183 | 216 | ||
| 184 | - | sv.setActiveInternal(index) | |
| 217 | + | sv.setActiveInternal(ctx, index) | |
| 185 | 218 | return nil | |
| 186 | 219 | } | |
| 187 | 220 | ||
| 188 | 221 | // setActiveInternal sets the active pane without locking. | |
| 189 | - | func (sv *StackedView) setActiveInternal(index int) { | |
| 222 | + | func (sv *StackedView) setActiveInternal(ctx context.Context, index int) { | |
| 190 | 223 | sv.activeIndex = index | |
| 191 | - | sv.updateVisibilityInternal() | |
| 224 | + | sv.updateVisibilityInternal(ctx) | |
| 192 | 225 | } | |
| 193 | 226 | ||
| 194 | 227 | // updateVisibilityInternal updates visibility of all panes based on activeIndex. | |
| 195 | - | func (sv *StackedView) updateVisibilityInternal() { | |
| 228 | + | func (sv *StackedView) updateVisibilityInternal(ctx context.Context) { | |
| 229 | + | log := logging.FromContext(ctx) | |
| 230 | + | ||
| 231 | + | log.Debug(). | |
| 232 | + | Int("active_index", sv.activeIndex). | |
| 233 | + | Int("pane_count", len(sv.panes)). | |
| 234 | + | Msg("StackedView.updateVisibilityInternal called") | |
| 235 | + | ||
| 196 | 236 | for i, pane := range sv.panes { | |
| 197 | 237 | isActive := i == sv.activeIndex | |
| 198 | 238 | pane.isActive = isActive | |
| @@ -202,12 +242,24 @@ func (sv *StackedView) updateVisibilityInternal() { | |||
| 202 | 242 | parent := pane.titleBar.GetParent() | |
| 203 | 243 | if parent != nil { | |
| 204 | 244 | parent.SetVisible(!isActive) | |
| 245 | + | log.Debug(). | |
| 246 | + | Int("pane_index", i). | |
| 247 | + | Str("title", pane.title). | |
| 248 | + | Bool("is_active", isActive). | |
| 249 | + | Bool("titlebar_visible", !isActive). | |
| 250 | + | Msg("StackedView: set titlebar visibility") | |
| 205 | 251 | } | |
| 206 | 252 | } | |
| 207 | 253 | ||
| 208 | 254 | // Container is visible only for active pane | |
| 209 | 255 | if pane.container != nil { | |
| 210 | 256 | pane.container.SetVisible(isActive) | |
| 257 | + | log.Debug(). | |
| 258 | + | Int("pane_index", i). | |
| 259 | + | Str("title", pane.title). | |
| 260 | + | Bool("is_active", isActive). | |
| 261 | + | Bool("container_visible", isActive). | |
| 262 | + | Msg("StackedView: set container visibility") | |
| 211 | 263 | } | |
| 212 | 264 | ||
| 213 | 265 | // Update CSS classes | |
| @@ -306,7 +358,8 @@ func (sv *StackedView) Box() BoxWidget { | |||
| 306 | 358 | } | |
| 307 | 359 | ||
| 308 | 360 | // NavigateNext moves to the next pane in the stack (wraps around). | |
| 309 | - | func (sv *StackedView) NavigateNext() error { | |
| 361 | + | func (sv *StackedView) NavigateNext(ctx context.Context) error { | |
| 362 | + | log := logging.FromContext(ctx) | |
| 310 | 363 | sv.mu.Lock() | |
| 311 | 364 | defer sv.mu.Unlock() | |
| 312 | 365 | ||
| @@ -315,12 +368,17 @@ func (sv *StackedView) NavigateNext() error { | |||
| 315 | 368 | } | |
| 316 | 369 | ||
| 317 | 370 | newIndex := (sv.activeIndex + 1) % len(sv.panes) | |
| 318 | - | sv.setActiveInternal(newIndex) | |
| 371 | + | log.Debug(). | |
| 372 | + | Int("old_index", sv.activeIndex). | |
| 373 | + | Int("new_index", newIndex). | |
| 374 | + | Msg("StackedView.NavigateNext") | |
| 375 | + | sv.setActiveInternal(ctx, newIndex) | |
| 319 | 376 | return nil | |
| 320 | 377 | } | |
| 321 | 378 | ||
| 322 | 379 | // NavigatePrevious moves to the previous pane in the stack (wraps around). | |
| 323 | - | func (sv *StackedView) NavigatePrevious() error { | |
| 380 | + | func (sv *StackedView) NavigatePrevious(ctx context.Context) error { | |
| 381 | + | log := logging.FromContext(ctx) | |
| 324 | 382 | sv.mu.Lock() | |
| 325 | 383 | defer sv.mu.Unlock() | |
| 326 | 384 | ||
| @@ -329,6 +387,10 @@ func (sv *StackedView) NavigatePrevious() error { | |||
| 329 | 387 | } | |
| 330 | 388 | ||
| 331 | 389 | newIndex := (sv.activeIndex - 1 + len(sv.panes)) % len(sv.panes) | |
| 332 | - | sv.setActiveInternal(newIndex) | |
| 390 | + | log.Debug(). | |
| 391 | + | Int("old_index", sv.activeIndex). | |
| 392 | + | Int("new_index", newIndex). | |
| 393 | + | Msg("StackedView.NavigatePrevious") | |
| 394 | + | sv.setActiveInternal(ctx, newIndex) | |
| 333 | 395 | return nil | |
| 334 | 396 | } | |
| @@ -1,8 +1,11 @@ | |||||
| 1 | package layout | 1 | package layout | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "context" | |||
| 4 | "errors" | 5 | "errors" | ||
| 5 | "sync" | 6 | "sync" | ||
| 7 | + | ||||
| 8 | + | "github.com/bnema/dumber/internal/logging" | |||
| 6 | ) | 9 | ) | ||
| 7 | 10 | ||||
| 8 | // ErrStackEmpty is returned when operating on an empty stack. | 11 | // ErrStackEmpty is returned when operating on an empty stack. | ||
| @@ -53,13 +56,21 @@ func NewStackedView(factory WidgetFactory) *StackedView { | |||||
| 53 | 56 | ||||
| 54 | // AddPane adds a new pane to the stack. | 57 | // AddPane adds a new pane to the stack. | ||
| 55 | // The new pane becomes active (visible). | 58 | // The new pane becomes active (visible). | ||
| 56 | - | func (sv *StackedView) AddPane(title, faviconIconName string, container Widget) int { | 59 | + | func (sv *StackedView) AddPane(ctx context.Context, title, faviconIconName string, container Widget) int { |
| 60 | + | log := logging.FromContext(ctx) | |||
| 57 | sv.mu.Lock() | 61 | sv.mu.Lock() | ||
| 58 | defer sv.mu.Unlock() | 62 | defer sv.mu.Unlock() | ||
| 59 | 63 | ||||
| 60 | - | // Create title bar | 64 | + | log.Debug(). |
| 65 | + | Str("title", title). | |||
| 66 | + | Bool("container_nil", container == nil). | |||
| 67 | + | Int("current_count", len(sv.panes)). | |||
| 68 | + | Msg("StackedView.AddPane called") | |||
| 69 | + | ||||
| 70 | + | // Create title bar - must not expand vertically | |||
| 61 | titleBar := sv.factory.NewBox(OrientationHorizontal, 4) | 71 | titleBar := sv.factory.NewBox(OrientationHorizontal, 4) | ||
| 62 | titleBar.AddCssClass("stacked-pane-titlebar") | 72 | titleBar.AddCssClass("stacked-pane-titlebar") | ||
| 73 | + | titleBar.SetVexpand(false) | |||
| 63 | 74 | ||||
| 64 | // Create favicon image | 75 | // Create favicon image | ||
| 65 | favicon := sv.factory.NewImage() | 76 | favicon := sv.factory.NewImage() | ||
| @@ -79,11 +90,13 @@ func (sv *StackedView) AddPane(title, faviconIconName string, container Widget) | |||||
| 79 | label.SetXalign(0.0) | 90 | label.SetXalign(0.0) | ||
| 80 | titleBar.Append(label) | 91 | titleBar.Append(label) | ||
| 81 | 92 | ||||
| 82 | - | // Make title bar clickable | 93 | + | // Make title bar clickable - ensure it doesn't expand vertically |
| 83 | titleButton := sv.factory.NewButton() | 94 | titleButton := sv.factory.NewButton() | ||
| 84 | titleButton.SetChild(titleBar) | 95 | titleButton.SetChild(titleBar) | ||
| 85 | titleButton.AddCssClass("stacked-pane-title-button") | 96 | titleButton.AddCssClass("stacked-pane-title-button") | ||
| 86 | titleButton.SetFocusOnClick(false) | 97 | titleButton.SetFocusOnClick(false) | ||
| 98 | + | titleButton.SetVexpand(false) // Critical: don't let title bar expand vertically | |||
| 99 | + | titleButton.SetHexpand(true) // But fill horizontal space | |||
| 87 | 100 | ||||
| 88 | pane := &stackedPane{ | 101 | pane := &stackedPane{ | ||
| 89 | titleBar: titleBar, | 102 | titleBar: titleBar, | ||
| @@ -109,23 +122,37 @@ func (sv *StackedView) AddPane(title, faviconIconName string, container Widget) | |||||
| 109 | }) | 122 | }) | ||
| 110 | 123 | ||||
| 111 | // Add to container | 124 | // Add to container | ||
| 125 | + | log.Debug(). | |||
| 126 | + | Int("index", index). | |||
| 127 | + | Msg("StackedView: appending titleButton and container to box") | |||
| 112 | sv.box.Append(titleButton) | 128 | sv.box.Append(titleButton) | ||
| 113 | if container != nil { | 129 | if container != nil { | ||
| 114 | sv.box.Append(container) | 130 | sv.box.Append(container) | ||
| 115 | } | 131 | } | ||
| 116 | 132 | ||||
| 117 | // Set this pane as active | 133 | // Set this pane as active | ||
| 118 | - | sv.setActiveInternal(index) | 134 | + | sv.setActiveInternal(ctx, index) |
| 135 | + | ||||
| 136 | + | log.Debug(). | |||
| 137 | + | Int("new_index", index). | |||
| 138 | + | Int("new_count", len(sv.panes)). | |||
| 139 | + | Msg("StackedView.AddPane completed") | |||
| 119 | 140 | ||||
| 120 | return index | 141 | return index | ||
| 121 | } | 142 | } | ||
| 122 | 143 | ||||
| 123 | // RemovePane removes a pane from the stack by index. | 144 | // RemovePane removes a pane from the stack by index. | ||
| 124 | // Returns an error if trying to remove the last pane. | 145 | // Returns an error if trying to remove the last pane. | ||
| 125 | - | func (sv *StackedView) RemovePane(index int) error { | 146 | + | func (sv *StackedView) RemovePane(ctx context.Context, index int) error { |
| 147 | + | log := logging.FromContext(ctx) | |||
| 126 | sv.mu.Lock() | 148 | sv.mu.Lock() | ||
| 127 | defer sv.mu.Unlock() | 149 | defer sv.mu.Unlock() | ||
| 128 | 150 | ||||
| 151 | + | log.Debug(). | |||
| 152 | + | Int("index", index). | |||
| 153 | + | Int("pane_count", len(sv.panes)). | |||
| 154 | + | Msg("StackedView.RemovePane called") | |||
| 155 | + | ||||
| 129 | if len(sv.panes) == 0 { | 156 | if len(sv.panes) == 0 { | ||
| 130 | return ErrStackEmpty | 157 | return ErrStackEmpty | ||
| 131 | } | 158 | } | ||
| @@ -162,7 +189,7 @@ func (sv *StackedView) RemovePane(index int) error { | |||||
| 162 | 189 | ||||
| 163 | // Update visibility | 190 | // Update visibility | ||
| 164 | if sv.activeIndex >= 0 { | 191 | if sv.activeIndex >= 0 { | ||
| 165 | - | sv.updateVisibilityInternal() | 192 | + | sv.updateVisibilityInternal(ctx) |
| 166 | } | 193 | } | ||
| 167 | 194 | ||||
| 168 | return nil | 195 | return nil | ||
| @@ -170,10 +197,16 @@ func (sv *StackedView) RemovePane(index int) error { | |||||
| 170 | 197 | ||||
| 171 | // SetActive activates the pane at the given index. | 198 | // SetActive activates the pane at the given index. | ||
| 172 | // The active pane's container is shown; inactive panes show only title bars. | 199 | // The active pane's container is shown; inactive panes show only title bars. | ||
| 173 | - | func (sv *StackedView) SetActive(index int) error { | 200 | + | func (sv *StackedView) SetActive(ctx context.Context, index int) error { |
| 201 | + | log := logging.FromContext(ctx) | |||
| 174 | sv.mu.Lock() | 202 | sv.mu.Lock() | ||
| 175 | defer sv.mu.Unlock() | 203 | defer sv.mu.Unlock() | ||
| 176 | 204 | ||||
| 205 | + | log.Debug(). | |||
| 206 | + | Int("index", index). | |||
| 207 | + | Int("pane_count", len(sv.panes)). | |||
| 208 | + | Msg("StackedView.SetActive called") | |||
| 209 | + | ||||
| 177 | if len(sv.panes) == 0 { | 210 | if len(sv.panes) == 0 { | ||
| 178 | return ErrStackEmpty | 211 | return ErrStackEmpty | ||
| 179 | } | 212 | } | ||
| @@ -181,18 +214,25 @@ func (sv *StackedView) SetActive(index int) error { | |||||
| 181 | return ErrIndexOutOfBounds | 214 | return ErrIndexOutOfBounds | ||
| 182 | } | 215 | } | ||
| 183 | 216 | ||||
| 184 | - | sv.setActiveInternal(index) | 217 | + | sv.setActiveInternal(ctx, index) |
| 185 | return nil | 218 | return nil | ||
| 186 | } | 219 | } | ||
| 187 | 220 | ||||
| 188 | // setActiveInternal sets the active pane without locking. | 221 | // setActiveInternal sets the active pane without locking. | ||
| 189 | - | func (sv *StackedView) setActiveInternal(index int) { | 222 | + | func (sv *StackedView) setActiveInternal(ctx context.Context, index int) { |
| 190 | sv.activeIndex = index | 223 | sv.activeIndex = index | ||
| 191 | - | sv.updateVisibilityInternal() | 224 | + | sv.updateVisibilityInternal(ctx) |
| 192 | } | 225 | } | ||
| 193 | 226 | ||||
| 194 | // updateVisibilityInternal updates visibility of all panes based on activeIndex. | 227 | // updateVisibilityInternal updates visibility of all panes based on activeIndex. | ||
| 195 | - | func (sv *StackedView) updateVisibilityInternal() { | 228 | + | func (sv *StackedView) updateVisibilityInternal(ctx context.Context) { |
| 229 | + | log := logging.FromContext(ctx) | |||
| 230 | + | ||||
| 231 | + | log.Debug(). | |||
| 232 | + | Int("active_index", sv.activeIndex). | |||
| 233 | + | Int("pane_count", len(sv.panes)). | |||
| 234 | + | Msg("StackedView.updateVisibilityInternal called") | |||
| 235 | + | ||||
| 196 | for i, pane := range sv.panes { | 236 | for i, pane := range sv.panes { | ||
| 197 | isActive := i == sv.activeIndex | 237 | isActive := i == sv.activeIndex | ||
| 198 | pane.isActive = isActive | 238 | pane.isActive = isActive | ||
| @@ -202,12 +242,24 @@ func (sv *StackedView) updateVisibilityInternal() { | |||||
| 202 | parent := pane.titleBar.GetParent() | 242 | parent := pane.titleBar.GetParent() | ||
| 203 | if parent != nil { | 243 | if parent != nil { | ||
| 204 | parent.SetVisible(!isActive) | 244 | parent.SetVisible(!isActive) | ||
| 245 | + | log.Debug(). | |||
| 246 | + | Int("pane_index", i). | |||
| 247 | + | Str("title", pane.title). | |||
| 248 | + | Bool("is_active", isActive). | |||
| 249 | + | Bool("titlebar_visible", !isActive). | |||
| 250 | + | Msg("StackedView: set titlebar visibility") | |||
| 205 | } | 251 | } | ||
| 206 | } | 252 | } | ||
| 207 | 253 | ||||
| 208 | // Container is visible only for active pane | 254 | // Container is visible only for active pane | ||
| 209 | if pane.container != nil { | 255 | if pane.container != nil { | ||
| 210 | pane.container.SetVisible(isActive) | 256 | pane.container.SetVisible(isActive) | ||
| 257 | + | log.Debug(). | |||
| 258 | + | Int("pane_index", i). | |||
| 259 | + | Str("title", pane.title). | |||
| 260 | + | Bool("is_active", isActive). | |||
| 261 | + | Bool("container_visible", isActive). | |||
| 262 | + | Msg("StackedView: set container visibility") | |||
| 211 | } | 263 | } | ||
| 212 | 264 | ||||
| 213 | // Update CSS classes | 265 | // Update CSS classes | ||
| @@ -306,7 +358,8 @@ func (sv *StackedView) Box() BoxWidget { | |||||
| 306 | } | 358 | } | ||
| 307 | 359 | ||||
| 308 | // NavigateNext moves to the next pane in the stack (wraps around). | 360 | // NavigateNext moves to the next pane in the stack (wraps around). | ||
| 309 | - | func (sv *StackedView) NavigateNext() error { | 361 | + | func (sv *StackedView) NavigateNext(ctx context.Context) error { |
| 362 | + | log := logging.FromContext(ctx) | |||
| 310 | sv.mu.Lock() | 363 | sv.mu.Lock() | ||
| 311 | defer sv.mu.Unlock() | 364 | defer sv.mu.Unlock() | ||
| 312 | 365 | ||||
| @@ -315,12 +368,17 @@ func (sv *StackedView) NavigateNext() error { | |||||
| 315 | } | 368 | } | ||
| 316 | 369 | ||||
| 317 | newIndex := (sv.activeIndex + 1) % len(sv.panes) | 370 | newIndex := (sv.activeIndex + 1) % len(sv.panes) | ||
| 318 | - | sv.setActiveInternal(newIndex) | 371 | + | log.Debug(). |
| 372 | + | Int("old_index", sv.activeIndex). | |||
| 373 | + | Int("new_index", newIndex). | |||
| 374 | + | Msg("StackedView.NavigateNext") | |||
| 375 | + | sv.setActiveInternal(ctx, newIndex) | |||
| 319 | return nil | 376 | return nil | ||
| 320 | } | 377 | } | ||
| 321 | 378 | ||||
| 322 | // NavigatePrevious moves to the previous pane in the stack (wraps around). | 379 | // NavigatePrevious moves to the previous pane in the stack (wraps around). | ||
| 323 | - | func (sv *StackedView) NavigatePrevious() error { | 380 | + | func (sv *StackedView) NavigatePrevious(ctx context.Context) error { |
| 381 | + | log := logging.FromContext(ctx) | |||
| 324 | sv.mu.Lock() | 382 | sv.mu.Lock() | ||
| 325 | defer sv.mu.Unlock() | 383 | defer sv.mu.Unlock() | ||
| 326 | 384 | ||||
| @@ -329,6 +387,10 @@ func (sv *StackedView) NavigatePrevious() error { | |||||
| 329 | } | 387 | } | ||
| 330 | 388 | ||||
| 331 | newIndex := (sv.activeIndex - 1 + len(sv.panes)) % len(sv.panes) | 389 | newIndex := (sv.activeIndex - 1 + len(sv.panes)) % len(sv.panes) | ||
| 332 | - | sv.setActiveInternal(newIndex) | 390 | + | log.Debug(). |
| 391 | + | Int("old_index", sv.activeIndex). | |||
| 392 | + | Int("new_index", newIndex). | |||
| 393 | + | Msg("StackedView.NavigatePrevious") | |||
| 394 | + | sv.setActiveInternal(ctx, newIndex) | |||
| 333 | return nil | 395 | return nil | ||
| 334 | } | 396 | } | ||
M internal/ui/layout/tree.go +92 -6
| @@ -31,8 +31,11 @@ type TreeRenderer struct { | |||
| 31 | 31 | paneViewFactory PaneViewFactory | |
| 32 | 32 | logger zerolog.Logger | |
| 33 | 33 | nodeToWidget map[string]Widget | |
| 34 | - | ctx context.Context | |
| 35 | - | mu sync.RWMutex | |
| 34 | + | // paneToStack maps pane IDs to their containing StackedView. | |
| 35 | + | // Every leaf pane is wrapped in a StackedView for easy stacking later. | |
| 36 | + | paneToStack map[string]*StackedView | |
| 37 | + | ctx context.Context | |
| 38 | + | mu sync.RWMutex | |
| 36 | 39 | } | |
| 37 | 40 | ||
| 38 | 41 | // NewTreeRenderer creates a new tree renderer. | |
| @@ -44,6 +47,7 @@ func NewTreeRenderer(ctx context.Context, factory WidgetFactory, paneViewFactory | |||
| 44 | 47 | paneViewFactory: paneViewFactory, | |
| 45 | 48 | logger: log.With().Str("component", "tree-renderer").Logger(), | |
| 46 | 49 | nodeToWidget: make(map[string]Widget), | |
| 50 | + | paneToStack: make(map[string]*StackedView), | |
| 47 | 51 | ctx: ctx, | |
| 48 | 52 | } | |
| 49 | 53 | } | |
| @@ -60,6 +64,7 @@ func (tr *TreeRenderer) Build(root *entity.PaneNode) (Widget, error) { | |||
| 60 | 64 | ||
| 61 | 65 | // Clear previous mappings | |
| 62 | 66 | tr.nodeToWidget = make(map[string]Widget) | |
| 67 | + | tr.paneToStack = make(map[string]*StackedView) | |
| 63 | 68 | ||
| 64 | 69 | return tr.renderNode(root) | |
| 65 | 70 | } | |
| @@ -100,12 +105,45 @@ func (tr *TreeRenderer) renderNode(node *entity.PaneNode) (Widget, error) { | |||
| 100 | 105 | return widget, nil | |
| 101 | 106 | } | |
| 102 | 107 | ||
| 103 | - | // renderLeaf creates a PaneView widget for a leaf node. | |
| 108 | + | // renderLeaf creates a PaneView widget wrapped in a StackedView. | |
| 109 | + | // Every pane is wrapped in a StackedView from the start, making stacking trivial. | |
| 110 | + | // When there's only 1 pane, the StackedView shows no title bar. | |
| 104 | 111 | func (tr *TreeRenderer) renderLeaf(node *entity.PaneNode) Widget { | |
| 105 | 112 | if tr.paneViewFactory == nil { | |
| 106 | 113 | return nil | |
| 107 | 114 | } | |
| 108 | - | return tr.paneViewFactory.CreatePaneView(node) | |
| 115 | + | ||
| 116 | + | paneWidget := tr.paneViewFactory.CreatePaneView(node) | |
| 117 | + | if paneWidget == nil { | |
| 118 | + | return nil | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | // Wrap the pane in a StackedView | |
| 122 | + | stackedView := NewStackedView(tr.factory) | |
| 123 | + | ||
| 124 | + | // Get title from pane if available | |
| 125 | + | title := "Untitled" | |
| 126 | + | paneID := "" | |
| 127 | + | if node.Pane != nil { | |
| 128 | + | paneID = string(node.Pane.ID) | |
| 129 | + | if node.Pane.Title != "" { | |
| 130 | + | title = node.Pane.Title | |
| 131 | + | } | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | tr.logger.Debug(). | |
| 135 | + | Str("pane_id", paneID). | |
| 136 | + | Str("title", title). | |
| 137 | + | Msg("TreeRenderer.renderLeaf: wrapping pane in StackedView") | |
| 138 | + | ||
| 139 | + | stackedView.AddPane(tr.ctx, title, "", paneWidget) | |
| 140 | + | ||
| 141 | + | // Track this pane's StackedView for later stacking operations | |
| 142 | + | if node.Pane != nil { | |
| 143 | + | tr.paneToStack[paneID] = stackedView | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | return stackedView.Widget() | |
| 109 | 147 | } | |
| 110 | 148 | ||
| 111 | 149 | // renderSplit creates a SplitView for a split node. | |
| @@ -137,6 +175,10 @@ func (tr *TreeRenderer) renderSplit(node *entity.PaneNode) (Widget, error) { | |||
| 137 | 175 | func (tr *TreeRenderer) renderStacked(node *entity.PaneNode) (Widget, error) { | |
| 138 | 176 | stackedView := NewStackedView(tr.factory) | |
| 139 | 177 | ||
| 178 | + | tr.logger.Debug(). | |
| 179 | + | Int("child_count", len(node.Children)). | |
| 180 | + | Msg("TreeRenderer.renderStacked: creating stacked view") | |
| 181 | + | ||
| 140 | 182 | // Add each child pane to the stack | |
| 141 | 183 | for _, child := range node.Children { | |
| 142 | 184 | childWidget, err := tr.renderNode(child) | |
| @@ -154,12 +196,12 @@ func (tr *TreeRenderer) renderStacked(node *entity.PaneNode) (Widget, error) { | |||
| 154 | 196 | favicon = child.Pane.FaviconURL | |
| 155 | 197 | } | |
| 156 | 198 | ||
| 157 | - | stackedView.AddPane(title, favicon, childWidget) | |
| 199 | + | stackedView.AddPane(tr.ctx, title, favicon, childWidget) | |
| 158 | 200 | } | |
| 159 | 201 | ||
| 160 | 202 | // Set active index | |
| 161 | 203 | if node.ActiveStackIndex >= 0 && node.ActiveStackIndex < stackedView.Count() { | |
| 162 | - | _ = stackedView.SetActive(node.ActiveStackIndex) | |
| 204 | + | _ = stackedView.SetActive(tr.ctx, node.ActiveStackIndex) | |
| 163 | 205 | } | |
| 164 | 206 | ||
| 165 | 207 | return stackedView.Widget(), nil | |
| @@ -200,6 +242,24 @@ func (tr *TreeRenderer) Clear() { | |||
| 200 | 242 | tr.nodeToWidget = make(map[string]Widget) | |
| 201 | 243 | } | |
| 202 | 244 | ||
| 245 | + | // RegisterWidget adds or updates a node-to-widget mapping. | |
| 246 | + | // Use this for incremental updates when you don't want to rebuild the entire tree. | |
| 247 | + | func (tr *TreeRenderer) RegisterWidget(nodeID string, widget Widget) { | |
| 248 | + | tr.mu.Lock() | |
| 249 | + | defer tr.mu.Unlock() | |
| 250 | + | ||
| 251 | + | tr.nodeToWidget[nodeID] = widget | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | // UnregisterWidget removes a node-to-widget mapping. | |
| 255 | + | // Use this when a node is removed or replaced during incremental operations. | |
| 256 | + | func (tr *TreeRenderer) UnregisterWidget(nodeID string) { | |
| 257 | + | tr.mu.Lock() | |
| 258 | + | defer tr.mu.Unlock() | |
| 259 | + | ||
| 260 | + | delete(tr.nodeToWidget, nodeID) | |
| 261 | + | } | |
| 262 | + | ||
| 203 | 263 | // UpdateSplitRatio updates the ratio of a split view by node ID. | |
| 204 | 264 | // Returns an error if the node is not found or is not a split. | |
| 205 | 265 | func (tr *TreeRenderer) UpdateSplitRatio(nodeID string, ratio float64) error { | |
| @@ -232,3 +292,29 @@ func (tr *TreeRenderer) GetNodeIDs() []string { | |||
| 232 | 292 | } | |
| 233 | 293 | return ids | |
| 234 | 294 | } | |
| 295 | + | ||
| 296 | + | // GetStackedViewForPane returns the StackedView containing the given pane. | |
| 297 | + | // Returns nil if the pane is not found. | |
| 298 | + | func (tr *TreeRenderer) GetStackedViewForPane(paneID string) *StackedView { | |
| 299 | + | tr.mu.RLock() | |
| 300 | + | defer tr.mu.RUnlock() | |
| 301 | + | ||
| 302 | + | return tr.paneToStack[paneID] | |
| 303 | + | } | |
| 304 | + | ||
| 305 | + | // RegisterPaneInStack adds a pane to the paneToStack mapping. | |
| 306 | + | // Use this when adding a pane to an existing stack without rebuild. | |
| 307 | + | func (tr *TreeRenderer) RegisterPaneInStack(paneID string, stackedView *StackedView) { | |
| 308 | + | tr.mu.Lock() | |
| 309 | + | defer tr.mu.Unlock() | |
| 310 | + | ||
| 311 | + | tr.paneToStack[paneID] = stackedView | |
| 312 | + | } | |
| 313 | + | ||
| 314 | + | // UnregisterPane removes a pane from the paneToStack mapping. | |
| 315 | + | func (tr *TreeRenderer) UnregisterPane(paneID string) { | |
| 316 | + | tr.mu.Lock() | |
| 317 | + | defer tr.mu.Unlock() | |
| 318 | + | ||
| 319 | + | delete(tr.paneToStack, paneID) | |
| 320 | + | } | |
| @@ -31,8 +31,11 @@ type TreeRenderer struct { | |||||
| 31 | paneViewFactory PaneViewFactory | 31 | paneViewFactory PaneViewFactory | ||
| 32 | logger zerolog.Logger | 32 | logger zerolog.Logger | ||
| 33 | nodeToWidget map[string]Widget | 33 | nodeToWidget map[string]Widget | ||
| 34 | - | ctx context.Context | 34 | + | // paneToStack maps pane IDs to their containing StackedView. |
| 35 | - | mu sync.RWMutex | 35 | + | // Every leaf pane is wrapped in a StackedView for easy stacking later. |
| 36 | + | paneToStack map[string]*StackedView | |||
| 37 | + | ctx context.Context | |||
| 38 | + | mu sync.RWMutex | |||
| 36 | } | 39 | } | ||
| 37 | 40 | ||||
| 38 | // NewTreeRenderer creates a new tree renderer. | 41 | // NewTreeRenderer creates a new tree renderer. | ||
| @@ -44,6 +47,7 @@ func NewTreeRenderer(ctx context.Context, factory WidgetFactory, paneViewFactory | |||||
| 44 | paneViewFactory: paneViewFactory, | 47 | paneViewFactory: paneViewFactory, | ||
| 45 | logger: log.With().Str("component", "tree-renderer").Logger(), | 48 | logger: log.With().Str("component", "tree-renderer").Logger(), | ||
| 46 | nodeToWidget: make(map[string]Widget), | 49 | nodeToWidget: make(map[string]Widget), | ||
| 50 | + | paneToStack: make(map[string]*StackedView), | |||
| 47 | ctx: ctx, | 51 | ctx: ctx, | ||
| 48 | } | 52 | } | ||
| 49 | } | 53 | } | ||
| @@ -60,6 +64,7 @@ func (tr *TreeRenderer) Build(root *entity.PaneNode) (Widget, error) { | |||||
| 60 | 64 | ||||
| 61 | // Clear previous mappings | 65 | // Clear previous mappings | ||
| 62 | tr.nodeToWidget = make(map[string]Widget) | 66 | tr.nodeToWidget = make(map[string]Widget) | ||
| 67 | + | tr.paneToStack = make(map[string]*StackedView) | |||
| 63 | 68 | ||||
| 64 | return tr.renderNode(root) | 69 | return tr.renderNode(root) | ||
| 65 | } | 70 | } | ||
| @@ -100,12 +105,45 @@ func (tr *TreeRenderer) renderNode(node *entity.PaneNode) (Widget, error) { | |||||
| 100 | return widget, nil | 105 | return widget, nil | ||
| 101 | } | 106 | } | ||
| 102 | 107 | ||||
| 103 | - | // renderLeaf creates a PaneView widget for a leaf node. | 108 | + | // renderLeaf creates a PaneView widget wrapped in a StackedView. |
| 109 | + | // Every pane is wrapped in a StackedView from the start, making stacking trivial. | |||
| 110 | + | // When there's only 1 pane, the StackedView shows no title bar. | |||
| 104 | func (tr *TreeRenderer) renderLeaf(node *entity.PaneNode) Widget { | 111 | func (tr *TreeRenderer) renderLeaf(node *entity.PaneNode) Widget { | ||
| 105 | if tr.paneViewFactory == nil { | 112 | if tr.paneViewFactory == nil { | ||
| 106 | return nil | 113 | return nil | ||
| 107 | } | 114 | } | ||
| 108 | - | return tr.paneViewFactory.CreatePaneView(node) | 115 | + | |
| 116 | + | paneWidget := tr.paneViewFactory.CreatePaneView(node) | |||
| 117 | + | if paneWidget == nil { | |||
| 118 | + | return nil | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | // Wrap the pane in a StackedView | |||
| 122 | + | stackedView := NewStackedView(tr.factory) | |||
| 123 | + | ||||
| 124 | + | // Get title from pane if available | |||
| 125 | + | title := "Untitled" | |||
| 126 | + | paneID := "" | |||
| 127 | + | if node.Pane != nil { | |||
| 128 | + | paneID = string(node.Pane.ID) | |||
| 129 | + | if node.Pane.Title != "" { | |||
| 130 | + | title = node.Pane.Title | |||
| 131 | + | } | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | tr.logger.Debug(). | |||
| 135 | + | Str("pane_id", paneID). | |||
| 136 | + | Str("title", title). | |||
| 137 | + | Msg("TreeRenderer.renderLeaf: wrapping pane in StackedView") | |||
| 138 | + | ||||
| 139 | + | stackedView.AddPane(tr.ctx, title, "", paneWidget) | |||
| 140 | + | ||||
| 141 | + | // Track this pane's StackedView for later stacking operations | |||
| 142 | + | if node.Pane != nil { | |||
| 143 | + | tr.paneToStack[paneID] = stackedView | |||
| 144 | + | } | |||
| 145 | + | ||||
| 146 | + | return stackedView.Widget() | |||
| 109 | } | 147 | } | ||
| 110 | 148 | ||||
| 111 | // renderSplit creates a SplitView for a split node. | 149 | // renderSplit creates a SplitView for a split node. | ||
| @@ -137,6 +175,10 @@ func (tr *TreeRenderer) renderSplit(node *entity.PaneNode) (Widget, error) { | |||||
| 137 | func (tr *TreeRenderer) renderStacked(node *entity.PaneNode) (Widget, error) { | 175 | func (tr *TreeRenderer) renderStacked(node *entity.PaneNode) (Widget, error) { | ||
| 138 | stackedView := NewStackedView(tr.factory) | 176 | stackedView := NewStackedView(tr.factory) | ||
| 139 | 177 | ||||
| 178 | + | tr.logger.Debug(). | |||
| 179 | + | Int("child_count", len(node.Children)). | |||
| 180 | + | Msg("TreeRenderer.renderStacked: creating stacked view") | |||
| 181 | + | ||||
| 140 | // Add each child pane to the stack | 182 | // Add each child pane to the stack | ||
| 141 | for _, child := range node.Children { | 183 | for _, child := range node.Children { | ||
| 142 | childWidget, err := tr.renderNode(child) | 184 | childWidget, err := tr.renderNode(child) | ||
| @@ -154,12 +196,12 @@ func (tr *TreeRenderer) renderStacked(node *entity.PaneNode) (Widget, error) { | |||||
| 154 | favicon = child.Pane.FaviconURL | 196 | favicon = child.Pane.FaviconURL | ||
| 155 | } | 197 | } | ||
| 156 | 198 | ||||
| 157 | - | stackedView.AddPane(title, favicon, childWidget) | 199 | + | stackedView.AddPane(tr.ctx, title, favicon, childWidget) |
| 158 | } | 200 | } | ||
| 159 | 201 | ||||
| 160 | // Set active index | 202 | // Set active index | ||
| 161 | if node.ActiveStackIndex >= 0 && node.ActiveStackIndex < stackedView.Count() { | 203 | if node.ActiveStackIndex >= 0 && node.ActiveStackIndex < stackedView.Count() { | ||
| 162 | - | _ = stackedView.SetActive(node.ActiveStackIndex) | 204 | + | _ = stackedView.SetActive(tr.ctx, node.ActiveStackIndex) |
| 163 | } | 205 | } | ||
| 164 | 206 | ||||
| 165 | return stackedView.Widget(), nil | 207 | return stackedView.Widget(), nil | ||
| @@ -200,6 +242,24 @@ func (tr *TreeRenderer) Clear() { | |||||
| 200 | tr.nodeToWidget = make(map[string]Widget) | 242 | tr.nodeToWidget = make(map[string]Widget) | ||
| 201 | } | 243 | } | ||
| 202 | 244 | ||||
| 245 | + | // RegisterWidget adds or updates a node-to-widget mapping. | |||
| 246 | + | // Use this for incremental updates when you don't want to rebuild the entire tree. | |||
| 247 | + | func (tr *TreeRenderer) RegisterWidget(nodeID string, widget Widget) { | |||
| 248 | + | tr.mu.Lock() | |||
| 249 | + | defer tr.mu.Unlock() | |||
| 250 | + | ||||
| 251 | + | tr.nodeToWidget[nodeID] = widget | |||
| 252 | + | } | |||
| 253 | + | ||||
| 254 | + | // UnregisterWidget removes a node-to-widget mapping. | |||
| 255 | + | // Use this when a node is removed or replaced during incremental operations. | |||
| 256 | + | func (tr *TreeRenderer) UnregisterWidget(nodeID string) { | |||
| 257 | + | tr.mu.Lock() | |||
| 258 | + | defer tr.mu.Unlock() | |||
| 259 | + | ||||
| 260 | + | delete(tr.nodeToWidget, nodeID) | |||
| 261 | + | } | |||
| 262 | + | ||||
| 203 | // UpdateSplitRatio updates the ratio of a split view by node ID. | 263 | // UpdateSplitRatio updates the ratio of a split view by node ID. | ||
| 204 | // Returns an error if the node is not found or is not a split. | 264 | // Returns an error if the node is not found or is not a split. | ||
| 205 | func (tr *TreeRenderer) UpdateSplitRatio(nodeID string, ratio float64) error { | 265 | func (tr *TreeRenderer) UpdateSplitRatio(nodeID string, ratio float64) error { | ||
| @@ -232,3 +292,29 @@ func (tr *TreeRenderer) GetNodeIDs() []string { | |||||
| 232 | } | 292 | } | ||
| 233 | return ids | 293 | return ids | ||
| 234 | } | 294 | } | ||
| 295 | + | ||||
| 296 | + | // GetStackedViewForPane returns the StackedView containing the given pane. | |||
| 297 | + | // Returns nil if the pane is not found. | |||
| 298 | + | func (tr *TreeRenderer) GetStackedViewForPane(paneID string) *StackedView { | |||
| 299 | + | tr.mu.RLock() | |||
| 300 | + | defer tr.mu.RUnlock() | |||
| 301 | + | ||||
| 302 | + | return tr.paneToStack[paneID] | |||
| 303 | + | } | |||
| 304 | + | ||||
| 305 | + | // RegisterPaneInStack adds a pane to the paneToStack mapping. | |||
| 306 | + | // Use this when adding a pane to an existing stack without rebuild. | |||
| 307 | + | func (tr *TreeRenderer) RegisterPaneInStack(paneID string, stackedView *StackedView) { | |||
| 308 | + | tr.mu.Lock() | |||
| 309 | + | defer tr.mu.Unlock() | |||
| 310 | + | ||||
| 311 | + | tr.paneToStack[paneID] = stackedView | |||
| 312 | + | } | |||
| 313 | + | ||||
| 314 | + | // UnregisterPane removes a pane from the paneToStack mapping. | |||
| 315 | + | func (tr *TreeRenderer) UnregisterPane(paneID string) { | |||
| 316 | + | tr.mu.Lock() | |||
| 317 | + | defer tr.mu.Unlock() | |||
| 318 | + | ||||
| 319 | + | delete(tr.paneToStack, paneID) | |||
| 320 | + | } | |||
M internal/ui/theme/css.go +63 -0
| @@ -25,6 +25,10 @@ func GenerateCSS(p Palette) string { | |||
| 25 | 25 | ||
| 26 | 26 | // Pane styling | |
| 27 | 27 | sb.WriteString(generatePaneCSS(p)) | |
| 28 | + | sb.WriteString("\n") | |
| 29 | + | ||
| 30 | + | // Stacked pane styling | |
| 31 | + | sb.WriteString(generateStackedPaneCSS(p)) | |
| 28 | 32 | ||
| 29 | 33 | return sb.String() | |
| 30 | 34 | } | |
| @@ -234,3 +238,62 @@ func generatePaneCSS(p Palette) string { | |||
| 234 | 238 | } | |
| 235 | 239 | ` | |
| 236 | 240 | } | |
| 241 | + | ||
| 242 | + | // generateStackedPaneCSS creates stacked pane (Zellij-style tabs within panes) styles. | |
| 243 | + | func generateStackedPaneCSS(p Palette) string { | |
| 244 | + | return `/* ===== Stacked Pane Styling ===== */ | |
| 245 | + | ||
| 246 | + | /* Title bar for inactive panes in a stack */ | |
| 247 | + | .stacked-pane-titlebar { | |
| 248 | + | background-color: var(--surface-variant); | |
| 249 | + | border-bottom: 1px solid var(--border); | |
| 250 | + | padding: 4px 8px; | |
| 251 | + | min-height: 24px; | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | /* Title bar button wrapper - clickable area */ | |
| 255 | + | button.stacked-pane-title-button { | |
| 256 | + | background-color: var(--surface-variant); | |
| 257 | + | background-image: none; | |
| 258 | + | border: none; | |
| 259 | + | border-bottom: 1px solid var(--border); | |
| 260 | + | border-radius: 0; | |
| 261 | + | padding: 0; | |
| 262 | + | margin: 0; | |
| 263 | + | transition: background-color 150ms ease-in-out; | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | button.stacked-pane-title-button:hover { | |
| 267 | + | background-color: alpha(var(--accent), 0.15); | |
| 268 | + | } | |
| 269 | + | ||
| 270 | + | button.stacked-pane-title-button:focus { | |
| 271 | + | outline: none; | |
| 272 | + | box-shadow: none; | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | /* Title bar content box */ | |
| 276 | + | .stacked-pane-titlebar.active { | |
| 277 | + | background-color: alpha(var(--accent), 0.1); | |
| 278 | + | border-left: 3px solid var(--accent); | |
| 279 | + | } | |
| 280 | + | ||
| 281 | + | /* Favicon image in title bar */ | |
| 282 | + | .stacked-pane-titlebar image { | |
| 283 | + | min-width: 16px; | |
| 284 | + | min-height: 16px; | |
| 285 | + | margin-right: 6px; | |
| 286 | + | } | |
| 287 | + | ||
| 288 | + | /* Title text in title bar */ | |
| 289 | + | .stacked-pane-titlebar label { | |
| 290 | + | color: var(--text); | |
| 291 | + | font-size: 12px; | |
| 292 | + | font-weight: 400; | |
| 293 | + | } | |
| 294 | + | ||
| 295 | + | button.stacked-pane-title-button:hover .stacked-pane-titlebar label { | |
| 296 | + | color: var(--accent); | |
| 297 | + | } | |
| 298 | + | ` | |
| 299 | + | } | |
| @@ -25,6 +25,10 @@ func GenerateCSS(p Palette) string { | |||||
| 25 | 25 | ||||
| 26 | // Pane styling | 26 | // Pane styling | ||
| 27 | sb.WriteString(generatePaneCSS(p)) | 27 | sb.WriteString(generatePaneCSS(p)) | ||
| 28 | + | sb.WriteString("\n") | |||
| 29 | + | ||||
| 30 | + | // Stacked pane styling | |||
| 31 | + | sb.WriteString(generateStackedPaneCSS(p)) | |||
| 28 | 32 | ||||
| 29 | return sb.String() | 33 | return sb.String() | ||
| 30 | } | 34 | } | ||
| @@ -234,3 +238,62 @@ func generatePaneCSS(p Palette) string { | |||||
| 234 | } | 238 | } | ||
| 235 | ` | 239 | ` | ||
| 236 | } | 240 | } | ||
| 241 | + | ||||
| 242 | + | // generateStackedPaneCSS creates stacked pane (Zellij-style tabs within panes) styles. | |||
| 243 | + | func generateStackedPaneCSS(p Palette) string { | |||
| 244 | + | return `/* ===== Stacked Pane Styling ===== */ | |||
| 245 | + | ||||
| 246 | + | /* Title bar for inactive panes in a stack */ | |||
| 247 | + | .stacked-pane-titlebar { | |||
| 248 | + | background-color: var(--surface-variant); | |||
| 249 | + | border-bottom: 1px solid var(--border); | |||
| 250 | + | padding: 4px 8px; | |||
| 251 | + | min-height: 24px; | |||
| 252 | + | } | |||
| 253 | + | ||||
| 254 | + | /* Title bar button wrapper - clickable area */ | |||
| 255 | + | button.stacked-pane-title-button { | |||
| 256 | + | background-color: var(--surface-variant); | |||
| 257 | + | background-image: none; | |||
| 258 | + | border: none; | |||
| 259 | + | border-bottom: 1px solid var(--border); | |||
| 260 | + | border-radius: 0; | |||
| 261 | + | padding: 0; | |||
| 262 | + | margin: 0; | |||
| 263 | + | transition: background-color 150ms ease-in-out; | |||
| 264 | + | } | |||
| 265 | + | ||||
| 266 | + | button.stacked-pane-title-button:hover { | |||
| 267 | + | background-color: alpha(var(--accent), 0.15); | |||
| 268 | + | } | |||
| 269 | + | ||||
| 270 | + | button.stacked-pane-title-button:focus { | |||
| 271 | + | outline: none; | |||
| 272 | + | box-shadow: none; | |||
| 273 | + | } | |||
| 274 | + | ||||
| 275 | + | /* Title bar content box */ | |||
| 276 | + | .stacked-pane-titlebar.active { | |||
| 277 | + | background-color: alpha(var(--accent), 0.1); | |||
| 278 | + | border-left: 3px solid var(--accent); | |||
| 279 | + | } | |||
| 280 | + | ||||
| 281 | + | /* Favicon image in title bar */ | |||
| 282 | + | .stacked-pane-titlebar image { | |||
| 283 | + | min-width: 16px; | |||
| 284 | + | min-height: 16px; | |||
| 285 | + | margin-right: 6px; | |||
| 286 | + | } | |||
| 287 | + | ||||
| 288 | + | /* Title text in title bar */ | |||
| 289 | + | .stacked-pane-titlebar label { | |||
| 290 | + | color: var(--text); | |||
| 291 | + | font-size: 12px; | |||
| 292 | + | font-weight: 400; | |||
| 293 | + | } | |||
| 294 | + | ||||
| 295 | + | button.stacked-pane-title-button:hover .stacked-pane-titlebar label { | |||
| 296 | + | color: var(--accent); | |||
| 297 | + | } | |||
| 298 | + | ` | |||
| 299 | + | } | |||