refactor(webkit,layout): enhance WebView with script execution and improve layout widget system
Enhancements to WebView: - Add RunJavaScript and RunJavaScriptAsync methods for script execution in specific worlds - Support for async script execution to prevent GTK main loop blocking - Improved error handling and logging in script execution Layout improvements: - Add GTK widget extensions and aliases for cleaner code - Add mock PanedWidget interface for testing - Improve split view ratio handling and test coverage - Refactor tree layout system for better maintainability - Add comprehensive unit tests for layout algorithms
- commit
- 5884f493ed845a635f7ed203ae24158a60a0e175
- author
- brice · · 2025-12-11 17:12 UTC
- committer
- brice · · 2025-12-11 21:31 UTC
- parent
- 225efeb
- signature
- unsigned
8 files · +404 -119
M internal/infrastructure/webkit/webview.go +190 -9
| @@ -1,11 +1,15 @@ | |||
| 1 | 1 | package webkit | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 4 | 6 | "fmt" | |
| 5 | 7 | "sync" | |
| 6 | 8 | "sync/atomic" | |
| 7 | 9 | ||
| 10 | + | "github.com/bnema/dumber/internal/logging" | |
| 8 | 11 | "github.com/bnema/puregotk-webkit/webkit" | |
| 12 | + | "github.com/jwijenbergh/puregotk/v4/gio" | |
| 9 | 13 | "github.com/jwijenbergh/puregotk/v4/gtk" | |
| 10 | 14 | "github.com/rs/zerolog" | |
| 11 | 15 | ) | |
| @@ -72,6 +76,7 @@ func LookupWebView(id WebViewID) *WebView { | |||
| 72 | 76 | type WebView struct { | |
| 73 | 77 | id WebViewID | |
| 74 | 78 | inner *webkit.WebView | |
| 79 | + | ucm *webkit.UserContentManager | |
| 75 | 80 | ||
| 76 | 81 | // State (protected by mutex) | |
| 77 | 82 | destroyed atomic.Bool | |
| @@ -96,6 +101,11 @@ type WebView struct { | |||
| 96 | 101 | ||
| 97 | 102 | logger zerolog.Logger | |
| 98 | 103 | mu sync.RWMutex | |
| 104 | + | ||
| 105 | + | frontendAttached atomic.Bool | |
| 106 | + | ||
| 107 | + | // asyncCallbacks keeps references to async JS callbacks to prevent GC | |
| 108 | + | asyncCallbacks []interface{} | |
| 99 | 109 | } | |
| 100 | 110 | ||
| 101 | 111 | // NewWebView creates a new WebView with the given context and settings. | |
| @@ -111,6 +121,7 @@ func NewWebView(wkCtx *WebKitContext, settings *SettingsManager, logger zerolog. | |||
| 111 | 121 | ||
| 112 | 122 | wv := &WebView{ | |
| 113 | 123 | inner: inner, | |
| 124 | + | ucm: inner.GetUserContentManager(), | |
| 114 | 125 | logger: logger.With().Str("component", "webview").Logger(), | |
| 115 | 126 | signalIDs: make([]uint32, 0, 4), | |
| 116 | 127 | } | |
| @@ -148,6 +159,7 @@ func NewWebViewWithRelated(parent *WebView, settings *SettingsManager, logger ze | |||
| 148 | 159 | ||
| 149 | 160 | wv := &WebView{ | |
| 150 | 161 | inner: inner, | |
| 162 | + | ucm: inner.GetUserContentManager(), | |
| 151 | 163 | logger: logger.With().Str("component", "webview-popup").Logger(), | |
| 152 | 164 | signalIDs: make([]uint32, 0, 6), | |
| 153 | 165 | } | |
| @@ -172,6 +184,7 @@ func NewWebViewWithRelated(parent *WebView, settings *SettingsManager, logger ze | |||
| 172 | 184 | func (wv *WebView) connectSignals() { | |
| 173 | 185 | // load-changed signal | |
| 174 | 186 | loadChangedCb := func(inner webkit.WebView, event webkit.LoadEvent) { | |
| 187 | + | ||
| 175 | 188 | wv.mu.Lock() | |
| 176 | 189 | wv.uri = inner.GetUri() | |
| 177 | 190 | wv.title = inner.GetTitle() | |
| @@ -257,23 +270,28 @@ func (wv *WebView) ID() WebViewID { | |||
| 257 | 270 | return wv.id | |
| 258 | 271 | } | |
| 259 | 272 | ||
| 273 | + | // UserContentManager returns the content manager associated with this WebView. | |
| 274 | + | func (wv *WebView) UserContentManager() *webkit.UserContentManager { | |
| 275 | + | return wv.ucm | |
| 276 | + | } | |
| 277 | + | ||
| 260 | 278 | // Widget returns the underlying webkit.WebView for GTK embedding. | |
| 261 | 279 | func (wv *WebView) Widget() *webkit.WebView { | |
| 262 | 280 | return wv.inner | |
| 263 | 281 | } | |
| 264 | 282 | ||
| 265 | 283 | // LoadURI loads the given URI. | |
| 266 | - | func (wv *WebView) LoadURI(uri string) error { | |
| 284 | + | func (wv *WebView) LoadURI(ctx context.Context, uri string) error { | |
| 267 | 285 | if wv.destroyed.Load() { | |
| 268 | 286 | return fmt.Errorf("webview %d is destroyed", wv.id) | |
| 269 | 287 | } | |
| 270 | 288 | wv.inner.LoadUri(uri) | |
| 271 | - | wv.logger.Debug().Str("uri", uri).Msg("loading URI") | |
| 289 | + | logging.FromContext(ctx).Debug().Str("uri", uri).Msg("loading URI") | |
| 272 | 290 | return nil | |
| 273 | 291 | } | |
| 274 | 292 | ||
| 275 | 293 | // LoadHTML loads HTML content with an optional base URI. | |
| 276 | - | func (wv *WebView) LoadHTML(content, baseURI string) error { | |
| 294 | + | func (wv *WebView) LoadHTML(ctx context.Context, content, baseURI string) error { | |
| 277 | 295 | if wv.destroyed.Load() { | |
| 278 | 296 | return fmt.Errorf("webview %d is destroyed", wv.id) | |
| 279 | 297 | } | |
| @@ -282,7 +300,7 @@ func (wv *WebView) LoadHTML(content, baseURI string) error { | |||
| 282 | 300 | } | |
| 283 | 301 | ||
| 284 | 302 | // Reload reloads the current page. | |
| 285 | - | func (wv *WebView) Reload() error { | |
| 303 | + | func (wv *WebView) Reload(ctx context.Context) error { | |
| 286 | 304 | if wv.destroyed.Load() { | |
| 287 | 305 | return fmt.Errorf("webview %d is destroyed", wv.id) | |
| 288 | 306 | } | |
| @@ -291,7 +309,7 @@ func (wv *WebView) Reload() error { | |||
| 291 | 309 | } | |
| 292 | 310 | ||
| 293 | 311 | // ReloadBypassCache reloads the current page, bypassing the cache. | |
| 294 | - | func (wv *WebView) ReloadBypassCache() error { | |
| 312 | + | func (wv *WebView) ReloadBypassCache(ctx context.Context) error { | |
| 295 | 313 | if wv.destroyed.Load() { | |
| 296 | 314 | return fmt.Errorf("webview %d is destroyed", wv.id) | |
| 297 | 315 | } | |
| @@ -300,7 +318,7 @@ func (wv *WebView) ReloadBypassCache() error { | |||
| 300 | 318 | } | |
| 301 | 319 | ||
| 302 | 320 | // Stop stops the current load operation. | |
| 303 | - | func (wv *WebView) Stop() error { | |
| 321 | + | func (wv *WebView) Stop(ctx context.Context) error { | |
| 304 | 322 | if wv.destroyed.Load() { | |
| 305 | 323 | return fmt.Errorf("webview %d is destroyed", wv.id) | |
| 306 | 324 | } | |
| @@ -309,7 +327,7 @@ func (wv *WebView) Stop() error { | |||
| 309 | 327 | } | |
| 310 | 328 | ||
| 311 | 329 | // GoBack navigates back in history. | |
| 312 | - | func (wv *WebView) GoBack() error { | |
| 330 | + | func (wv *WebView) GoBack(ctx context.Context) error { | |
| 313 | 331 | if wv.destroyed.Load() { | |
| 314 | 332 | return fmt.Errorf("webview %d is destroyed", wv.id) | |
| 315 | 333 | } | |
| @@ -321,7 +339,7 @@ func (wv *WebView) GoBack() error { | |||
| 321 | 339 | } | |
| 322 | 340 | ||
| 323 | 341 | // GoForward navigates forward in history. | |
| 324 | - | func (wv *WebView) GoForward() error { | |
| 342 | + | func (wv *WebView) GoForward(ctx context.Context) error { | |
| 325 | 343 | if wv.destroyed.Load() { | |
| 326 | 344 | return fmt.Errorf("webview %d is destroyed", wv.id) | |
| 327 | 345 | } | |
| @@ -375,7 +393,7 @@ func (wv *WebView) EstimatedProgress() float64 { | |||
| 375 | 393 | } | |
| 376 | 394 | ||
| 377 | 395 | // SetZoomLevel sets the zoom level (1.0 = 100%). | |
| 378 | - | func (wv *WebView) SetZoomLevel(level float64) error { | |
| 396 | + | func (wv *WebView) SetZoomLevel(ctx context.Context, level float64) error { | |
| 379 | 397 | if wv.destroyed.Load() { | |
| 380 | 398 | return fmt.Errorf("webview %d is destroyed", wv.id) | |
| 381 | 399 | } | |
| @@ -391,6 +409,20 @@ func (wv *WebView) GetZoomLevel() float64 { | |||
| 391 | 409 | return wv.inner.GetZoomLevel() | |
| 392 | 410 | } | |
| 393 | 411 | ||
| 412 | + | // ShowDevTools opens the WebKit inspector/developer tools. | |
| 413 | + | func (wv *WebView) ShowDevTools() error { | |
| 414 | + | if wv.destroyed.Load() { | |
| 415 | + | return fmt.Errorf("webview %d is destroyed", wv.id) | |
| 416 | + | } | |
| 417 | + | inspector := wv.inner.GetInspector() | |
| 418 | + | if inspector == nil { | |
| 419 | + | return fmt.Errorf("failed to get inspector for webview %d", wv.id) | |
| 420 | + | } | |
| 421 | + | inspector.Show() | |
| 422 | + | wv.logger.Debug().Uint64("id", uint64(wv.id)).Msg("devtools shown") | |
| 423 | + | return nil | |
| 424 | + | } | |
| 425 | + | ||
| 394 | 426 | // IsDestroyed returns true if the WebView has been destroyed. | |
| 395 | 427 | func (wv *WebView) IsDestroyed() bool { | |
| 396 | 428 | return wv.destroyed.Load() | |
| @@ -411,3 +443,152 @@ func (wv *WebView) Destroy() { | |||
| 411 | 443 | ||
| 412 | 444 | wv.logger.Debug().Uint64("id", uint64(wv.id)).Msg("webview destroyed") | |
| 413 | 445 | } | |
| 446 | + | ||
| 447 | + | // RunJavaScript executes script in the specified world (empty for main world). | |
| 448 | + | // It waits for completion and reports JS exceptions through the returned error. | |
| 449 | + | // WARNING: Do not call from GTK signal handlers - use RunJavaScriptAsync instead. | |
| 450 | + | func (wv *WebView) RunJavaScript(ctx context.Context, script, worldName string) error { | |
| 451 | + | if wv.destroyed.Load() { | |
| 452 | + | return fmt.Errorf("webview %d is destroyed", wv.id) | |
| 453 | + | } | |
| 454 | + | if ctx == nil { | |
| 455 | + | ctx = context.Background() | |
| 456 | + | } | |
| 457 | + | ||
| 458 | + | log := logging.FromContext(ctx) | |
| 459 | + | ||
| 460 | + | done := make(chan struct{}) | |
| 461 | + | var execErr error | |
| 462 | + | ||
| 463 | + | cb := gio.AsyncReadyCallback(func(_ uintptr, resPtr uintptr, _ uintptr) { | |
| 464 | + | defer close(done) | |
| 465 | + | ||
| 466 | + | if resPtr == 0 { | |
| 467 | + | execErr = errors.New("nil async result") | |
| 468 | + | return | |
| 469 | + | } | |
| 470 | + | ||
| 471 | + | res := &gio.AsyncResultBase{Ptr: resPtr} | |
| 472 | + | value, err := wv.inner.EvaluateJavascriptFinish(res) | |
| 473 | + | if err != nil { | |
| 474 | + | execErr = err | |
| 475 | + | return | |
| 476 | + | } | |
| 477 | + | ||
| 478 | + | if value != nil { | |
| 479 | + | if jscCtx := value.GetContext(); jscCtx != nil { | |
| 480 | + | if exc := jscCtx.GetException(); exc != nil { | |
| 481 | + | execErr = fmt.Errorf("javascript exception: %s", exc.GetMessage()) | |
| 482 | + | } | |
| 483 | + | } | |
| 484 | + | } | |
| 485 | + | }) | |
| 486 | + | ||
| 487 | + | wv.inner.EvaluateJavascript(script, -1, worldName, "", nil, &cb, 0) | |
| 488 | + | ||
| 489 | + | select { | |
| 490 | + | case <-done: | |
| 491 | + | if execErr != nil { | |
| 492 | + | log.Warn(). | |
| 493 | + | Err(execErr). | |
| 494 | + | Uint64("webview_id", uint64(wv.id)). | |
| 495 | + | Msg("javascript execution failed") | |
| 496 | + | } | |
| 497 | + | return execErr | |
| 498 | + | case <-ctx.Done(): | |
| 499 | + | return ctx.Err() | |
| 500 | + | } | |
| 501 | + | } | |
| 502 | + | ||
| 503 | + | // RunJavaScriptAsync executes script without waiting for completion. | |
| 504 | + | // Safe to call from GTK signal handlers. Errors are logged but not returned. | |
| 505 | + | func (wv *WebView) RunJavaScriptAsync(ctx context.Context, script, worldName string) { | |
| 506 | + | if wv.destroyed.Load() { | |
| 507 | + | return | |
| 508 | + | } | |
| 509 | + | if ctx == nil { | |
| 510 | + | ctx = context.Background() | |
| 511 | + | } | |
| 512 | + | ||
| 513 | + | log := logging.FromContext(ctx) | |
| 514 | + | ||
| 515 | + | cb := gio.AsyncReadyCallback(func(_ uintptr, resPtr uintptr, _ uintptr) { | |
| 516 | + | if resPtr == 0 { | |
| 517 | + | log.Warn().Uint64("webview_id", uint64(wv.id)).Msg("RunJavaScriptAsync: nil async result") | |
| 518 | + | return | |
| 519 | + | } | |
| 520 | + | ||
| 521 | + | res := &gio.AsyncResultBase{Ptr: resPtr} | |
| 522 | + | value, err := wv.inner.EvaluateJavascriptFinish(res) | |
| 523 | + | if err != nil { | |
| 524 | + | log.Warn().Err(err).Uint64("webview_id", uint64(wv.id)).Msg("RunJavaScriptAsync: failed") | |
| 525 | + | return | |
| 526 | + | } | |
| 527 | + | ||
| 528 | + | if value != nil { | |
| 529 | + | if jscCtx := value.GetContext(); jscCtx != nil { | |
| 530 | + | if exc := jscCtx.GetException(); exc != nil { | |
| 531 | + | log.Warn(). | |
| 532 | + | Str("exception", exc.GetMessage()). | |
| 533 | + | Uint64("webview_id", uint64(wv.id)). | |
| 534 | + | Msg("RunJavaScriptAsync: JS exception") | |
| 535 | + | } | |
| 536 | + | } | |
| 537 | + | } | |
| 538 | + | }) | |
| 539 | + | ||
| 540 | + | // prevent callback from being GC'd before it's called | |
| 541 | + | wv.mu.Lock() | |
| 542 | + | wv.asyncCallbacks = append(wv.asyncCallbacks, cb) | |
| 543 | + | wv.mu.Unlock() | |
| 544 | + | ||
| 545 | + | wv.inner.EvaluateJavascript(script, -1, worldName, "", nil, &cb, 0) | |
| 546 | + | } | |
| 547 | + | ||
| 548 | + | // AttachFrontend injects scripts/styles and wires the message router once per WebView. | |
| 549 | + | func (wv *WebView) AttachFrontend(ctx context.Context, injector *ContentInjector, router *MessageRouter) error { | |
| 550 | + | if ctx == nil { | |
| 551 | + | ctx = context.Background() | |
| 552 | + | } | |
| 553 | + | ||
| 554 | + | log := logging.FromContext(ctx).With(). | |
| 555 | + | Str("component", "webview"). | |
| 556 | + | Uint64("webview_id", uint64(wv.id)). | |
| 557 | + | Logger() | |
| 558 | + | ||
| 559 | + | if wv.destroyed.Load() { | |
| 560 | + | return fmt.Errorf("webview %d is destroyed", wv.id) | |
| 561 | + | } | |
| 562 | + | if injector == nil && router == nil { | |
| 563 | + | return nil | |
| 564 | + | } | |
| 565 | + | ||
| 566 | + | if !wv.frontendAttached.CompareAndSwap(false, true) { | |
| 567 | + | return nil // already attached | |
| 568 | + | } | |
| 569 | + | ||
| 570 | + | var attachErr error | |
| 571 | + | ||
| 572 | + | defer func() { | |
| 573 | + | if attachErr != nil { | |
| 574 | + | // allow retry on next call | |
| 575 | + | wv.frontendAttached.Store(false) | |
| 576 | + | } | |
| 577 | + | }() | |
| 578 | + | ||
| 579 | + | if injector != nil { | |
| 580 | + | injector.InjectScripts(ctx, wv.ucm, wv.id) | |
| 581 | + | injector.InjectStyles(ctx, wv.ucm) | |
| 582 | + | } | |
| 583 | + | ||
| 584 | + | if router != nil { | |
| 585 | + | if _, err := router.SetupMessageHandler(wv.ucm, ScriptWorldName); err != nil { | |
| 586 | + | attachErr = fmt.Errorf("setup message router: %w", err) | |
| 587 | + | log.Warn().Err(err).Msg("failed to attach message router") | |
| 588 | + | return attachErr | |
| 589 | + | } | |
| 590 | + | } | |
| 591 | + | ||
| 592 | + | log.Debug().Msg("frontend assets attached to webview") | |
| 593 | + | return nil | |
| 594 | + | } | |
| @@ -1,11 +1,15 @@ | |||||
| 1 | package webkit | 1 | package webkit | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 4 | "fmt" | 6 | "fmt" | ||
| 5 | "sync" | 7 | "sync" | ||
| 6 | "sync/atomic" | 8 | "sync/atomic" | ||
| 7 | 9 | ||||
| 10 | + | "github.com/bnema/dumber/internal/logging" | |||
| 8 | "github.com/bnema/puregotk-webkit/webkit" | 11 | "github.com/bnema/puregotk-webkit/webkit" | ||
| 12 | + | "github.com/jwijenbergh/puregotk/v4/gio" | |||
| 9 | "github.com/jwijenbergh/puregotk/v4/gtk" | 13 | "github.com/jwijenbergh/puregotk/v4/gtk" | ||
| 10 | "github.com/rs/zerolog" | 14 | "github.com/rs/zerolog" | ||
| 11 | ) | 15 | ) | ||
| @@ -72,6 +76,7 @@ func LookupWebView(id WebViewID) *WebView { | |||||
| 72 | type WebView struct { | 76 | type WebView struct { | ||
| 73 | id WebViewID | 77 | id WebViewID | ||
| 74 | inner *webkit.WebView | 78 | inner *webkit.WebView | ||
| 79 | + | ucm *webkit.UserContentManager | |||
| 75 | 80 | ||||
| 76 | // State (protected by mutex) | 81 | // State (protected by mutex) | ||
| 77 | destroyed atomic.Bool | 82 | destroyed atomic.Bool | ||
| @@ -96,6 +101,11 @@ type WebView struct { | |||||
| 96 | 101 | ||||
| 97 | logger zerolog.Logger | 102 | logger zerolog.Logger | ||
| 98 | mu sync.RWMutex | 103 | mu sync.RWMutex | ||
| 104 | + | ||||
| 105 | + | frontendAttached atomic.Bool | |||
| 106 | + | ||||
| 107 | + | // asyncCallbacks keeps references to async JS callbacks to prevent GC | |||
| 108 | + | asyncCallbacks []interface{} | |||
| 99 | } | 109 | } | ||
| 100 | 110 | ||||
| 101 | // NewWebView creates a new WebView with the given context and settings. | 111 | // NewWebView creates a new WebView with the given context and settings. | ||
| @@ -111,6 +121,7 @@ func NewWebView(wkCtx *WebKitContext, settings *SettingsManager, logger zerolog. | |||||
| 111 | 121 | ||||
| 112 | wv := &WebView{ | 122 | wv := &WebView{ | ||
| 113 | inner: inner, | 123 | inner: inner, | ||
| 124 | + | ucm: inner.GetUserContentManager(), | |||
| 114 | logger: logger.With().Str("component", "webview").Logger(), | 125 | logger: logger.With().Str("component", "webview").Logger(), | ||
| 115 | signalIDs: make([]uint32, 0, 4), | 126 | signalIDs: make([]uint32, 0, 4), | ||
| 116 | } | 127 | } | ||
| @@ -148,6 +159,7 @@ func NewWebViewWithRelated(parent *WebView, settings *SettingsManager, logger ze | |||||
| 148 | 159 | ||||
| 149 | wv := &WebView{ | 160 | wv := &WebView{ | ||
| 150 | inner: inner, | 161 | inner: inner, | ||
| 162 | + | ucm: inner.GetUserContentManager(), | |||
| 151 | logger: logger.With().Str("component", "webview-popup").Logger(), | 163 | logger: logger.With().Str("component", "webview-popup").Logger(), | ||
| 152 | signalIDs: make([]uint32, 0, 6), | 164 | signalIDs: make([]uint32, 0, 6), | ||
| 153 | } | 165 | } | ||
| @@ -172,6 +184,7 @@ func NewWebViewWithRelated(parent *WebView, settings *SettingsManager, logger ze | |||||
| 172 | func (wv *WebView) connectSignals() { | 184 | func (wv *WebView) connectSignals() { | ||
| 173 | // load-changed signal | 185 | // load-changed signal | ||
| 174 | loadChangedCb := func(inner webkit.WebView, event webkit.LoadEvent) { | 186 | loadChangedCb := func(inner webkit.WebView, event webkit.LoadEvent) { | ||
| 187 | + | ||||
| 175 | wv.mu.Lock() | 188 | wv.mu.Lock() | ||
| 176 | wv.uri = inner.GetUri() | 189 | wv.uri = inner.GetUri() | ||
| 177 | wv.title = inner.GetTitle() | 190 | wv.title = inner.GetTitle() | ||
| @@ -257,23 +270,28 @@ func (wv *WebView) ID() WebViewID { | |||||
| 257 | return wv.id | 270 | return wv.id | ||
| 258 | } | 271 | } | ||
| 259 | 272 | ||||
| 273 | + | // UserContentManager returns the content manager associated with this WebView. | |||
| 274 | + | func (wv *WebView) UserContentManager() *webkit.UserContentManager { | |||
| 275 | + | return wv.ucm | |||
| 276 | + | } | |||
| 277 | + | ||||
| 260 | // Widget returns the underlying webkit.WebView for GTK embedding. | 278 | // Widget returns the underlying webkit.WebView for GTK embedding. | ||
| 261 | func (wv *WebView) Widget() *webkit.WebView { | 279 | func (wv *WebView) Widget() *webkit.WebView { | ||
| 262 | return wv.inner | 280 | return wv.inner | ||
| 263 | } | 281 | } | ||
| 264 | 282 | ||||
| 265 | // LoadURI loads the given URI. | 283 | // LoadURI loads the given URI. | ||
| 266 | - | func (wv *WebView) LoadURI(uri string) error { | 284 | + | func (wv *WebView) LoadURI(ctx context.Context, uri string) error { |
| 267 | if wv.destroyed.Load() { | 285 | if wv.destroyed.Load() { | ||
| 268 | return fmt.Errorf("webview %d is destroyed", wv.id) | 286 | return fmt.Errorf("webview %d is destroyed", wv.id) | ||
| 269 | } | 287 | } | ||
| 270 | wv.inner.LoadUri(uri) | 288 | wv.inner.LoadUri(uri) | ||
| 271 | - | wv.logger.Debug().Str("uri", uri).Msg("loading URI") | 289 | + | logging.FromContext(ctx).Debug().Str("uri", uri).Msg("loading URI") |
| 272 | return nil | 290 | return nil | ||
| 273 | } | 291 | } | ||
| 274 | 292 | ||||
| 275 | // LoadHTML loads HTML content with an optional base URI. | 293 | // LoadHTML loads HTML content with an optional base URI. | ||
| 276 | - | func (wv *WebView) LoadHTML(content, baseURI string) error { | 294 | + | func (wv *WebView) LoadHTML(ctx context.Context, content, baseURI string) error { |
| 277 | if wv.destroyed.Load() { | 295 | if wv.destroyed.Load() { | ||
| 278 | return fmt.Errorf("webview %d is destroyed", wv.id) | 296 | return fmt.Errorf("webview %d is destroyed", wv.id) | ||
| 279 | } | 297 | } | ||
| @@ -282,7 +300,7 @@ func (wv *WebView) LoadHTML(content, baseURI string) error { | |||||
| 282 | } | 300 | } | ||
| 283 | 301 | ||||
| 284 | // Reload reloads the current page. | 302 | // Reload reloads the current page. | ||
| 285 | - | func (wv *WebView) Reload() error { | 303 | + | func (wv *WebView) Reload(ctx context.Context) error { |
| 286 | if wv.destroyed.Load() { | 304 | if wv.destroyed.Load() { | ||
| 287 | return fmt.Errorf("webview %d is destroyed", wv.id) | 305 | return fmt.Errorf("webview %d is destroyed", wv.id) | ||
| 288 | } | 306 | } | ||
| @@ -291,7 +309,7 @@ func (wv *WebView) Reload() error { | |||||
| 291 | } | 309 | } | ||
| 292 | 310 | ||||
| 293 | // ReloadBypassCache reloads the current page, bypassing the cache. | 311 | // ReloadBypassCache reloads the current page, bypassing the cache. | ||
| 294 | - | func (wv *WebView) ReloadBypassCache() error { | 312 | + | func (wv *WebView) ReloadBypassCache(ctx context.Context) error { |
| 295 | if wv.destroyed.Load() { | 313 | if wv.destroyed.Load() { | ||
| 296 | return fmt.Errorf("webview %d is destroyed", wv.id) | 314 | return fmt.Errorf("webview %d is destroyed", wv.id) | ||
| 297 | } | 315 | } | ||
| @@ -300,7 +318,7 @@ func (wv *WebView) ReloadBypassCache() error { | |||||
| 300 | } | 318 | } | ||
| 301 | 319 | ||||
| 302 | // Stop stops the current load operation. | 320 | // Stop stops the current load operation. | ||
| 303 | - | func (wv *WebView) Stop() error { | 321 | + | func (wv *WebView) Stop(ctx context.Context) error { |
| 304 | if wv.destroyed.Load() { | 322 | if wv.destroyed.Load() { | ||
| 305 | return fmt.Errorf("webview %d is destroyed", wv.id) | 323 | return fmt.Errorf("webview %d is destroyed", wv.id) | ||
| 306 | } | 324 | } | ||
| @@ -309,7 +327,7 @@ func (wv *WebView) Stop() error { | |||||
| 309 | } | 327 | } | ||
| 310 | 328 | ||||
| 311 | // GoBack navigates back in history. | 329 | // GoBack navigates back in history. | ||
| 312 | - | func (wv *WebView) GoBack() error { | 330 | + | func (wv *WebView) GoBack(ctx context.Context) error { |
| 313 | if wv.destroyed.Load() { | 331 | if wv.destroyed.Load() { | ||
| 314 | return fmt.Errorf("webview %d is destroyed", wv.id) | 332 | return fmt.Errorf("webview %d is destroyed", wv.id) | ||
| 315 | } | 333 | } | ||
| @@ -321,7 +339,7 @@ func (wv *WebView) GoBack() error { | |||||
| 321 | } | 339 | } | ||
| 322 | 340 | ||||
| 323 | // GoForward navigates forward in history. | 341 | // GoForward navigates forward in history. | ||
| 324 | - | func (wv *WebView) GoForward() error { | 342 | + | func (wv *WebView) GoForward(ctx context.Context) error { |
| 325 | if wv.destroyed.Load() { | 343 | if wv.destroyed.Load() { | ||
| 326 | return fmt.Errorf("webview %d is destroyed", wv.id) | 344 | return fmt.Errorf("webview %d is destroyed", wv.id) | ||
| 327 | } | 345 | } | ||
| @@ -375,7 +393,7 @@ func (wv *WebView) EstimatedProgress() float64 { | |||||
| 375 | } | 393 | } | ||
| 376 | 394 | ||||
| 377 | // SetZoomLevel sets the zoom level (1.0 = 100%). | 395 | // SetZoomLevel sets the zoom level (1.0 = 100%). | ||
| 378 | - | func (wv *WebView) SetZoomLevel(level float64) error { | 396 | + | func (wv *WebView) SetZoomLevel(ctx context.Context, level float64) error { |
| 379 | if wv.destroyed.Load() { | 397 | if wv.destroyed.Load() { | ||
| 380 | return fmt.Errorf("webview %d is destroyed", wv.id) | 398 | return fmt.Errorf("webview %d is destroyed", wv.id) | ||
| 381 | } | 399 | } | ||
| @@ -391,6 +409,20 @@ func (wv *WebView) GetZoomLevel() float64 { | |||||
| 391 | return wv.inner.GetZoomLevel() | 409 | return wv.inner.GetZoomLevel() | ||
| 392 | } | 410 | } | ||
| 393 | 411 | ||||
| 412 | + | // ShowDevTools opens the WebKit inspector/developer tools. | |||
| 413 | + | func (wv *WebView) ShowDevTools() error { | |||
| 414 | + | if wv.destroyed.Load() { | |||
| 415 | + | return fmt.Errorf("webview %d is destroyed", wv.id) | |||
| 416 | + | } | |||
| 417 | + | inspector := wv.inner.GetInspector() | |||
| 418 | + | if inspector == nil { | |||
| 419 | + | return fmt.Errorf("failed to get inspector for webview %d", wv.id) | |||
| 420 | + | } | |||
| 421 | + | inspector.Show() | |||
| 422 | + | wv.logger.Debug().Uint64("id", uint64(wv.id)).Msg("devtools shown") | |||
| 423 | + | return nil | |||
| 424 | + | } | |||
| 425 | + | ||||
| 394 | // IsDestroyed returns true if the WebView has been destroyed. | 426 | // IsDestroyed returns true if the WebView has been destroyed. | ||
| 395 | func (wv *WebView) IsDestroyed() bool { | 427 | func (wv *WebView) IsDestroyed() bool { | ||
| 396 | return wv.destroyed.Load() | 428 | return wv.destroyed.Load() | ||
| @@ -411,3 +443,152 @@ func (wv *WebView) Destroy() { | |||||
| 411 | 443 | ||||
| 412 | wv.logger.Debug().Uint64("id", uint64(wv.id)).Msg("webview destroyed") | 444 | wv.logger.Debug().Uint64("id", uint64(wv.id)).Msg("webview destroyed") | ||
| 413 | } | 445 | } | ||
| 446 | + | ||||
| 447 | + | // RunJavaScript executes script in the specified world (empty for main world). | |||
| 448 | + | // It waits for completion and reports JS exceptions through the returned error. | |||
| 449 | + | // WARNING: Do not call from GTK signal handlers - use RunJavaScriptAsync instead. | |||
| 450 | + | func (wv *WebView) RunJavaScript(ctx context.Context, script, worldName string) error { | |||
| 451 | + | if wv.destroyed.Load() { | |||
| 452 | + | return fmt.Errorf("webview %d is destroyed", wv.id) | |||
| 453 | + | } | |||
| 454 | + | if ctx == nil { | |||
| 455 | + | ctx = context.Background() | |||
| 456 | + | } | |||
| 457 | + | ||||
| 458 | + | log := logging.FromContext(ctx) | |||
| 459 | + | ||||
| 460 | + | done := make(chan struct{}) | |||
| 461 | + | var execErr error | |||
| 462 | + | ||||
| 463 | + | cb := gio.AsyncReadyCallback(func(_ uintptr, resPtr uintptr, _ uintptr) { | |||
| 464 | + | defer close(done) | |||
| 465 | + | ||||
| 466 | + | if resPtr == 0 { | |||
| 467 | + | execErr = errors.New("nil async result") | |||
| 468 | + | return | |||
| 469 | + | } | |||
| 470 | + | ||||
| 471 | + | res := &gio.AsyncResultBase{Ptr: resPtr} | |||
| 472 | + | value, err := wv.inner.EvaluateJavascriptFinish(res) | |||
| 473 | + | if err != nil { | |||
| 474 | + | execErr = err | |||
| 475 | + | return | |||
| 476 | + | } | |||
| 477 | + | ||||
| 478 | + | if value != nil { | |||
| 479 | + | if jscCtx := value.GetContext(); jscCtx != nil { | |||
| 480 | + | if exc := jscCtx.GetException(); exc != nil { | |||
| 481 | + | execErr = fmt.Errorf("javascript exception: %s", exc.GetMessage()) | |||
| 482 | + | } | |||
| 483 | + | } | |||
| 484 | + | } | |||
| 485 | + | }) | |||
| 486 | + | ||||
| 487 | + | wv.inner.EvaluateJavascript(script, -1, worldName, "", nil, &cb, 0) | |||
| 488 | + | ||||
| 489 | + | select { | |||
| 490 | + | case <-done: | |||
| 491 | + | if execErr != nil { | |||
| 492 | + | log.Warn(). | |||
| 493 | + | Err(execErr). | |||
| 494 | + | Uint64("webview_id", uint64(wv.id)). | |||
| 495 | + | Msg("javascript execution failed") | |||
| 496 | + | } | |||
| 497 | + | return execErr | |||
| 498 | + | case <-ctx.Done(): | |||
| 499 | + | return ctx.Err() | |||
| 500 | + | } | |||
| 501 | + | } | |||
| 502 | + | ||||
| 503 | + | // RunJavaScriptAsync executes script without waiting for completion. | |||
| 504 | + | // Safe to call from GTK signal handlers. Errors are logged but not returned. | |||
| 505 | + | func (wv *WebView) RunJavaScriptAsync(ctx context.Context, script, worldName string) { | |||
| 506 | + | if wv.destroyed.Load() { | |||
| 507 | + | return | |||
| 508 | + | } | |||
| 509 | + | if ctx == nil { | |||
| 510 | + | ctx = context.Background() | |||
| 511 | + | } | |||
| 512 | + | ||||
| 513 | + | log := logging.FromContext(ctx) | |||
| 514 | + | ||||
| 515 | + | cb := gio.AsyncReadyCallback(func(_ uintptr, resPtr uintptr, _ uintptr) { | |||
| 516 | + | if resPtr == 0 { | |||
| 517 | + | log.Warn().Uint64("webview_id", uint64(wv.id)).Msg("RunJavaScriptAsync: nil async result") | |||
| 518 | + | return | |||
| 519 | + | } | |||
| 520 | + | ||||
| 521 | + | res := &gio.AsyncResultBase{Ptr: resPtr} | |||
| 522 | + | value, err := wv.inner.EvaluateJavascriptFinish(res) | |||
| 523 | + | if err != nil { | |||
| 524 | + | log.Warn().Err(err).Uint64("webview_id", uint64(wv.id)).Msg("RunJavaScriptAsync: failed") | |||
| 525 | + | return | |||
| 526 | + | } | |||
| 527 | + | ||||
| 528 | + | if value != nil { | |||
| 529 | + | if jscCtx := value.GetContext(); jscCtx != nil { | |||
| 530 | + | if exc := jscCtx.GetException(); exc != nil { | |||
| 531 | + | log.Warn(). | |||
| 532 | + | Str("exception", exc.GetMessage()). | |||
| 533 | + | Uint64("webview_id", uint64(wv.id)). | |||
| 534 | + | Msg("RunJavaScriptAsync: JS exception") | |||
| 535 | + | } | |||
| 536 | + | } | |||
| 537 | + | } | |||
| 538 | + | }) | |||
| 539 | + | ||||
| 540 | + | // prevent callback from being GC'd before it's called | |||
| 541 | + | wv.mu.Lock() | |||
| 542 | + | wv.asyncCallbacks = append(wv.asyncCallbacks, cb) | |||
| 543 | + | wv.mu.Unlock() | |||
| 544 | + | ||||
| 545 | + | wv.inner.EvaluateJavascript(script, -1, worldName, "", nil, &cb, 0) | |||
| 546 | + | } | |||
| 547 | + | ||||
| 548 | + | // AttachFrontend injects scripts/styles and wires the message router once per WebView. | |||
| 549 | + | func (wv *WebView) AttachFrontend(ctx context.Context, injector *ContentInjector, router *MessageRouter) error { | |||
| 550 | + | if ctx == nil { | |||
| 551 | + | ctx = context.Background() | |||
| 552 | + | } | |||
| 553 | + | ||||
| 554 | + | log := logging.FromContext(ctx).With(). | |||
| 555 | + | Str("component", "webview"). | |||
| 556 | + | Uint64("webview_id", uint64(wv.id)). | |||
| 557 | + | Logger() | |||
| 558 | + | ||||
| 559 | + | if wv.destroyed.Load() { | |||
| 560 | + | return fmt.Errorf("webview %d is destroyed", wv.id) | |||
| 561 | + | } | |||
| 562 | + | if injector == nil && router == nil { | |||
| 563 | + | return nil | |||
| 564 | + | } | |||
| 565 | + | ||||
| 566 | + | if !wv.frontendAttached.CompareAndSwap(false, true) { | |||
| 567 | + | return nil // already attached | |||
| 568 | + | } | |||
| 569 | + | ||||
| 570 | + | var attachErr error | |||
| 571 | + | ||||
| 572 | + | defer func() { | |||
| 573 | + | if attachErr != nil { | |||
| 574 | + | // allow retry on next call | |||
| 575 | + | wv.frontendAttached.Store(false) | |||
| 576 | + | } | |||
| 577 | + | }() | |||
| 578 | + | ||||
| 579 | + | if injector != nil { | |||
| 580 | + | injector.InjectScripts(ctx, wv.ucm, wv.id) | |||
| 581 | + | injector.InjectStyles(ctx, wv.ucm) | |||
| 582 | + | } | |||
| 583 | + | ||||
| 584 | + | if router != nil { | |||
| 585 | + | if _, err := router.SetupMessageHandler(wv.ucm, ScriptWorldName); err != nil { | |||
| 586 | + | attachErr = fmt.Errorf("setup message router: %w", err) | |||
| 587 | + | log.Warn().Err(err).Msg("failed to attach message router") | |||
| 588 | + | return attachErr | |||
| 589 | + | } | |||
| 590 | + | } | |||
| 591 | + | ||||
| 592 | + | log.Debug().Msg("frontend assets attached to webview") | |||
| 593 | + | return nil | |||
| 594 | + | } | |||
M internal/ui/layout/gtk_widgets.go +14 -0
| @@ -30,6 +30,7 @@ func (w *gtkWidget) GrabFocus() bool { return w.inner.GrabFocus() | |||
| 30 | 30 | func (w *gtkWidget) HasFocus() bool { return w.inner.HasFocus() } | |
| 31 | 31 | func (w *gtkWidget) SetCanFocus(canFocus bool) { w.inner.SetCanFocus(canFocus) } | |
| 32 | 32 | func (w *gtkWidget) SetFocusOnClick(focus bool) { w.inner.SetFocusOnClick(focus) } | |
| 33 | + | func (w *gtkWidget) SetCanTarget(canTarget bool) { w.inner.SetCanTarget(canTarget) } | |
| 33 | 34 | func (w *gtkWidget) SetHexpand(expand bool) { w.inner.SetHexpand(expand) } | |
| 34 | 35 | func (w *gtkWidget) SetVexpand(expand bool) { w.inner.SetVexpand(expand) } | |
| 35 | 36 | func (w *gtkWidget) GetHexpand() bool { return w.inner.GetHexpand() } | |
| @@ -64,6 +65,7 @@ func (p *gtkPaned) GrabFocus() bool { return p.inner.GrabFocus() } | |||
| 64 | 65 | func (p *gtkPaned) HasFocus() bool { return p.inner.HasFocus() } | |
| 65 | 66 | func (p *gtkPaned) SetCanFocus(canFocus bool) { p.inner.SetCanFocus(canFocus) } | |
| 66 | 67 | func (p *gtkPaned) SetFocusOnClick(focus bool) { p.inner.SetFocusOnClick(focus) } | |
| 68 | + | func (p *gtkPaned) SetCanTarget(canTarget bool) { p.inner.SetCanTarget(canTarget) } | |
| 67 | 69 | func (p *gtkPaned) SetHexpand(expand bool) { p.inner.SetHexpand(expand) } | |
| 68 | 70 | func (p *gtkPaned) SetVexpand(expand bool) { p.inner.SetVexpand(expand) } | |
| 69 | 71 | func (p *gtkPaned) GetHexpand() bool { return p.inner.GetHexpand() } | |
| @@ -137,6 +139,13 @@ func (p *gtkPaned) ConnectMap(callback func()) uint32 { | |||
| 137 | 139 | return p.inner.ConnectMap(&cb) | |
| 138 | 140 | } | |
| 139 | 141 | ||
| 142 | + | func (p *gtkPaned) AddTickCallback(callback func() bool) uint { | |
| 143 | + | cb := gtk.TickCallback(func(widget uintptr, frameClock uintptr, userData uintptr) bool { | |
| 144 | + | return callback() | |
| 145 | + | }) | |
| 146 | + | return p.inner.AddTickCallback(&cb, 0, nil) | |
| 147 | + | } | |
| 148 | + | ||
| 140 | 149 | func (p *gtkPaned) GetAllocatedWidth() int { return p.inner.GetAllocatedWidth() } | |
| 141 | 150 | func (p *gtkPaned) GetAllocatedHeight() int { return p.inner.GetAllocatedHeight() } | |
| 142 | 151 | ||
| @@ -153,6 +162,7 @@ func (b *gtkBox) GrabFocus() bool { return b.inner.GrabFocus() } | |||
| 153 | 162 | func (b *gtkBox) HasFocus() bool { return b.inner.HasFocus() } | |
| 154 | 163 | func (b *gtkBox) SetCanFocus(canFocus bool) { b.inner.SetCanFocus(canFocus) } | |
| 155 | 164 | func (b *gtkBox) SetFocusOnClick(focus bool) { b.inner.SetFocusOnClick(focus) } | |
| 165 | + | func (b *gtkBox) SetCanTarget(canTarget bool) { b.inner.SetCanTarget(canTarget) } | |
| 156 | 166 | func (b *gtkBox) SetHexpand(expand bool) { b.inner.SetHexpand(expand) } | |
| 157 | 167 | func (b *gtkBox) SetVexpand(expand bool) { b.inner.SetVexpand(expand) } | |
| 158 | 168 | func (b *gtkBox) GetHexpand() bool { return b.inner.GetHexpand() } | |
| @@ -237,6 +247,7 @@ func (o *gtkOverlay) GrabFocus() bool { return o.inner.GrabFocus() | |||
| 237 | 247 | func (o *gtkOverlay) HasFocus() bool { return o.inner.HasFocus() } | |
| 238 | 248 | func (o *gtkOverlay) SetCanFocus(canFocus bool) { o.inner.SetCanFocus(canFocus) } | |
| 239 | 249 | func (o *gtkOverlay) SetFocusOnClick(focus bool) { o.inner.SetFocusOnClick(focus) } | |
| 250 | + | func (o *gtkOverlay) SetCanTarget(canTarget bool) { o.inner.SetCanTarget(canTarget) } | |
| 240 | 251 | func (o *gtkOverlay) SetHexpand(expand bool) { o.inner.SetHexpand(expand) } | |
| 241 | 252 | func (o *gtkOverlay) SetVexpand(expand bool) { o.inner.SetVexpand(expand) } | |
| 242 | 253 | func (o *gtkOverlay) GetHexpand() bool { return o.inner.GetHexpand() } | |
| @@ -329,6 +340,7 @@ func (l *gtkLabel) GrabFocus() bool { return l.inner.GrabFocus() } | |||
| 329 | 340 | func (l *gtkLabel) HasFocus() bool { return l.inner.HasFocus() } | |
| 330 | 341 | func (l *gtkLabel) SetCanFocus(canFocus bool) { l.inner.SetCanFocus(canFocus) } | |
| 331 | 342 | func (l *gtkLabel) SetFocusOnClick(focus bool) { l.inner.SetFocusOnClick(focus) } | |
| 343 | + | func (l *gtkLabel) SetCanTarget(canTarget bool) { l.inner.SetCanTarget(canTarget) } | |
| 332 | 344 | func (l *gtkLabel) SetHexpand(expand bool) { l.inner.SetHexpand(expand) } | |
| 333 | 345 | func (l *gtkLabel) SetVexpand(expand bool) { l.inner.SetVexpand(expand) } | |
| 334 | 346 | func (l *gtkLabel) GetHexpand() bool { return l.inner.GetHexpand() } | |
| @@ -370,6 +382,7 @@ func (btn *gtkButton) GrabFocus() bool { return btn.inner.GrabFocu | |||
| 370 | 382 | func (btn *gtkButton) HasFocus() bool { return btn.inner.HasFocus() } | |
| 371 | 383 | func (btn *gtkButton) SetCanFocus(canFocus bool) { btn.inner.SetCanFocus(canFocus) } | |
| 372 | 384 | func (btn *gtkButton) SetFocusOnClick(focus bool) { btn.inner.SetFocusOnClick(focus) } | |
| 385 | + | func (btn *gtkButton) SetCanTarget(canTarget bool) { btn.inner.SetCanTarget(canTarget) } | |
| 373 | 386 | func (btn *gtkButton) SetHexpand(expand bool) { btn.inner.SetHexpand(expand) } | |
| 374 | 387 | func (btn *gtkButton) SetVexpand(expand bool) { btn.inner.SetVexpand(expand) } | |
| 375 | 388 | func (btn *gtkButton) GetHexpand() bool { return btn.inner.GetHexpand() } | |
| @@ -430,6 +443,7 @@ func (img *gtkImage) GrabFocus() bool { return img.inner.GrabFocus | |||
| 430 | 443 | func (img *gtkImage) HasFocus() bool { return img.inner.HasFocus() } | |
| 431 | 444 | func (img *gtkImage) SetCanFocus(canFocus bool) { img.inner.SetCanFocus(canFocus) } | |
| 432 | 445 | func (img *gtkImage) SetFocusOnClick(focus bool) { img.inner.SetFocusOnClick(focus) } | |
| 446 | + | func (img *gtkImage) SetCanTarget(canTarget bool) { img.inner.SetCanTarget(canTarget) } | |
| 433 | 447 | func (img *gtkImage) SetHexpand(expand bool) { img.inner.SetHexpand(expand) } | |
| 434 | 448 | func (img *gtkImage) SetVexpand(expand bool) { img.inner.SetVexpand(expand) } | |
| 435 | 449 | func (img *gtkImage) GetHexpand() bool { return img.inner.GetHexpand() } | |
| @@ -30,6 +30,7 @@ func (w *gtkWidget) GrabFocus() bool { return w.inner.GrabFocus() | |||||
| 30 | func (w *gtkWidget) HasFocus() bool { return w.inner.HasFocus() } | 30 | func (w *gtkWidget) HasFocus() bool { return w.inner.HasFocus() } | ||
| 31 | func (w *gtkWidget) SetCanFocus(canFocus bool) { w.inner.SetCanFocus(canFocus) } | 31 | func (w *gtkWidget) SetCanFocus(canFocus bool) { w.inner.SetCanFocus(canFocus) } | ||
| 32 | func (w *gtkWidget) SetFocusOnClick(focus bool) { w.inner.SetFocusOnClick(focus) } | 32 | func (w *gtkWidget) SetFocusOnClick(focus bool) { w.inner.SetFocusOnClick(focus) } | ||
| 33 | + | func (w *gtkWidget) SetCanTarget(canTarget bool) { w.inner.SetCanTarget(canTarget) } | |||
| 33 | func (w *gtkWidget) SetHexpand(expand bool) { w.inner.SetHexpand(expand) } | 34 | func (w *gtkWidget) SetHexpand(expand bool) { w.inner.SetHexpand(expand) } | ||
| 34 | func (w *gtkWidget) SetVexpand(expand bool) { w.inner.SetVexpand(expand) } | 35 | func (w *gtkWidget) SetVexpand(expand bool) { w.inner.SetVexpand(expand) } | ||
| 35 | func (w *gtkWidget) GetHexpand() bool { return w.inner.GetHexpand() } | 36 | func (w *gtkWidget) GetHexpand() bool { return w.inner.GetHexpand() } | ||
| @@ -64,6 +65,7 @@ func (p *gtkPaned) GrabFocus() bool { return p.inner.GrabFocus() } | |||||
| 64 | func (p *gtkPaned) HasFocus() bool { return p.inner.HasFocus() } | 65 | func (p *gtkPaned) HasFocus() bool { return p.inner.HasFocus() } | ||
| 65 | func (p *gtkPaned) SetCanFocus(canFocus bool) { p.inner.SetCanFocus(canFocus) } | 66 | func (p *gtkPaned) SetCanFocus(canFocus bool) { p.inner.SetCanFocus(canFocus) } | ||
| 66 | func (p *gtkPaned) SetFocusOnClick(focus bool) { p.inner.SetFocusOnClick(focus) } | 67 | func (p *gtkPaned) SetFocusOnClick(focus bool) { p.inner.SetFocusOnClick(focus) } | ||
| 68 | + | func (p *gtkPaned) SetCanTarget(canTarget bool) { p.inner.SetCanTarget(canTarget) } | |||
| 67 | func (p *gtkPaned) SetHexpand(expand bool) { p.inner.SetHexpand(expand) } | 69 | func (p *gtkPaned) SetHexpand(expand bool) { p.inner.SetHexpand(expand) } | ||
| 68 | func (p *gtkPaned) SetVexpand(expand bool) { p.inner.SetVexpand(expand) } | 70 | func (p *gtkPaned) SetVexpand(expand bool) { p.inner.SetVexpand(expand) } | ||
| 69 | func (p *gtkPaned) GetHexpand() bool { return p.inner.GetHexpand() } | 71 | func (p *gtkPaned) GetHexpand() bool { return p.inner.GetHexpand() } | ||
| @@ -137,6 +139,13 @@ func (p *gtkPaned) ConnectMap(callback func()) uint32 { | |||||
| 137 | return p.inner.ConnectMap(&cb) | 139 | return p.inner.ConnectMap(&cb) | ||
| 138 | } | 140 | } | ||
| 139 | 141 | ||||
| 142 | + | func (p *gtkPaned) AddTickCallback(callback func() bool) uint { | |||
| 143 | + | cb := gtk.TickCallback(func(widget uintptr, frameClock uintptr, userData uintptr) bool { | |||
| 144 | + | return callback() | |||
| 145 | + | }) | |||
| 146 | + | return p.inner.AddTickCallback(&cb, 0, nil) | |||
| 147 | + | } | |||
| 148 | + | ||||
| 140 | func (p *gtkPaned) GetAllocatedWidth() int { return p.inner.GetAllocatedWidth() } | 149 | func (p *gtkPaned) GetAllocatedWidth() int { return p.inner.GetAllocatedWidth() } | ||
| 141 | func (p *gtkPaned) GetAllocatedHeight() int { return p.inner.GetAllocatedHeight() } | 150 | func (p *gtkPaned) GetAllocatedHeight() int { return p.inner.GetAllocatedHeight() } | ||
| 142 | 151 | ||||
| @@ -153,6 +162,7 @@ func (b *gtkBox) GrabFocus() bool { return b.inner.GrabFocus() } | |||||
| 153 | func (b *gtkBox) HasFocus() bool { return b.inner.HasFocus() } | 162 | func (b *gtkBox) HasFocus() bool { return b.inner.HasFocus() } | ||
| 154 | func (b *gtkBox) SetCanFocus(canFocus bool) { b.inner.SetCanFocus(canFocus) } | 163 | func (b *gtkBox) SetCanFocus(canFocus bool) { b.inner.SetCanFocus(canFocus) } | ||
| 155 | func (b *gtkBox) SetFocusOnClick(focus bool) { b.inner.SetFocusOnClick(focus) } | 164 | func (b *gtkBox) SetFocusOnClick(focus bool) { b.inner.SetFocusOnClick(focus) } | ||
| 165 | + | func (b *gtkBox) SetCanTarget(canTarget bool) { b.inner.SetCanTarget(canTarget) } | |||
| 156 | func (b *gtkBox) SetHexpand(expand bool) { b.inner.SetHexpand(expand) } | 166 | func (b *gtkBox) SetHexpand(expand bool) { b.inner.SetHexpand(expand) } | ||
| 157 | func (b *gtkBox) SetVexpand(expand bool) { b.inner.SetVexpand(expand) } | 167 | func (b *gtkBox) SetVexpand(expand bool) { b.inner.SetVexpand(expand) } | ||
| 158 | func (b *gtkBox) GetHexpand() bool { return b.inner.GetHexpand() } | 168 | func (b *gtkBox) GetHexpand() bool { return b.inner.GetHexpand() } | ||
| @@ -237,6 +247,7 @@ func (o *gtkOverlay) GrabFocus() bool { return o.inner.GrabFocus() | |||||
| 237 | func (o *gtkOverlay) HasFocus() bool { return o.inner.HasFocus() } | 247 | func (o *gtkOverlay) HasFocus() bool { return o.inner.HasFocus() } | ||
| 238 | func (o *gtkOverlay) SetCanFocus(canFocus bool) { o.inner.SetCanFocus(canFocus) } | 248 | func (o *gtkOverlay) SetCanFocus(canFocus bool) { o.inner.SetCanFocus(canFocus) } | ||
| 239 | func (o *gtkOverlay) SetFocusOnClick(focus bool) { o.inner.SetFocusOnClick(focus) } | 249 | func (o *gtkOverlay) SetFocusOnClick(focus bool) { o.inner.SetFocusOnClick(focus) } | ||
| 250 | + | func (o *gtkOverlay) SetCanTarget(canTarget bool) { o.inner.SetCanTarget(canTarget) } | |||
| 240 | func (o *gtkOverlay) SetHexpand(expand bool) { o.inner.SetHexpand(expand) } | 251 | func (o *gtkOverlay) SetHexpand(expand bool) { o.inner.SetHexpand(expand) } | ||
| 241 | func (o *gtkOverlay) SetVexpand(expand bool) { o.inner.SetVexpand(expand) } | 252 | func (o *gtkOverlay) SetVexpand(expand bool) { o.inner.SetVexpand(expand) } | ||
| 242 | func (o *gtkOverlay) GetHexpand() bool { return o.inner.GetHexpand() } | 253 | func (o *gtkOverlay) GetHexpand() bool { return o.inner.GetHexpand() } | ||
| @@ -329,6 +340,7 @@ func (l *gtkLabel) GrabFocus() bool { return l.inner.GrabFocus() } | |||||
| 329 | func (l *gtkLabel) HasFocus() bool { return l.inner.HasFocus() } | 340 | func (l *gtkLabel) HasFocus() bool { return l.inner.HasFocus() } | ||
| 330 | func (l *gtkLabel) SetCanFocus(canFocus bool) { l.inner.SetCanFocus(canFocus) } | 341 | func (l *gtkLabel) SetCanFocus(canFocus bool) { l.inner.SetCanFocus(canFocus) } | ||
| 331 | func (l *gtkLabel) SetFocusOnClick(focus bool) { l.inner.SetFocusOnClick(focus) } | 342 | func (l *gtkLabel) SetFocusOnClick(focus bool) { l.inner.SetFocusOnClick(focus) } | ||
| 343 | + | func (l *gtkLabel) SetCanTarget(canTarget bool) { l.inner.SetCanTarget(canTarget) } | |||
| 332 | func (l *gtkLabel) SetHexpand(expand bool) { l.inner.SetHexpand(expand) } | 344 | func (l *gtkLabel) SetHexpand(expand bool) { l.inner.SetHexpand(expand) } | ||
| 333 | func (l *gtkLabel) SetVexpand(expand bool) { l.inner.SetVexpand(expand) } | 345 | func (l *gtkLabel) SetVexpand(expand bool) { l.inner.SetVexpand(expand) } | ||
| 334 | func (l *gtkLabel) GetHexpand() bool { return l.inner.GetHexpand() } | 346 | func (l *gtkLabel) GetHexpand() bool { return l.inner.GetHexpand() } | ||
| @@ -370,6 +382,7 @@ func (btn *gtkButton) GrabFocus() bool { return btn.inner.GrabFocu | |||||
| 370 | func (btn *gtkButton) HasFocus() bool { return btn.inner.HasFocus() } | 382 | func (btn *gtkButton) HasFocus() bool { return btn.inner.HasFocus() } | ||
| 371 | func (btn *gtkButton) SetCanFocus(canFocus bool) { btn.inner.SetCanFocus(canFocus) } | 383 | func (btn *gtkButton) SetCanFocus(canFocus bool) { btn.inner.SetCanFocus(canFocus) } | ||
| 372 | func (btn *gtkButton) SetFocusOnClick(focus bool) { btn.inner.SetFocusOnClick(focus) } | 384 | func (btn *gtkButton) SetFocusOnClick(focus bool) { btn.inner.SetFocusOnClick(focus) } | ||
| 385 | + | func (btn *gtkButton) SetCanTarget(canTarget bool) { btn.inner.SetCanTarget(canTarget) } | |||
| 373 | func (btn *gtkButton) SetHexpand(expand bool) { btn.inner.SetHexpand(expand) } | 386 | func (btn *gtkButton) SetHexpand(expand bool) { btn.inner.SetHexpand(expand) } | ||
| 374 | func (btn *gtkButton) SetVexpand(expand bool) { btn.inner.SetVexpand(expand) } | 387 | func (btn *gtkButton) SetVexpand(expand bool) { btn.inner.SetVexpand(expand) } | ||
| 375 | func (btn *gtkButton) GetHexpand() bool { return btn.inner.GetHexpand() } | 388 | func (btn *gtkButton) GetHexpand() bool { return btn.inner.GetHexpand() } | ||
| @@ -430,6 +443,7 @@ func (img *gtkImage) GrabFocus() bool { return img.inner.GrabFocus | |||||
| 430 | func (img *gtkImage) HasFocus() bool { return img.inner.HasFocus() } | 443 | func (img *gtkImage) HasFocus() bool { return img.inner.HasFocus() } | ||
| 431 | func (img *gtkImage) SetCanFocus(canFocus bool) { img.inner.SetCanFocus(canFocus) } | 444 | func (img *gtkImage) SetCanFocus(canFocus bool) { img.inner.SetCanFocus(canFocus) } | ||
| 432 | func (img *gtkImage) SetFocusOnClick(focus bool) { img.inner.SetFocusOnClick(focus) } | 445 | func (img *gtkImage) SetFocusOnClick(focus bool) { img.inner.SetFocusOnClick(focus) } | ||
| 446 | + | func (img *gtkImage) SetCanTarget(canTarget bool) { img.inner.SetCanTarget(canTarget) } | |||
| 433 | func (img *gtkImage) SetHexpand(expand bool) { img.inner.SetHexpand(expand) } | 447 | func (img *gtkImage) SetHexpand(expand bool) { img.inner.SetHexpand(expand) } | ||
| 434 | func (img *gtkImage) SetVexpand(expand bool) { img.inner.SetVexpand(expand) } | 448 | func (img *gtkImage) SetVexpand(expand bool) { img.inner.SetVexpand(expand) } | ||
| 435 | func (img *gtkImage) GetHexpand() bool { return img.inner.GetHexpand() } | 449 | func (img *gtkImage) GetHexpand() bool { return img.inner.GetHexpand() } | ||
M internal/ui/layout/mocks/mock_paned_widget.go +51 -0
| @@ -77,6 +77,57 @@ func (_c *MockPanedWidget_AddCssClass_Call) RunAndReturn(run func(cssClass strin | |||
| 77 | 77 | return _c | |
| 78 | 78 | } | |
| 79 | 79 | ||
| 80 | + | // AddTickCallback provides a mock function for the type MockPanedWidget | |
| 81 | + | func (_mock *MockPanedWidget) AddTickCallback(callback func() bool) uint { | |
| 82 | + | ret := _mock.Called(callback) | |
| 83 | + | ||
| 84 | + | if len(ret) == 0 { | |
| 85 | + | panic("no return value specified for AddTickCallback") | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | var r0 uint | |
| 89 | + | if returnFunc, ok := ret.Get(0).(func(func() bool) uint); ok { | |
| 90 | + | r0 = returnFunc(callback) | |
| 91 | + | } else { | |
| 92 | + | r0 = ret.Get(0).(uint) | |
| 93 | + | } | |
| 94 | + | return r0 | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | // MockPanedWidget_AddTickCallback_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AddTickCallback' | |
| 98 | + | type MockPanedWidget_AddTickCallback_Call struct { | |
| 99 | + | *mock.Call | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | // AddTickCallback is a helper method to define mock.On call | |
| 103 | + | // - callback func() bool | |
| 104 | + | func (_e *MockPanedWidget_Expecter) AddTickCallback(callback interface{}) *MockPanedWidget_AddTickCallback_Call { | |
| 105 | + | return &MockPanedWidget_AddTickCallback_Call{Call: _e.mock.On("AddTickCallback", callback)} | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | func (_c *MockPanedWidget_AddTickCallback_Call) Run(run func(callback func() bool)) *MockPanedWidget_AddTickCallback_Call { | |
| 109 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 110 | + | var arg0 func() bool | |
| 111 | + | if args[0] != nil { | |
| 112 | + | arg0 = args[0].(func() bool) | |
| 113 | + | } | |
| 114 | + | run( | |
| 115 | + | arg0, | |
| 116 | + | ) | |
| 117 | + | }) | |
| 118 | + | return _c | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | func (_c *MockPanedWidget_AddTickCallback_Call) Return(v uint) *MockPanedWidget_AddTickCallback_Call { | |
| 122 | + | _c.Call.Return(v) | |
| 123 | + | return _c | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | func (_c *MockPanedWidget_AddTickCallback_Call) RunAndReturn(run func(callback func() bool) uint) *MockPanedWidget_AddTickCallback_Call { | |
| 127 | + | _c.Call.Return(run) | |
| 128 | + | return _c | |
| 129 | + | } | |
| 130 | + | ||
| 80 | 131 | // ConnectMap provides a mock function for the type MockPanedWidget | |
| 81 | 132 | func (_mock *MockPanedWidget) ConnectMap(callback func()) uint32 { | |
| 82 | 133 | ret := _mock.Called(callback) | |
| @@ -77,6 +77,57 @@ func (_c *MockPanedWidget_AddCssClass_Call) RunAndReturn(run func(cssClass strin | |||||
| 77 | return _c | 77 | return _c | ||
| 78 | } | 78 | } | ||
| 79 | 79 | ||||
| 80 | + | // AddTickCallback provides a mock function for the type MockPanedWidget | |||
| 81 | + | func (_mock *MockPanedWidget) AddTickCallback(callback func() bool) uint { | |||
| 82 | + | ret := _mock.Called(callback) | |||
| 83 | + | ||||
| 84 | + | if len(ret) == 0 { | |||
| 85 | + | panic("no return value specified for AddTickCallback") | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | var r0 uint | |||
| 89 | + | if returnFunc, ok := ret.Get(0).(func(func() bool) uint); ok { | |||
| 90 | + | r0 = returnFunc(callback) | |||
| 91 | + | } else { | |||
| 92 | + | r0 = ret.Get(0).(uint) | |||
| 93 | + | } | |||
| 94 | + | return r0 | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | // MockPanedWidget_AddTickCallback_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AddTickCallback' | |||
| 98 | + | type MockPanedWidget_AddTickCallback_Call struct { | |||
| 99 | + | *mock.Call | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | // AddTickCallback is a helper method to define mock.On call | |||
| 103 | + | // - callback func() bool | |||
| 104 | + | func (_e *MockPanedWidget_Expecter) AddTickCallback(callback interface{}) *MockPanedWidget_AddTickCallback_Call { | |||
| 105 | + | return &MockPanedWidget_AddTickCallback_Call{Call: _e.mock.On("AddTickCallback", callback)} | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | func (_c *MockPanedWidget_AddTickCallback_Call) Run(run func(callback func() bool)) *MockPanedWidget_AddTickCallback_Call { | |||
| 109 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 110 | + | var arg0 func() bool | |||
| 111 | + | if args[0] != nil { | |||
| 112 | + | arg0 = args[0].(func() bool) | |||
| 113 | + | } | |||
| 114 | + | run( | |||
| 115 | + | arg0, | |||
| 116 | + | ) | |||
| 117 | + | }) | |||
| 118 | + | return _c | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | func (_c *MockPanedWidget_AddTickCallback_Call) Return(v uint) *MockPanedWidget_AddTickCallback_Call { | |||
| 122 | + | _c.Call.Return(v) | |||
| 123 | + | return _c | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | func (_c *MockPanedWidget_AddTickCallback_Call) RunAndReturn(run func(callback func() bool) uint) *MockPanedWidget_AddTickCallback_Call { | |||
| 127 | + | _c.Call.Return(run) | |||
| 128 | + | return _c | |||
| 129 | + | } | |||
| 130 | + | ||||
| 80 | // ConnectMap provides a mock function for the type MockPanedWidget | 131 | // ConnectMap provides a mock function for the type MockPanedWidget | ||
| 81 | func (_mock *MockPanedWidget) ConnectMap(callback func()) uint32 { | 132 | func (_mock *MockPanedWidget) ConnectMap(callback func()) uint32 { | ||
| 82 | ret := _mock.Called(callback) | 133 | ret := _mock.Called(callback) | ||
M internal/ui/layout/split.go +57 -7
| @@ -2,6 +2,8 @@ package layout | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "sync" | |
| 5 | + | ||
| 6 | + | "github.com/rs/zerolog" | |
| 5 | 7 | ) | |
| 6 | 8 | ||
| 7 | 9 | // SplitView wraps a GTK Paned widget for creating split pane layouts. | |
| @@ -12,22 +14,26 @@ type SplitView struct { | |||
| 12 | 14 | startChild Widget | |
| 13 | 15 | endChild Widget | |
| 14 | 16 | ratio float64 // 0.0-1.0, relative position of divider | |
| 17 | + | logger zerolog.Logger | |
| 15 | 18 | ||
| 16 | 19 | mu sync.RWMutex | |
| 17 | 20 | } | |
| 18 | 21 | ||
| 22 | + | // maxRetryFrames is the maximum number of frames to wait for allocation to stabilize. | |
| 23 | + | // At 60fps, 120 frames = ~2 seconds timeout. | |
| 24 | + | const maxRetryFrames = 120 | |
| 25 | + | ||
| 19 | 26 | // NewSplitView creates a new split view with the given orientation and children. | |
| 20 | 27 | // The ratio determines the initial divider position (0.0-1.0). | |
| 21 | - | func NewSplitView(factory WidgetFactory, orientation Orientation, startChild, endChild Widget, ratio float64) *SplitView { | |
| 28 | + | func NewSplitView(factory WidgetFactory, logger zerolog.Logger, orientation Orientation, startChild, endChild Widget, ratio float64) *SplitView { | |
| 22 | 29 | paned := factory.NewPaned(orientation) | |
| 23 | 30 | ||
| 24 | 31 | // Configure resize behavior - both children should resize | |
| 32 | + | // Note: Do NOT set SetShrinkStartChild/SetShrinkEndChild to false | |
| 33 | + | // as that prevents GTK from shrinking children to fit the ratio | |
| 25 | 34 | paned.SetResizeStartChild(true) | |
| 26 | 35 | paned.SetResizeEndChild(true) | |
| 27 | - | ||
| 28 | - | // Prevent children from shrinking below minimum size | |
| 29 | - | paned.SetShrinkStartChild(false) | |
| 30 | - | paned.SetShrinkEndChild(false) | |
| 36 | + | paned.SetVisible(true) | |
| 31 | 37 | ||
| 32 | 38 | sv := &SplitView{ | |
| 33 | 39 | paned: paned, | |
| @@ -35,21 +41,50 @@ func NewSplitView(factory WidgetFactory, orientation Orientation, startChild, en | |||
| 35 | 41 | startChild: startChild, | |
| 36 | 42 | endChild: endChild, | |
| 37 | 43 | ratio: clampRatio(ratio), | |
| 44 | + | logger: logger.With().Str("component", "split-view").Logger(), | |
| 38 | 45 | } | |
| 39 | 46 | ||
| 40 | 47 | // Set children | |
| 41 | 48 | if startChild != nil { | |
| 49 | + | startChild.SetVisible(true) | |
| 42 | 50 | paned.SetStartChild(startChild) | |
| 43 | 51 | } | |
| 44 | 52 | if endChild != nil { | |
| 53 | + | endChild.SetVisible(true) | |
| 45 | 54 | paned.SetEndChild(endChild) | |
| 46 | 55 | } | |
| 47 | 56 | ||
| 57 | + | // Try to apply ratio immediately (in case widget is already allocated) | |
| 58 | + | if sv.ApplyRatio() { | |
| 59 | + | return sv | |
| 60 | + | } | |
| 61 | + | ||
| 48 | 62 | // Apply ratio after widget is mapped (when we know the allocated size) | |
| 49 | 63 | paned.ConnectMap(func() { | |
| 50 | 64 | sv.ApplyRatio() | |
| 51 | 65 | }) | |
| 52 | 66 | ||
| 67 | + | // Add tick callback to retry applying ratio every frame until successful. | |
| 68 | + | // This handles cases where allocation isn't ready even after Map signal. | |
| 69 | + | frames := 0 | |
| 70 | + | paned.AddTickCallback(func() bool { | |
| 71 | + | frames++ | |
| 72 | + | if sv.ApplyRatio() { | |
| 73 | + | sv.logger.Debug(). | |
| 74 | + | Int("frames", frames). | |
| 75 | + | Float64("ratio", sv.GetRatio()). | |
| 76 | + | Msg("tick callback: ratio applied successfully") | |
| 77 | + | return false // Stop ticking - ratio applied successfully | |
| 78 | + | } | |
| 79 | + | if frames >= maxRetryFrames { | |
| 80 | + | sv.logger.Warn(). | |
| 81 | + | Int("frames", frames). | |
| 82 | + | Msg("tick callback: timeout reached, allocation never stabilized") | |
| 83 | + | return false // Stop ticking - timeout reached | |
| 84 | + | } | |
| 85 | + | return true // Continue ticking | |
| 86 | + | }) | |
| 87 | + | ||
| 53 | 88 | return sv | |
| 54 | 89 | } | |
| 55 | 90 | ||
| @@ -74,7 +109,8 @@ func (sv *SplitView) GetRatio() float64 { | |||
| 74 | 109 | ||
| 75 | 110 | // ApplyRatio converts the ratio to a pixel position and applies it. | |
| 76 | 111 | // This should be called after the widget has been mapped and has an allocated size. | |
| 77 | - | func (sv *SplitView) ApplyRatio() { | |
| 112 | + | // Returns true if the ratio was successfully applied, false if the widget is not yet allocated. | |
| 113 | + | func (sv *SplitView) ApplyRatio() bool { | |
| 78 | 114 | sv.mu.RLock() | |
| 79 | 115 | ratio := sv.ratio | |
| 80 | 116 | orientation := sv.orientation | |
| @@ -82,19 +118,33 @@ func (sv *SplitView) ApplyRatio() { | |||
| 82 | 118 | ||
| 83 | 119 | // Get the relevant dimension based on orientation | |
| 84 | 120 | var totalSize int | |
| 121 | + | var orientStr string | |
| 85 | 122 | if orientation == OrientationHorizontal { | |
| 86 | 123 | totalSize = sv.paned.GetAllocatedWidth() | |
| 124 | + | orientStr = "horizontal" | |
| 87 | 125 | } else { | |
| 88 | 126 | totalSize = sv.paned.GetAllocatedHeight() | |
| 127 | + | orientStr = "vertical" | |
| 89 | 128 | } | |
| 90 | 129 | ||
| 91 | 130 | if totalSize <= 0 { | |
| 92 | - | return // Not yet allocated | |
| 131 | + | sv.logger.Debug(). | |
| 132 | + | Str("orientation", orientStr). | |
| 133 | + | Int("total_size", totalSize). | |
| 134 | + | Msg("ApplyRatio: not yet allocated") | |
| 135 | + | return false // Not yet allocated | |
| 93 | 136 | } | |
| 94 | 137 | ||
| 95 | 138 | // Calculate position from ratio | |
| 96 | 139 | position := int(float64(totalSize) * ratio) | |
| 140 | + | sv.logger.Debug(). | |
| 141 | + | Str("orientation", orientStr). | |
| 142 | + | Int("total_size", totalSize). | |
| 143 | + | Float64("ratio", ratio). | |
| 144 | + | Int("position", position). | |
| 145 | + | Msg("ApplyRatio: setting position") | |
| 97 | 146 | sv.paned.SetPosition(position) | |
| 147 | + | return true | |
| 98 | 148 | } | |
| 99 | 149 | ||
| 100 | 150 | // SetPosition sets the divider position in pixels. | |
| @@ -2,6 +2,8 @@ package layout | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "sync" | 4 | "sync" | ||
| 5 | + | ||||
| 6 | + | "github.com/rs/zerolog" | |||
| 5 | ) | 7 | ) | ||
| 6 | 8 | ||||
| 7 | // SplitView wraps a GTK Paned widget for creating split pane layouts. | 9 | // SplitView wraps a GTK Paned widget for creating split pane layouts. | ||
| @@ -12,22 +14,26 @@ type SplitView struct { | |||||
| 12 | startChild Widget | 14 | startChild Widget | ||
| 13 | endChild Widget | 15 | endChild Widget | ||
| 14 | ratio float64 // 0.0-1.0, relative position of divider | 16 | ratio float64 // 0.0-1.0, relative position of divider | ||
| 17 | + | logger zerolog.Logger | |||
| 15 | 18 | ||||
| 16 | mu sync.RWMutex | 19 | mu sync.RWMutex | ||
| 17 | } | 20 | } | ||
| 18 | 21 | ||||
| 22 | + | // maxRetryFrames is the maximum number of frames to wait for allocation to stabilize. | |||
| 23 | + | // At 60fps, 120 frames = ~2 seconds timeout. | |||
| 24 | + | const maxRetryFrames = 120 | |||
| 25 | + | ||||
| 19 | // NewSplitView creates a new split view with the given orientation and children. | 26 | // NewSplitView creates a new split view with the given orientation and children. | ||
| 20 | // The ratio determines the initial divider position (0.0-1.0). | 27 | // The ratio determines the initial divider position (0.0-1.0). | ||
| 21 | - | func NewSplitView(factory WidgetFactory, orientation Orientation, startChild, endChild Widget, ratio float64) *SplitView { | 28 | + | func NewSplitView(factory WidgetFactory, logger zerolog.Logger, orientation Orientation, startChild, endChild Widget, ratio float64) *SplitView { |
| 22 | paned := factory.NewPaned(orientation) | 29 | paned := factory.NewPaned(orientation) | ||
| 23 | 30 | ||||
| 24 | // Configure resize behavior - both children should resize | 31 | // Configure resize behavior - both children should resize | ||
| 32 | + | // Note: Do NOT set SetShrinkStartChild/SetShrinkEndChild to false | |||
| 33 | + | // as that prevents GTK from shrinking children to fit the ratio | |||
| 25 | paned.SetResizeStartChild(true) | 34 | paned.SetResizeStartChild(true) | ||
| 26 | paned.SetResizeEndChild(true) | 35 | paned.SetResizeEndChild(true) | ||
| 27 | - | 36 | + | paned.SetVisible(true) | |
| 28 | - | // Prevent children from shrinking below minimum size | |||
| 29 | - | paned.SetShrinkStartChild(false) | |||
| 30 | - | paned.SetShrinkEndChild(false) | |||
| 31 | 37 | ||||
| 32 | sv := &SplitView{ | 38 | sv := &SplitView{ | ||
| 33 | paned: paned, | 39 | paned: paned, | ||
| @@ -35,21 +41,50 @@ func NewSplitView(factory WidgetFactory, orientation Orientation, startChild, en | |||||
| 35 | startChild: startChild, | 41 | startChild: startChild, | ||
| 36 | endChild: endChild, | 42 | endChild: endChild, | ||
| 37 | ratio: clampRatio(ratio), | 43 | ratio: clampRatio(ratio), | ||
| 44 | + | logger: logger.With().Str("component", "split-view").Logger(), | |||
| 38 | } | 45 | } | ||
| 39 | 46 | ||||
| 40 | // Set children | 47 | // Set children | ||
| 41 | if startChild != nil { | 48 | if startChild != nil { | ||
| 49 | + | startChild.SetVisible(true) | |||
| 42 | paned.SetStartChild(startChild) | 50 | paned.SetStartChild(startChild) | ||
| 43 | } | 51 | } | ||
| 44 | if endChild != nil { | 52 | if endChild != nil { | ||
| 53 | + | endChild.SetVisible(true) | |||
| 45 | paned.SetEndChild(endChild) | 54 | paned.SetEndChild(endChild) | ||
| 46 | } | 55 | } | ||
| 47 | 56 | ||||
| 57 | + | // Try to apply ratio immediately (in case widget is already allocated) | |||
| 58 | + | if sv.ApplyRatio() { | |||
| 59 | + | return sv | |||
| 60 | + | } | |||
| 61 | + | ||||
| 48 | // Apply ratio after widget is mapped (when we know the allocated size) | 62 | // Apply ratio after widget is mapped (when we know the allocated size) | ||
| 49 | paned.ConnectMap(func() { | 63 | paned.ConnectMap(func() { | ||
| 50 | sv.ApplyRatio() | 64 | sv.ApplyRatio() | ||
| 51 | }) | 65 | }) | ||
| 52 | 66 | ||||
| 67 | + | // Add tick callback to retry applying ratio every frame until successful. | |||
| 68 | + | // This handles cases where allocation isn't ready even after Map signal. | |||
| 69 | + | frames := 0 | |||
| 70 | + | paned.AddTickCallback(func() bool { | |||
| 71 | + | frames++ | |||
| 72 | + | if sv.ApplyRatio() { | |||
| 73 | + | sv.logger.Debug(). | |||
| 74 | + | Int("frames", frames). | |||
| 75 | + | Float64("ratio", sv.GetRatio()). | |||
| 76 | + | Msg("tick callback: ratio applied successfully") | |||
| 77 | + | return false // Stop ticking - ratio applied successfully | |||
| 78 | + | } | |||
| 79 | + | if frames >= maxRetryFrames { | |||
| 80 | + | sv.logger.Warn(). | |||
| 81 | + | Int("frames", frames). | |||
| 82 | + | Msg("tick callback: timeout reached, allocation never stabilized") | |||
| 83 | + | return false // Stop ticking - timeout reached | |||
| 84 | + | } | |||
| 85 | + | return true // Continue ticking | |||
| 86 | + | }) | |||
| 87 | + | ||||
| 53 | return sv | 88 | return sv | ||
| 54 | } | 89 | } | ||
| 55 | 90 | ||||
| @@ -74,7 +109,8 @@ func (sv *SplitView) GetRatio() float64 { | |||||
| 74 | 109 | ||||
| 75 | // ApplyRatio converts the ratio to a pixel position and applies it. | 110 | // ApplyRatio converts the ratio to a pixel position and applies it. | ||
| 76 | // This should be called after the widget has been mapped and has an allocated size. | 111 | // This should be called after the widget has been mapped and has an allocated size. | ||
| 77 | - | func (sv *SplitView) ApplyRatio() { | 112 | + | // Returns true if the ratio was successfully applied, false if the widget is not yet allocated. |
| 113 | + | func (sv *SplitView) ApplyRatio() bool { | |||
| 78 | sv.mu.RLock() | 114 | sv.mu.RLock() | ||
| 79 | ratio := sv.ratio | 115 | ratio := sv.ratio | ||
| 80 | orientation := sv.orientation | 116 | orientation := sv.orientation | ||
| @@ -82,19 +118,33 @@ func (sv *SplitView) ApplyRatio() { | |||||
| 82 | 118 | ||||
| 83 | // Get the relevant dimension based on orientation | 119 | // Get the relevant dimension based on orientation | ||
| 84 | var totalSize int | 120 | var totalSize int | ||
| 121 | + | var orientStr string | |||
| 85 | if orientation == OrientationHorizontal { | 122 | if orientation == OrientationHorizontal { | ||
| 86 | totalSize = sv.paned.GetAllocatedWidth() | 123 | totalSize = sv.paned.GetAllocatedWidth() | ||
| 124 | + | orientStr = "horizontal" | |||
| 87 | } else { | 125 | } else { | ||
| 88 | totalSize = sv.paned.GetAllocatedHeight() | 126 | totalSize = sv.paned.GetAllocatedHeight() | ||
| 127 | + | orientStr = "vertical" | |||
| 89 | } | 128 | } | ||
| 90 | 129 | ||||
| 91 | if totalSize <= 0 { | 130 | if totalSize <= 0 { | ||
| 92 | - | return // Not yet allocated | 131 | + | sv.logger.Debug(). |
| 132 | + | Str("orientation", orientStr). | |||
| 133 | + | Int("total_size", totalSize). | |||
| 134 | + | Msg("ApplyRatio: not yet allocated") | |||
| 135 | + | return false // Not yet allocated | |||
| 93 | } | 136 | } | ||
| 94 | 137 | ||||
| 95 | // Calculate position from ratio | 138 | // Calculate position from ratio | ||
| 96 | position := int(float64(totalSize) * ratio) | 139 | position := int(float64(totalSize) * ratio) | ||
| 140 | + | sv.logger.Debug(). | |||
| 141 | + | Str("orientation", orientStr). | |||
| 142 | + | Int("total_size", totalSize). | |||
| 143 | + | Float64("ratio", ratio). | |||
| 144 | + | Int("position", position). | |||
| 145 | + | Msg("ApplyRatio: setting position") | |||
| 97 | sv.paned.SetPosition(position) | 146 | sv.paned.SetPosition(position) | ||
| 147 | + | return true | |||
| 98 | } | 148 | } | ||
| 99 | 149 | ||||
| 100 | // SetPosition sets the divider position in pixels. | 150 | // SetPosition sets the divider position in pixels. | ||
M internal/ui/layout/split_test.go +50 -84
| @@ -3,6 +3,7 @@ package layout_test | |||
| 3 | 3 | import ( | |
| 4 | 4 | "testing" | |
| 5 | 5 | ||
| 6 | + | "github.com/rs/zerolog" | |
| 6 | 7 | "github.com/stretchr/testify/assert" | |
| 7 | 8 | "github.com/stretchr/testify/mock" | |
| 8 | 9 | "github.com/stretchr/testify/require" | |
| @@ -11,6 +12,27 @@ import ( | |||
| 11 | 12 | "github.com/bnema/dumber/internal/ui/layout/mocks" | |
| 12 | 13 | ) | |
| 13 | 14 | ||
| 15 | + | // setupSplitViewMocks configures the common mock expectations for NewSplitView. | |
| 16 | + | // Since the mock paned returns 0 for GetAllocatedWidth/Height, ApplyRatio will | |
| 17 | + | // return false and trigger the ConnectMap and AddTickCallback registrations. | |
| 18 | + | func setupSplitViewMocks(mockPaned *mocks.MockPanedWidget, orientation layout.Orientation) { | |
| 19 | + | mockPaned.EXPECT().SetResizeStartChild(true).Once() | |
| 20 | + | mockPaned.EXPECT().SetResizeEndChild(true).Once() | |
| 21 | + | // Note: SetShrinkStartChild/SetShrinkEndChild are NOT called - allowing shrink | |
| 22 | + | // enables GTK to respect the 50/50 ratio even if children have larger natural sizes | |
| 23 | + | ||
| 24 | + | // ApplyRatio is called immediately, which calls GetAllocatedWidth/Height | |
| 25 | + | if orientation == layout.OrientationHorizontal { | |
| 26 | + | mockPaned.EXPECT().GetAllocatedWidth().Return(0).Once() | |
| 27 | + | } else { | |
| 28 | + | mockPaned.EXPECT().GetAllocatedHeight().Return(0).Once() | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | // Since allocation is 0, ApplyRatio returns false and callbacks are registered | |
| 32 | + | mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once() | |
| 33 | + | mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once() | |
| 34 | + | } | |
| 35 | + | ||
| 14 | 36 | func TestNewSplitView_Horizontal(t *testing.T) { | |
| 15 | 37 | // Arrange | |
| 16 | 38 | mockFactory := mocks.NewMockWidgetFactory(t) | |
| @@ -19,16 +41,12 @@ func TestNewSplitView_Horizontal(t *testing.T) { | |||
| 19 | 41 | mockEndChild := mocks.NewMockWidget(t) | |
| 20 | 42 | ||
| 21 | 43 | mockFactory.EXPECT().NewPaned(layout.OrientationHorizontal).Return(mockPaned).Once() | |
| 22 | - | mockPaned.EXPECT().SetResizeStartChild(true).Once() | |
| 23 | - | mockPaned.EXPECT().SetResizeEndChild(true).Once() | |
| 24 | - | mockPaned.EXPECT().SetShrinkStartChild(false).Once() | |
| 25 | - | mockPaned.EXPECT().SetShrinkEndChild(false).Once() | |
| 44 | + | setupSplitViewMocks(mockPaned, layout.OrientationHorizontal) | |
| 26 | 45 | mockPaned.EXPECT().SetStartChild(mockStartChild).Once() | |
| 27 | 46 | mockPaned.EXPECT().SetEndChild(mockEndChild).Once() | |
| 28 | - | mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once() | |
| 29 | 47 | ||
| 30 | 48 | // Act | |
| 31 | - | sv := layout.NewSplitView(mockFactory, layout.OrientationHorizontal, mockStartChild, mockEndChild, 0.5) | |
| 49 | + | sv := layout.NewSplitView(mockFactory, zerolog.Nop(), layout.OrientationHorizontal, mockStartChild, mockEndChild, 0.5) | |
| 32 | 50 | ||
| 33 | 51 | // Assert | |
| 34 | 52 | require.NotNil(t, sv) | |
| @@ -46,16 +64,12 @@ func TestNewSplitView_Vertical(t *testing.T) { | |||
| 46 | 64 | mockEndChild := mocks.NewMockWidget(t) | |
| 47 | 65 | ||
| 48 | 66 | mockFactory.EXPECT().NewPaned(layout.OrientationVertical).Return(mockPaned).Once() | |
| 49 | - | mockPaned.EXPECT().SetResizeStartChild(true).Once() | |
| 50 | - | mockPaned.EXPECT().SetResizeEndChild(true).Once() | |
| 51 | - | mockPaned.EXPECT().SetShrinkStartChild(false).Once() | |
| 52 | - | mockPaned.EXPECT().SetShrinkEndChild(false).Once() | |
| 67 | + | setupSplitViewMocks(mockPaned, layout.OrientationVertical) | |
| 53 | 68 | mockPaned.EXPECT().SetStartChild(mockStartChild).Once() | |
| 54 | 69 | mockPaned.EXPECT().SetEndChild(mockEndChild).Once() | |
| 55 | - | mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once() | |
| 56 | 70 | ||
| 57 | 71 | // Act | |
| 58 | - | sv := layout.NewSplitView(mockFactory, layout.OrientationVertical, mockStartChild, mockEndChild, 0.5) | |
| 72 | + | sv := layout.NewSplitView(mockFactory, zerolog.Nop(), layout.OrientationVertical, mockStartChild, mockEndChild, 0.5) | |
| 59 | 73 | ||
| 60 | 74 | // Assert | |
| 61 | 75 | require.NotNil(t, sv) | |
| @@ -68,15 +82,11 @@ func TestNewSplitView_NilChildren(t *testing.T) { | |||
| 68 | 82 | mockPaned := mocks.NewMockPanedWidget(t) | |
| 69 | 83 | ||
| 70 | 84 | mockFactory.EXPECT().NewPaned(layout.OrientationHorizontal).Return(mockPaned).Once() | |
| 71 | - | mockPaned.EXPECT().SetResizeStartChild(true).Once() | |
| 72 | - | mockPaned.EXPECT().SetResizeEndChild(true).Once() | |
| 73 | - | mockPaned.EXPECT().SetShrinkStartChild(false).Once() | |
| 74 | - | mockPaned.EXPECT().SetShrinkEndChild(false).Once() | |
| 75 | - | mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once() | |
| 85 | + | setupSplitViewMocks(mockPaned, layout.OrientationHorizontal) | |
| 76 | 86 | // SetStartChild and SetEndChild should NOT be called when children are nil | |
| 77 | 87 | ||
| 78 | 88 | // Act | |
| 79 | - | sv := layout.NewSplitView(mockFactory, layout.OrientationHorizontal, nil, nil, 0.5) | |
| 89 | + | sv := layout.NewSplitView(mockFactory, zerolog.Nop(), layout.OrientationHorizontal, nil, nil, 0.5) | |
| 80 | 90 | ||
| 81 | 91 | // Assert | |
| 82 | 92 | require.NotNil(t, sv) | |
| @@ -104,13 +114,9 @@ func TestSetRatio_ValidRange(t *testing.T) { | |||
| 104 | 114 | mockPaned := mocks.NewMockPanedWidget(t) | |
| 105 | 115 | ||
| 106 | 116 | mockFactory.EXPECT().NewPaned(mock.Anything).Return(mockPaned).Once() | |
| 107 | - | mockPaned.EXPECT().SetResizeStartChild(true).Once() | |
| 108 | - | mockPaned.EXPECT().SetResizeEndChild(true).Once() | |
| 109 | - | mockPaned.EXPECT().SetShrinkStartChild(false).Once() | |
| 110 | - | mockPaned.EXPECT().SetShrinkEndChild(false).Once() | |
| 111 | - | mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once() | |
| 117 | + | setupSplitViewMocks(mockPaned, layout.OrientationHorizontal) | |
| 112 | 118 | ||
| 113 | - | sv := layout.NewSplitView(mockFactory, layout.OrientationHorizontal, nil, nil, 0.5) | |
| 119 | + | sv := layout.NewSplitView(mockFactory, zerolog.Nop(), layout.OrientationHorizontal, nil, nil, 0.5) | |
| 114 | 120 | ||
| 115 | 121 | // Act | |
| 116 | 122 | sv.SetRatio(tt.input) | |
| @@ -140,13 +146,9 @@ func TestSetRatio_OutOfRange_Clamped(t *testing.T) { | |||
| 140 | 146 | mockPaned := mocks.NewMockPanedWidget(t) | |
| 141 | 147 | ||
| 142 | 148 | mockFactory.EXPECT().NewPaned(mock.Anything).Return(mockPaned).Once() | |
| 143 | - | mockPaned.EXPECT().SetResizeStartChild(true).Once() | |
| 144 | - | mockPaned.EXPECT().SetResizeEndChild(true).Once() | |
| 145 | - | mockPaned.EXPECT().SetShrinkStartChild(false).Once() | |
| 146 | - | mockPaned.EXPECT().SetShrinkEndChild(false).Once() | |
| 147 | - | mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once() | |
| 149 | + | setupSplitViewMocks(mockPaned, layout.OrientationHorizontal) | |
| 148 | 150 | ||
| 149 | - | sv := layout.NewSplitView(mockFactory, layout.OrientationHorizontal, nil, nil, 0.5) | |
| 151 | + | sv := layout.NewSplitView(mockFactory, zerolog.Nop(), layout.OrientationHorizontal, nil, nil, 0.5) | |
| 150 | 152 | ||
| 151 | 153 | // Act | |
| 152 | 154 | sv.SetRatio(tt.input) | |
| @@ -165,14 +167,10 @@ func TestSwapStart_ReplacesChild(t *testing.T) { | |||
| 165 | 167 | mockNewChild := mocks.NewMockWidget(t) | |
| 166 | 168 | ||
| 167 | 169 | mockFactory.EXPECT().NewPaned(mock.Anything).Return(mockPaned).Once() | |
| 168 | - | mockPaned.EXPECT().SetResizeStartChild(true).Once() | |
| 169 | - | mockPaned.EXPECT().SetResizeEndChild(true).Once() | |
| 170 | - | mockPaned.EXPECT().SetShrinkStartChild(false).Once() | |
| 171 | - | mockPaned.EXPECT().SetShrinkEndChild(false).Once() | |
| 170 | + | setupSplitViewMocks(mockPaned, layout.OrientationHorizontal) | |
| 172 | 171 | mockPaned.EXPECT().SetStartChild(mockOldChild).Once() | |
| 173 | - | mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once() | |
| 174 | 172 | ||
| 175 | - | sv := layout.NewSplitView(mockFactory, layout.OrientationHorizontal, mockOldChild, nil, 0.5) | |
| 173 | + | sv := layout.NewSplitView(mockFactory, zerolog.Nop(), layout.OrientationHorizontal, mockOldChild, nil, 0.5) | |
| 176 | 174 | ||
| 177 | 175 | // Expect swap operations | |
| 178 | 176 | mockPaned.EXPECT().SetStartChild(nil).Once() // Remove old | |
| @@ -193,14 +191,10 @@ func TestSwapEnd_ReplacesChild(t *testing.T) { | |||
| 193 | 191 | mockNewChild := mocks.NewMockWidget(t) | |
| 194 | 192 | ||
| 195 | 193 | mockFactory.EXPECT().NewPaned(mock.Anything).Return(mockPaned).Once() | |
| 196 | - | mockPaned.EXPECT().SetResizeStartChild(true).Once() | |
| 197 | - | mockPaned.EXPECT().SetResizeEndChild(true).Once() | |
| 198 | - | mockPaned.EXPECT().SetShrinkStartChild(false).Once() | |
| 199 | - | mockPaned.EXPECT().SetShrinkEndChild(false).Once() | |
| 194 | + | setupSplitViewMocks(mockPaned, layout.OrientationHorizontal) | |
| 200 | 195 | mockPaned.EXPECT().SetEndChild(mockOldChild).Once() | |
| 201 | - | mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once() | |
| 202 | 196 | ||
| 203 | - | sv := layout.NewSplitView(mockFactory, layout.OrientationHorizontal, nil, mockOldChild, 0.5) | |
| 197 | + | sv := layout.NewSplitView(mockFactory, zerolog.Nop(), layout.OrientationHorizontal, nil, mockOldChild, 0.5) | |
| 204 | 198 | ||
| 205 | 199 | // Expect swap operations | |
| 206 | 200 | mockPaned.EXPECT().SetEndChild(nil).Once() // Remove old | |
| @@ -220,13 +214,9 @@ func TestSwapStart_FromNil(t *testing.T) { | |||
| 220 | 214 | mockNewChild := mocks.NewMockWidget(t) | |
| 221 | 215 | ||
| 222 | 216 | mockFactory.EXPECT().NewPaned(mock.Anything).Return(mockPaned).Once() | |
| 223 | - | mockPaned.EXPECT().SetResizeStartChild(true).Once() | |
| 224 | - | mockPaned.EXPECT().SetResizeEndChild(true).Once() | |
| 225 | - | mockPaned.EXPECT().SetShrinkStartChild(false).Once() | |
| 226 | - | mockPaned.EXPECT().SetShrinkEndChild(false).Once() | |
| 227 | - | mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once() | |
| 217 | + | setupSplitViewMocks(mockPaned, layout.OrientationHorizontal) | |
| 228 | 218 | ||
| 229 | - | sv := layout.NewSplitView(mockFactory, layout.OrientationHorizontal, nil, nil, 0.5) | |
| 219 | + | sv := layout.NewSplitView(mockFactory, zerolog.Nop(), layout.OrientationHorizontal, nil, nil, 0.5) | |
| 230 | 220 | ||
| 231 | 221 | // Expect only adding new child (no removal since old was nil) | |
| 232 | 222 | mockPaned.EXPECT().SetStartChild(mockNewChild).Once() | |
| @@ -245,14 +235,10 @@ func TestSwapEnd_ToNil(t *testing.T) { | |||
| 245 | 235 | mockOldChild := mocks.NewMockWidget(t) | |
| 246 | 236 | ||
| 247 | 237 | mockFactory.EXPECT().NewPaned(mock.Anything).Return(mockPaned).Once() | |
| 248 | - | mockPaned.EXPECT().SetResizeStartChild(true).Once() | |
| 249 | - | mockPaned.EXPECT().SetResizeEndChild(true).Once() | |
| 250 | - | mockPaned.EXPECT().SetShrinkStartChild(false).Once() | |
| 251 | - | mockPaned.EXPECT().SetShrinkEndChild(false).Once() | |
| 238 | + | setupSplitViewMocks(mockPaned, layout.OrientationHorizontal) | |
| 252 | 239 | mockPaned.EXPECT().SetEndChild(mockOldChild).Once() | |
| 253 | - | mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once() | |
| 254 | 240 | ||
| 255 | - | sv := layout.NewSplitView(mockFactory, layout.OrientationHorizontal, nil, mockOldChild, 0.5) | |
| 241 | + | sv := layout.NewSplitView(mockFactory, zerolog.Nop(), layout.OrientationHorizontal, nil, mockOldChild, 0.5) | |
| 256 | 242 | ||
| 257 | 243 | // Expect removal (SetEndChild(nil) called for clearing old) | |
| 258 | 244 | mockPaned.EXPECT().SetEndChild(nil).Once() | |
| @@ -270,13 +256,9 @@ func TestWidget_ReturnsPanedWidget(t *testing.T) { | |||
| 270 | 256 | mockPaned := mocks.NewMockPanedWidget(t) | |
| 271 | 257 | ||
| 272 | 258 | mockFactory.EXPECT().NewPaned(mock.Anything).Return(mockPaned).Once() | |
| 273 | - | mockPaned.EXPECT().SetResizeStartChild(true).Once() | |
| 274 | - | mockPaned.EXPECT().SetResizeEndChild(true).Once() | |
| 275 | - | mockPaned.EXPECT().SetShrinkStartChild(false).Once() | |
| 276 | - | mockPaned.EXPECT().SetShrinkEndChild(false).Once() | |
| 277 | - | mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once() | |
| 259 | + | setupSplitViewMocks(mockPaned, layout.OrientationHorizontal) | |
| 278 | 260 | ||
| 279 | - | sv := layout.NewSplitView(mockFactory, layout.OrientationHorizontal, nil, nil, 0.5) | |
| 261 | + | sv := layout.NewSplitView(mockFactory, zerolog.Nop(), layout.OrientationHorizontal, nil, nil, 0.5) | |
| 280 | 262 | ||
| 281 | 263 | // Act | |
| 282 | 264 | widget := sv.Widget() | |
| @@ -291,13 +273,9 @@ func TestSetPosition_DelegatesToPaned(t *testing.T) { | |||
| 291 | 273 | mockPaned := mocks.NewMockPanedWidget(t) | |
| 292 | 274 | ||
| 293 | 275 | mockFactory.EXPECT().NewPaned(mock.Anything).Return(mockPaned).Once() | |
| 294 | - | mockPaned.EXPECT().SetResizeStartChild(true).Once() | |
| 295 | - | mockPaned.EXPECT().SetResizeEndChild(true).Once() | |
| 296 | - | mockPaned.EXPECT().SetShrinkStartChild(false).Once() | |
| 297 | - | mockPaned.EXPECT().SetShrinkEndChild(false).Once() | |
| 298 | - | mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once() | |
| 276 | + | setupSplitViewMocks(mockPaned, layout.OrientationHorizontal) | |
| 299 | 277 | ||
| 300 | - | sv := layout.NewSplitView(mockFactory, layout.OrientationHorizontal, nil, nil, 0.5) | |
| 278 | + | sv := layout.NewSplitView(mockFactory, zerolog.Nop(), layout.OrientationHorizontal, nil, nil, 0.5) | |
| 301 | 279 | ||
| 302 | 280 | mockPaned.EXPECT().SetPosition(400).Once() | |
| 303 | 281 | ||
| @@ -313,13 +291,9 @@ func TestGetPosition_DelegatesToPaned(t *testing.T) { | |||
| 313 | 291 | mockPaned := mocks.NewMockPanedWidget(t) | |
| 314 | 292 | ||
| 315 | 293 | mockFactory.EXPECT().NewPaned(mock.Anything).Return(mockPaned).Once() | |
| 316 | - | mockPaned.EXPECT().SetResizeStartChild(true).Once() | |
| 317 | - | mockPaned.EXPECT().SetResizeEndChild(true).Once() | |
| 318 | - | mockPaned.EXPECT().SetShrinkStartChild(false).Once() | |
| 319 | - | mockPaned.EXPECT().SetShrinkEndChild(false).Once() | |
| 320 | - | mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once() | |
| 294 | + | setupSplitViewMocks(mockPaned, layout.OrientationHorizontal) | |
| 321 | 295 | ||
| 322 | - | sv := layout.NewSplitView(mockFactory, layout.OrientationHorizontal, nil, nil, 0.5) | |
| 296 | + | sv := layout.NewSplitView(mockFactory, zerolog.Nop(), layout.OrientationHorizontal, nil, nil, 0.5) | |
| 323 | 297 | ||
| 324 | 298 | mockPaned.EXPECT().GetPosition().Return(400).Once() | |
| 325 | 299 | ||
| @@ -336,13 +310,9 @@ func TestSetWideHandle_DelegatesToPaned(t *testing.T) { | |||
| 336 | 310 | mockPaned := mocks.NewMockPanedWidget(t) | |
| 337 | 311 | ||
| 338 | 312 | mockFactory.EXPECT().NewPaned(mock.Anything).Return(mockPaned).Once() | |
| 339 | - | mockPaned.EXPECT().SetResizeStartChild(true).Once() | |
| 340 | - | mockPaned.EXPECT().SetResizeEndChild(true).Once() | |
| 341 | - | mockPaned.EXPECT().SetShrinkStartChild(false).Once() | |
| 342 | - | mockPaned.EXPECT().SetShrinkEndChild(false).Once() | |
| 343 | - | mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once() | |
| 313 | + | setupSplitViewMocks(mockPaned, layout.OrientationHorizontal) | |
| 344 | 314 | ||
| 345 | - | sv := layout.NewSplitView(mockFactory, layout.OrientationHorizontal, nil, nil, 0.5) | |
| 315 | + | sv := layout.NewSplitView(mockFactory, zerolog.Nop(), layout.OrientationHorizontal, nil, nil, 0.5) | |
| 346 | 316 | ||
| 347 | 317 | mockPaned.EXPECT().SetWideHandle(true).Once() | |
| 348 | 318 | ||
| @@ -370,14 +340,10 @@ func TestNewSplitView_InitialRatio_Clamped(t *testing.T) { | |||
| 370 | 340 | mockPaned := mocks.NewMockPanedWidget(t) | |
| 371 | 341 | ||
| 372 | 342 | mockFactory.EXPECT().NewPaned(mock.Anything).Return(mockPaned).Once() | |
| 373 | - | mockPaned.EXPECT().SetResizeStartChild(true).Once() | |
| 374 | - | mockPaned.EXPECT().SetResizeEndChild(true).Once() | |
| 375 | - | mockPaned.EXPECT().SetShrinkStartChild(false).Once() | |
| 376 | - | mockPaned.EXPECT().SetShrinkEndChild(false).Once() | |
| 377 | - | mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once() | |
| 343 | + | setupSplitViewMocks(mockPaned, layout.OrientationHorizontal) | |
| 378 | 344 | ||
| 379 | 345 | // Act | |
| 380 | - | sv := layout.NewSplitView(mockFactory, layout.OrientationHorizontal, nil, nil, tt.input) | |
| 346 | + | sv := layout.NewSplitView(mockFactory, zerolog.Nop(), layout.OrientationHorizontal, nil, nil, tt.input) | |
| 381 | 347 | ||
| 382 | 348 | // Assert | |
| 383 | 349 | assert.Equal(t, tt.expected, sv.GetRatio()) | |
| @@ -3,6 +3,7 @@ package layout_test | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "testing" | 4 | "testing" | ||
| 5 | 5 | ||||
| 6 | + | "github.com/rs/zerolog" | |||
| 6 | "github.com/stretchr/testify/assert" | 7 | "github.com/stretchr/testify/assert" | ||
| 7 | "github.com/stretchr/testify/mock" | 8 | "github.com/stretchr/testify/mock" | ||
| 8 | "github.com/stretchr/testify/require" | 9 | "github.com/stretchr/testify/require" | ||
| @@ -11,6 +12,27 @@ import ( | |||||
| 11 | "github.com/bnema/dumber/internal/ui/layout/mocks" | 12 | "github.com/bnema/dumber/internal/ui/layout/mocks" | ||
| 12 | ) | 13 | ) | ||
| 13 | 14 | ||||
| 15 | + | // setupSplitViewMocks configures the common mock expectations for NewSplitView. | |||
| 16 | + | // Since the mock paned returns 0 for GetAllocatedWidth/Height, ApplyRatio will | |||
| 17 | + | // return false and trigger the ConnectMap and AddTickCallback registrations. | |||
| 18 | + | func setupSplitViewMocks(mockPaned *mocks.MockPanedWidget, orientation layout.Orientation) { | |||
| 19 | + | mockPaned.EXPECT().SetResizeStartChild(true).Once() | |||
| 20 | + | mockPaned.EXPECT().SetResizeEndChild(true).Once() | |||
| 21 | + | // Note: SetShrinkStartChild/SetShrinkEndChild are NOT called - allowing shrink | |||
| 22 | + | // enables GTK to respect the 50/50 ratio even if children have larger natural sizes | |||
| 23 | + | ||||
| 24 | + | // ApplyRatio is called immediately, which calls GetAllocatedWidth/Height | |||
| 25 | + | if orientation == layout.OrientationHorizontal { | |||
| 26 | + | mockPaned.EXPECT().GetAllocatedWidth().Return(0).Once() | |||
| 27 | + | } else { | |||
| 28 | + | mockPaned.EXPECT().GetAllocatedHeight().Return(0).Once() | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | // Since allocation is 0, ApplyRatio returns false and callbacks are registered | |||
| 32 | + | mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once() | |||
| 33 | + | mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once() | |||
| 34 | + | } | |||
| 35 | + | ||||
| 14 | func TestNewSplitView_Horizontal(t *testing.T) { | 36 | func TestNewSplitView_Horizontal(t *testing.T) { | ||
| 15 | // Arrange | 37 | // Arrange | ||
| 16 | mockFactory := mocks.NewMockWidgetFactory(t) | 38 | mockFactory := mocks.NewMockWidgetFactory(t) | ||
| @@ -19,16 +41,12 @@ func TestNewSplitView_Horizontal(t *testing.T) { | |||||
| 19 | mockEndChild := mocks.NewMockWidget(t) | 41 | mockEndChild := mocks.NewMockWidget(t) | ||
| 20 | 42 | ||||
| 21 | mockFactory.EXPECT().NewPaned(layout.OrientationHorizontal).Return(mockPaned).Once() | 43 | mockFactory.EXPECT().NewPaned(layout.OrientationHorizontal).Return(mockPaned).Once() | ||
| 22 | - | mockPaned.EXPECT().SetResizeStartChild(true).Once() | 44 | + | setupSplitViewMocks(mockPaned, layout.OrientationHorizontal) |
| 23 | - | mockPaned.EXPECT().SetResizeEndChild(true).Once() | |||
| 24 | - | mockPaned.EXPECT().SetShrinkStartChild(false).Once() | |||
| 25 | - | mockPaned.EXPECT().SetShrinkEndChild(false).Once() | |||
| 26 | mockPaned.EXPECT().SetStartChild(mockStartChild).Once() | 45 | mockPaned.EXPECT().SetStartChild(mockStartChild).Once() | ||
| 27 | mockPaned.EXPECT().SetEndChild(mockEndChild).Once() | 46 | mockPaned.EXPECT().SetEndChild(mockEndChild).Once() | ||
| 28 | - | mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once() | |||
| 29 | 47 | ||||
| 30 | // Act | 48 | // Act | ||
| 31 | - | sv := layout.NewSplitView(mockFactory, layout.OrientationHorizontal, mockStartChild, mockEndChild, 0.5) | 49 | + | sv := layout.NewSplitView(mockFactory, zerolog.Nop(), layout.OrientationHorizontal, mockStartChild, mockEndChild, 0.5) |
| 32 | 50 | ||||
| 33 | // Assert | 51 | // Assert | ||
| 34 | require.NotNil(t, sv) | 52 | require.NotNil(t, sv) | ||
| @@ -46,16 +64,12 @@ func TestNewSplitView_Vertical(t *testing.T) { | |||||
| 46 | mockEndChild := mocks.NewMockWidget(t) | 64 | mockEndChild := mocks.NewMockWidget(t) | ||
| 47 | 65 | ||||
| 48 | mockFactory.EXPECT().NewPaned(layout.OrientationVertical).Return(mockPaned).Once() | 66 | mockFactory.EXPECT().NewPaned(layout.OrientationVertical).Return(mockPaned).Once() | ||
| 49 | - | mockPaned.EXPECT().SetResizeStartChild(true).Once() | 67 | + | setupSplitViewMocks(mockPaned, layout.OrientationVertical) |
| 50 | - | mockPaned.EXPECT().SetResizeEndChild(true).Once() | |||
| 51 | - | mockPaned.EXPECT().SetShrinkStartChild(false).Once() | |||
| 52 | - | mockPaned.EXPECT().SetShrinkEndChild(false).Once() | |||
| 53 | mockPaned.EXPECT().SetStartChild(mockStartChild).Once() | 68 | mockPaned.EXPECT().SetStartChild(mockStartChild).Once() | ||
| 54 | mockPaned.EXPECT().SetEndChild(mockEndChild).Once() | 69 | mockPaned.EXPECT().SetEndChild(mockEndChild).Once() | ||
| 55 | - | mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once() | |||
| 56 | 70 | ||||
| 57 | // Act | 71 | // Act | ||
| 58 | - | sv := layout.NewSplitView(mockFactory, layout.OrientationVertical, mockStartChild, mockEndChild, 0.5) | 72 | + | sv := layout.NewSplitView(mockFactory, zerolog.Nop(), layout.OrientationVertical, mockStartChild, mockEndChild, 0.5) |
| 59 | 73 | ||||
| 60 | // Assert | 74 | // Assert | ||
| 61 | require.NotNil(t, sv) | 75 | require.NotNil(t, sv) | ||
| @@ -68,15 +82,11 @@ func TestNewSplitView_NilChildren(t *testing.T) { | |||||
| 68 | mockPaned := mocks.NewMockPanedWidget(t) | 82 | mockPaned := mocks.NewMockPanedWidget(t) | ||
| 69 | 83 | ||||
| 70 | mockFactory.EXPECT().NewPaned(layout.OrientationHorizontal).Return(mockPaned).Once() | 84 | mockFactory.EXPECT().NewPaned(layout.OrientationHorizontal).Return(mockPaned).Once() | ||
| 71 | - | mockPaned.EXPECT().SetResizeStartChild(true).Once() | 85 | + | setupSplitViewMocks(mockPaned, layout.OrientationHorizontal) |
| 72 | - | mockPaned.EXPECT().SetResizeEndChild(true).Once() | |||
| 73 | - | mockPaned.EXPECT().SetShrinkStartChild(false).Once() | |||
| 74 | - | mockPaned.EXPECT().SetShrinkEndChild(false).Once() | |||
| 75 | - | mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once() | |||
| 76 | // SetStartChild and SetEndChild should NOT be called when children are nil | 86 | // SetStartChild and SetEndChild should NOT be called when children are nil | ||
| 77 | 87 | ||||
| 78 | // Act | 88 | // Act | ||
| 79 | - | sv := layout.NewSplitView(mockFactory, layout.OrientationHorizontal, nil, nil, 0.5) | 89 | + | sv := layout.NewSplitView(mockFactory, zerolog.Nop(), layout.OrientationHorizontal, nil, nil, 0.5) |
| 80 | 90 | ||||
| 81 | // Assert | 91 | // Assert | ||
| 82 | require.NotNil(t, sv) | 92 | require.NotNil(t, sv) | ||
| @@ -104,13 +114,9 @@ func TestSetRatio_ValidRange(t *testing.T) { | |||||
| 104 | mockPaned := mocks.NewMockPanedWidget(t) | 114 | mockPaned := mocks.NewMockPanedWidget(t) | ||
| 105 | 115 | ||||
| 106 | mockFactory.EXPECT().NewPaned(mock.Anything).Return(mockPaned).Once() | 116 | mockFactory.EXPECT().NewPaned(mock.Anything).Return(mockPaned).Once() | ||
| 107 | - | mockPaned.EXPECT().SetResizeStartChild(true).Once() | 117 | + | setupSplitViewMocks(mockPaned, layout.OrientationHorizontal) |
| 108 | - | mockPaned.EXPECT().SetResizeEndChild(true).Once() | |||
| 109 | - | mockPaned.EXPECT().SetShrinkStartChild(false).Once() | |||
| 110 | - | mockPaned.EXPECT().SetShrinkEndChild(false).Once() | |||
| 111 | - | mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once() | |||
| 112 | 118 | ||||
| 113 | - | sv := layout.NewSplitView(mockFactory, layout.OrientationHorizontal, nil, nil, 0.5) | 119 | + | sv := layout.NewSplitView(mockFactory, zerolog.Nop(), layout.OrientationHorizontal, nil, nil, 0.5) |
| 114 | 120 | ||||
| 115 | // Act | 121 | // Act | ||
| 116 | sv.SetRatio(tt.input) | 122 | sv.SetRatio(tt.input) | ||
| @@ -140,13 +146,9 @@ func TestSetRatio_OutOfRange_Clamped(t *testing.T) { | |||||
| 140 | mockPaned := mocks.NewMockPanedWidget(t) | 146 | mockPaned := mocks.NewMockPanedWidget(t) | ||
| 141 | 147 | ||||
| 142 | mockFactory.EXPECT().NewPaned(mock.Anything).Return(mockPaned).Once() | 148 | mockFactory.EXPECT().NewPaned(mock.Anything).Return(mockPaned).Once() | ||
| 143 | - | mockPaned.EXPECT().SetResizeStartChild(true).Once() | 149 | + | setupSplitViewMocks(mockPaned, layout.OrientationHorizontal) |
| 144 | - | mockPaned.EXPECT().SetResizeEndChild(true).Once() | |||
| 145 | - | mockPaned.EXPECT().SetShrinkStartChild(false).Once() | |||
| 146 | - | mockPaned.EXPECT().SetShrinkEndChild(false).Once() | |||
| 147 | - | mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once() | |||
| 148 | 150 | ||||
| 149 | - | sv := layout.NewSplitView(mockFactory, layout.OrientationHorizontal, nil, nil, 0.5) | 151 | + | sv := layout.NewSplitView(mockFactory, zerolog.Nop(), layout.OrientationHorizontal, nil, nil, 0.5) |
| 150 | 152 | ||||
| 151 | // Act | 153 | // Act | ||
| 152 | sv.SetRatio(tt.input) | 154 | sv.SetRatio(tt.input) | ||
| @@ -165,14 +167,10 @@ func TestSwapStart_ReplacesChild(t *testing.T) { | |||||
| 165 | mockNewChild := mocks.NewMockWidget(t) | 167 | mockNewChild := mocks.NewMockWidget(t) | ||
| 166 | 168 | ||||
| 167 | mockFactory.EXPECT().NewPaned(mock.Anything).Return(mockPaned).Once() | 169 | mockFactory.EXPECT().NewPaned(mock.Anything).Return(mockPaned).Once() | ||
| 168 | - | mockPaned.EXPECT().SetResizeStartChild(true).Once() | 170 | + | setupSplitViewMocks(mockPaned, layout.OrientationHorizontal) |
| 169 | - | mockPaned.EXPECT().SetResizeEndChild(true).Once() | |||
| 170 | - | mockPaned.EXPECT().SetShrinkStartChild(false).Once() | |||
| 171 | - | mockPaned.EXPECT().SetShrinkEndChild(false).Once() | |||
| 172 | mockPaned.EXPECT().SetStartChild(mockOldChild).Once() | 171 | mockPaned.EXPECT().SetStartChild(mockOldChild).Once() | ||
| 173 | - | mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once() | |||
| 174 | 172 | ||||
| 175 | - | sv := layout.NewSplitView(mockFactory, layout.OrientationHorizontal, mockOldChild, nil, 0.5) | 173 | + | sv := layout.NewSplitView(mockFactory, zerolog.Nop(), layout.OrientationHorizontal, mockOldChild, nil, 0.5) |
| 176 | 174 | ||||
| 177 | // Expect swap operations | 175 | // Expect swap operations | ||
| 178 | mockPaned.EXPECT().SetStartChild(nil).Once() // Remove old | 176 | mockPaned.EXPECT().SetStartChild(nil).Once() // Remove old | ||
| @@ -193,14 +191,10 @@ func TestSwapEnd_ReplacesChild(t *testing.T) { | |||||
| 193 | mockNewChild := mocks.NewMockWidget(t) | 191 | mockNewChild := mocks.NewMockWidget(t) | ||
| 194 | 192 | ||||
| 195 | mockFactory.EXPECT().NewPaned(mock.Anything).Return(mockPaned).Once() | 193 | mockFactory.EXPECT().NewPaned(mock.Anything).Return(mockPaned).Once() | ||
| 196 | - | mockPaned.EXPECT().SetResizeStartChild(true).Once() | 194 | + | setupSplitViewMocks(mockPaned, layout.OrientationHorizontal) |
| 197 | - | mockPaned.EXPECT().SetResizeEndChild(true).Once() | |||
| 198 | - | mockPaned.EXPECT().SetShrinkStartChild(false).Once() | |||
| 199 | - | mockPaned.EXPECT().SetShrinkEndChild(false).Once() | |||
| 200 | mockPaned.EXPECT().SetEndChild(mockOldChild).Once() | 195 | mockPaned.EXPECT().SetEndChild(mockOldChild).Once() | ||
| 201 | - | mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once() | |||
| 202 | 196 | ||||
| 203 | - | sv := layout.NewSplitView(mockFactory, layout.OrientationHorizontal, nil, mockOldChild, 0.5) | 197 | + | sv := layout.NewSplitView(mockFactory, zerolog.Nop(), layout.OrientationHorizontal, nil, mockOldChild, 0.5) |
| 204 | 198 | ||||
| 205 | // Expect swap operations | 199 | // Expect swap operations | ||
| 206 | mockPaned.EXPECT().SetEndChild(nil).Once() // Remove old | 200 | mockPaned.EXPECT().SetEndChild(nil).Once() // Remove old | ||
| @@ -220,13 +214,9 @@ func TestSwapStart_FromNil(t *testing.T) { | |||||
| 220 | mockNewChild := mocks.NewMockWidget(t) | 214 | mockNewChild := mocks.NewMockWidget(t) | ||
| 221 | 215 | ||||
| 222 | mockFactory.EXPECT().NewPaned(mock.Anything).Return(mockPaned).Once() | 216 | mockFactory.EXPECT().NewPaned(mock.Anything).Return(mockPaned).Once() | ||
| 223 | - | mockPaned.EXPECT().SetResizeStartChild(true).Once() | 217 | + | setupSplitViewMocks(mockPaned, layout.OrientationHorizontal) |
| 224 | - | mockPaned.EXPECT().SetResizeEndChild(true).Once() | |||
| 225 | - | mockPaned.EXPECT().SetShrinkStartChild(false).Once() | |||
| 226 | - | mockPaned.EXPECT().SetShrinkEndChild(false).Once() | |||
| 227 | - | mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once() | |||
| 228 | 218 | ||||
| 229 | - | sv := layout.NewSplitView(mockFactory, layout.OrientationHorizontal, nil, nil, 0.5) | 219 | + | sv := layout.NewSplitView(mockFactory, zerolog.Nop(), layout.OrientationHorizontal, nil, nil, 0.5) |
| 230 | 220 | ||||
| 231 | // Expect only adding new child (no removal since old was nil) | 221 | // Expect only adding new child (no removal since old was nil) | ||
| 232 | mockPaned.EXPECT().SetStartChild(mockNewChild).Once() | 222 | mockPaned.EXPECT().SetStartChild(mockNewChild).Once() | ||
| @@ -245,14 +235,10 @@ func TestSwapEnd_ToNil(t *testing.T) { | |||||
| 245 | mockOldChild := mocks.NewMockWidget(t) | 235 | mockOldChild := mocks.NewMockWidget(t) | ||
| 246 | 236 | ||||
| 247 | mockFactory.EXPECT().NewPaned(mock.Anything).Return(mockPaned).Once() | 237 | mockFactory.EXPECT().NewPaned(mock.Anything).Return(mockPaned).Once() | ||
| 248 | - | mockPaned.EXPECT().SetResizeStartChild(true).Once() | 238 | + | setupSplitViewMocks(mockPaned, layout.OrientationHorizontal) |
| 249 | - | mockPaned.EXPECT().SetResizeEndChild(true).Once() | |||
| 250 | - | mockPaned.EXPECT().SetShrinkStartChild(false).Once() | |||
| 251 | - | mockPaned.EXPECT().SetShrinkEndChild(false).Once() | |||
| 252 | mockPaned.EXPECT().SetEndChild(mockOldChild).Once() | 239 | mockPaned.EXPECT().SetEndChild(mockOldChild).Once() | ||
| 253 | - | mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once() | |||
| 254 | 240 | ||||
| 255 | - | sv := layout.NewSplitView(mockFactory, layout.OrientationHorizontal, nil, mockOldChild, 0.5) | 241 | + | sv := layout.NewSplitView(mockFactory, zerolog.Nop(), layout.OrientationHorizontal, nil, mockOldChild, 0.5) |
| 256 | 242 | ||||
| 257 | // Expect removal (SetEndChild(nil) called for clearing old) | 243 | // Expect removal (SetEndChild(nil) called for clearing old) | ||
| 258 | mockPaned.EXPECT().SetEndChild(nil).Once() | 244 | mockPaned.EXPECT().SetEndChild(nil).Once() | ||
| @@ -270,13 +256,9 @@ func TestWidget_ReturnsPanedWidget(t *testing.T) { | |||||
| 270 | mockPaned := mocks.NewMockPanedWidget(t) | 256 | mockPaned := mocks.NewMockPanedWidget(t) | ||
| 271 | 257 | ||||
| 272 | mockFactory.EXPECT().NewPaned(mock.Anything).Return(mockPaned).Once() | 258 | mockFactory.EXPECT().NewPaned(mock.Anything).Return(mockPaned).Once() | ||
| 273 | - | mockPaned.EXPECT().SetResizeStartChild(true).Once() | 259 | + | setupSplitViewMocks(mockPaned, layout.OrientationHorizontal) |
| 274 | - | mockPaned.EXPECT().SetResizeEndChild(true).Once() | |||
| 275 | - | mockPaned.EXPECT().SetShrinkStartChild(false).Once() | |||
| 276 | - | mockPaned.EXPECT().SetShrinkEndChild(false).Once() | |||
| 277 | - | mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once() | |||
| 278 | 260 | ||||
| 279 | - | sv := layout.NewSplitView(mockFactory, layout.OrientationHorizontal, nil, nil, 0.5) | 261 | + | sv := layout.NewSplitView(mockFactory, zerolog.Nop(), layout.OrientationHorizontal, nil, nil, 0.5) |
| 280 | 262 | ||||
| 281 | // Act | 263 | // Act | ||
| 282 | widget := sv.Widget() | 264 | widget := sv.Widget() | ||
| @@ -291,13 +273,9 @@ func TestSetPosition_DelegatesToPaned(t *testing.T) { | |||||
| 291 | mockPaned := mocks.NewMockPanedWidget(t) | 273 | mockPaned := mocks.NewMockPanedWidget(t) | ||
| 292 | 274 | ||||
| 293 | mockFactory.EXPECT().NewPaned(mock.Anything).Return(mockPaned).Once() | 275 | mockFactory.EXPECT().NewPaned(mock.Anything).Return(mockPaned).Once() | ||
| 294 | - | mockPaned.EXPECT().SetResizeStartChild(true).Once() | 276 | + | setupSplitViewMocks(mockPaned, layout.OrientationHorizontal) |
| 295 | - | mockPaned.EXPECT().SetResizeEndChild(true).Once() | |||
| 296 | - | mockPaned.EXPECT().SetShrinkStartChild(false).Once() | |||
| 297 | - | mockPaned.EXPECT().SetShrinkEndChild(false).Once() | |||
| 298 | - | mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once() | |||
| 299 | 277 | ||||
| 300 | - | sv := layout.NewSplitView(mockFactory, layout.OrientationHorizontal, nil, nil, 0.5) | 278 | + | sv := layout.NewSplitView(mockFactory, zerolog.Nop(), layout.OrientationHorizontal, nil, nil, 0.5) |
| 301 | 279 | ||||
| 302 | mockPaned.EXPECT().SetPosition(400).Once() | 280 | mockPaned.EXPECT().SetPosition(400).Once() | ||
| 303 | 281 | ||||
| @@ -313,13 +291,9 @@ func TestGetPosition_DelegatesToPaned(t *testing.T) { | |||||
| 313 | mockPaned := mocks.NewMockPanedWidget(t) | 291 | mockPaned := mocks.NewMockPanedWidget(t) | ||
| 314 | 292 | ||||
| 315 | mockFactory.EXPECT().NewPaned(mock.Anything).Return(mockPaned).Once() | 293 | mockFactory.EXPECT().NewPaned(mock.Anything).Return(mockPaned).Once() | ||
| 316 | - | mockPaned.EXPECT().SetResizeStartChild(true).Once() | 294 | + | setupSplitViewMocks(mockPaned, layout.OrientationHorizontal) |
| 317 | - | mockPaned.EXPECT().SetResizeEndChild(true).Once() | |||
| 318 | - | mockPaned.EXPECT().SetShrinkStartChild(false).Once() | |||
| 319 | - | mockPaned.EXPECT().SetShrinkEndChild(false).Once() | |||
| 320 | - | mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once() | |||
| 321 | 295 | ||||
| 322 | - | sv := layout.NewSplitView(mockFactory, layout.OrientationHorizontal, nil, nil, 0.5) | 296 | + | sv := layout.NewSplitView(mockFactory, zerolog.Nop(), layout.OrientationHorizontal, nil, nil, 0.5) |
| 323 | 297 | ||||
| 324 | mockPaned.EXPECT().GetPosition().Return(400).Once() | 298 | mockPaned.EXPECT().GetPosition().Return(400).Once() | ||
| 325 | 299 | ||||
| @@ -336,13 +310,9 @@ func TestSetWideHandle_DelegatesToPaned(t *testing.T) { | |||||
| 336 | mockPaned := mocks.NewMockPanedWidget(t) | 310 | mockPaned := mocks.NewMockPanedWidget(t) | ||
| 337 | 311 | ||||
| 338 | mockFactory.EXPECT().NewPaned(mock.Anything).Return(mockPaned).Once() | 312 | mockFactory.EXPECT().NewPaned(mock.Anything).Return(mockPaned).Once() | ||
| 339 | - | mockPaned.EXPECT().SetResizeStartChild(true).Once() | 313 | + | setupSplitViewMocks(mockPaned, layout.OrientationHorizontal) |
| 340 | - | mockPaned.EXPECT().SetResizeEndChild(true).Once() | |||
| 341 | - | mockPaned.EXPECT().SetShrinkStartChild(false).Once() | |||
| 342 | - | mockPaned.EXPECT().SetShrinkEndChild(false).Once() | |||
| 343 | - | mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once() | |||
| 344 | 314 | ||||
| 345 | - | sv := layout.NewSplitView(mockFactory, layout.OrientationHorizontal, nil, nil, 0.5) | 315 | + | sv := layout.NewSplitView(mockFactory, zerolog.Nop(), layout.OrientationHorizontal, nil, nil, 0.5) |
| 346 | 316 | ||||
| 347 | mockPaned.EXPECT().SetWideHandle(true).Once() | 317 | mockPaned.EXPECT().SetWideHandle(true).Once() | ||
| 348 | 318 | ||||
| @@ -370,14 +340,10 @@ func TestNewSplitView_InitialRatio_Clamped(t *testing.T) { | |||||
| 370 | mockPaned := mocks.NewMockPanedWidget(t) | 340 | mockPaned := mocks.NewMockPanedWidget(t) | ||
| 371 | 341 | ||||
| 372 | mockFactory.EXPECT().NewPaned(mock.Anything).Return(mockPaned).Once() | 342 | mockFactory.EXPECT().NewPaned(mock.Anything).Return(mockPaned).Once() | ||
| 373 | - | mockPaned.EXPECT().SetResizeStartChild(true).Once() | 343 | + | setupSplitViewMocks(mockPaned, layout.OrientationHorizontal) |
| 374 | - | mockPaned.EXPECT().SetResizeEndChild(true).Once() | |||
| 375 | - | mockPaned.EXPECT().SetShrinkStartChild(false).Once() | |||
| 376 | - | mockPaned.EXPECT().SetShrinkEndChild(false).Once() | |||
| 377 | - | mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once() | |||
| 378 | 344 | ||||
| 379 | // Act | 345 | // Act | ||
| 380 | - | sv := layout.NewSplitView(mockFactory, layout.OrientationHorizontal, nil, nil, tt.input) | 346 | + | sv := layout.NewSplitView(mockFactory, zerolog.Nop(), layout.OrientationHorizontal, nil, nil, tt.input) |
| 381 | 347 | ||||
| 382 | // Assert | 348 | // Assert | ||
| 383 | assert.Equal(t, tt.expected, sv.GetRatio()) | 349 | assert.Equal(t, tt.expected, sv.GetRatio()) | ||
M internal/ui/layout/tree.go +5 -2
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | "sync" | |
| 6 | 6 | ||
| 7 | 7 | "github.com/bnema/dumber/internal/domain/entity" | |
| 8 | + | "github.com/rs/zerolog" | |
| 8 | 9 | ) | |
| 9 | 10 | ||
| 10 | 11 | // ErrNilRoot is returned when attempting to build from a nil root node. | |
| @@ -26,15 +27,17 @@ type PaneViewFactory interface { | |||
| 26 | 27 | type TreeRenderer struct { | |
| 27 | 28 | factory WidgetFactory | |
| 28 | 29 | paneViewFactory PaneViewFactory | |
| 30 | + | logger zerolog.Logger | |
| 29 | 31 | nodeToWidget map[string]Widget | |
| 30 | 32 | mu sync.RWMutex | |
| 31 | 33 | } | |
| 32 | 34 | ||
| 33 | 35 | // NewTreeRenderer creates a new tree renderer. | |
| 34 | - | func NewTreeRenderer(factory WidgetFactory, paneViewFactory PaneViewFactory) *TreeRenderer { | |
| 36 | + | func NewTreeRenderer(factory WidgetFactory, paneViewFactory PaneViewFactory, logger zerolog.Logger) *TreeRenderer { | |
| 35 | 37 | return &TreeRenderer{ | |
| 36 | 38 | factory: factory, | |
| 37 | 39 | paneViewFactory: paneViewFactory, | |
| 40 | + | logger: logger.With().Str("component", "tree-renderer").Logger(), | |
| 38 | 41 | nodeToWidget: make(map[string]Widget), | |
| 39 | 42 | } | |
| 40 | 43 | } | |
| @@ -119,7 +122,7 @@ func (tr *TreeRenderer) renderSplit(node *entity.PaneNode) (Widget, error) { | |||
| 119 | 122 | } | |
| 120 | 123 | ||
| 121 | 124 | // Create split view | |
| 122 | - | splitView := NewSplitView(tr.factory, orientation, leftWidget, rightWidget, node.SplitRatio) | |
| 125 | + | splitView := NewSplitView(tr.factory, tr.logger, orientation, leftWidget, rightWidget, node.SplitRatio) | |
| 123 | 126 | ||
| 124 | 127 | return splitView.Widget(), nil | |
| 125 | 128 | } | |
| @@ -5,6 +5,7 @@ import ( | |||||
| 5 | "sync" | 5 | "sync" | ||
| 6 | 6 | ||||
| 7 | "github.com/bnema/dumber/internal/domain/entity" | 7 | "github.com/bnema/dumber/internal/domain/entity" | ||
| 8 | + | "github.com/rs/zerolog" | |||
| 8 | ) | 9 | ) | ||
| 9 | 10 | ||||
| 10 | // ErrNilRoot is returned when attempting to build from a nil root node. | 11 | // ErrNilRoot is returned when attempting to build from a nil root node. | ||
| @@ -26,15 +27,17 @@ type PaneViewFactory interface { | |||||
| 26 | type TreeRenderer struct { | 27 | type TreeRenderer struct { | ||
| 27 | factory WidgetFactory | 28 | factory WidgetFactory | ||
| 28 | paneViewFactory PaneViewFactory | 29 | paneViewFactory PaneViewFactory | ||
| 30 | + | logger zerolog.Logger | |||
| 29 | nodeToWidget map[string]Widget | 31 | nodeToWidget map[string]Widget | ||
| 30 | mu sync.RWMutex | 32 | mu sync.RWMutex | ||
| 31 | } | 33 | } | ||
| 32 | 34 | ||||
| 33 | // NewTreeRenderer creates a new tree renderer. | 35 | // NewTreeRenderer creates a new tree renderer. | ||
| 34 | - | func NewTreeRenderer(factory WidgetFactory, paneViewFactory PaneViewFactory) *TreeRenderer { | 36 | + | func NewTreeRenderer(factory WidgetFactory, paneViewFactory PaneViewFactory, logger zerolog.Logger) *TreeRenderer { |
| 35 | return &TreeRenderer{ | 37 | return &TreeRenderer{ | ||
| 36 | factory: factory, | 38 | factory: factory, | ||
| 37 | paneViewFactory: paneViewFactory, | 39 | paneViewFactory: paneViewFactory, | ||
| 40 | + | logger: logger.With().Str("component", "tree-renderer").Logger(), | |||
| 38 | nodeToWidget: make(map[string]Widget), | 41 | nodeToWidget: make(map[string]Widget), | ||
| 39 | } | 42 | } | ||
| 40 | } | 43 | } | ||
| @@ -119,7 +122,7 @@ func (tr *TreeRenderer) renderSplit(node *entity.PaneNode) (Widget, error) { | |||||
| 119 | } | 122 | } | ||
| 120 | 123 | ||||
| 121 | // Create split view | 124 | // Create split view | ||
| 122 | - | splitView := NewSplitView(tr.factory, orientation, leftWidget, rightWidget, node.SplitRatio) | 125 | + | splitView := NewSplitView(tr.factory, tr.logger, orientation, leftWidget, rightWidget, node.SplitRatio) |
| 123 | 126 | ||||
| 124 | return splitView.Widget(), nil | 127 | return splitView.Widget(), nil | ||
| 125 | } | 128 | } | ||
M internal/ui/layout/tree_test.go +30 -17
| @@ -3,6 +3,7 @@ package layout_test | |||
| 3 | 3 | import ( | |
| 4 | 4 | "testing" | |
| 5 | 5 | ||
| 6 | + | "github.com/rs/zerolog" | |
| 6 | 7 | "github.com/stretchr/testify/assert" | |
| 7 | 8 | "github.com/stretchr/testify/mock" | |
| 8 | 9 | "github.com/stretchr/testify/require" | |
| @@ -18,7 +19,7 @@ func TestNewTreeRenderer_CreatesRenderer(t *testing.T) { | |||
| 18 | 19 | mockPaneViewFactory := mocks.NewMockPaneViewFactory(t) | |
| 19 | 20 | ||
| 20 | 21 | // Act | |
| 21 | - | renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory) | |
| 22 | + | renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory, zerolog.Nop()) | |
| 22 | 23 | ||
| 23 | 24 | // Assert | |
| 24 | 25 | require.NotNil(t, renderer) | |
| @@ -30,7 +31,7 @@ func TestBuild_NilRoot_ReturnsError(t *testing.T) { | |||
| 30 | 31 | mockFactory := mocks.NewMockWidgetFactory(t) | |
| 31 | 32 | mockPaneViewFactory := mocks.NewMockPaneViewFactory(t) | |
| 32 | 33 | ||
| 33 | - | renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory) | |
| 34 | + | renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory, zerolog.Nop()) | |
| 34 | 35 | ||
| 35 | 36 | // Act | |
| 36 | 37 | widget, err := renderer.Build(nil) | |
| @@ -54,7 +55,7 @@ func TestBuild_SingleLeafPane(t *testing.T) { | |||
| 54 | 55 | ||
| 55 | 56 | mockPaneViewFactory.EXPECT().CreatePaneView(node).Return(mockWidget).Once() | |
| 56 | 57 | ||
| 57 | - | renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory) | |
| 58 | + | renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory, zerolog.Nop()) | |
| 58 | 59 | ||
| 59 | 60 | // Act | |
| 60 | 61 | widget, err := renderer.Build(node) | |
| @@ -100,9 +101,11 @@ func TestBuild_HorizontalSplit(t *testing.T) { | |||
| 100 | 101 | mockPaned.EXPECT().SetShrinkEndChild(false).Once() | |
| 101 | 102 | mockPaned.EXPECT().SetStartChild(mockLeftWidget).Once() | |
| 102 | 103 | mockPaned.EXPECT().SetEndChild(mockRightWidget).Once() | |
| 104 | + | mockPaned.EXPECT().GetAllocatedWidth().Return(0).Once() | |
| 103 | 105 | mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once() | |
| 106 | + | mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once() | |
| 104 | 107 | ||
| 105 | - | renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory) | |
| 108 | + | renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory, zerolog.Nop()) | |
| 106 | 109 | ||
| 107 | 110 | // Act | |
| 108 | 111 | widget, err := renderer.Build(splitNode) | |
| @@ -145,9 +148,11 @@ func TestBuild_VerticalSplit(t *testing.T) { | |||
| 145 | 148 | mockPaned.EXPECT().SetShrinkEndChild(false).Once() | |
| 146 | 149 | mockPaned.EXPECT().SetStartChild(mockTopWidget).Once() | |
| 147 | 150 | mockPaned.EXPECT().SetEndChild(mockBottomWidget).Once() | |
| 151 | + | mockPaned.EXPECT().GetAllocatedHeight().Return(0).Once() | |
| 148 | 152 | mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once() | |
| 153 | + | mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once() | |
| 149 | 154 | ||
| 150 | - | renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory) | |
| 155 | + | renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory, zerolog.Nop()) | |
| 151 | 156 | ||
| 152 | 157 | // Act | |
| 153 | 158 | widget, err := renderer.Build(splitNode) | |
| @@ -209,7 +214,9 @@ func TestBuild_NestedSplits(t *testing.T) { | |||
| 209 | 214 | mockInnerPaned.EXPECT().SetShrinkEndChild(false).Once() | |
| 210 | 215 | mockInnerPaned.EXPECT().SetStartChild(mockWidget1).Once() | |
| 211 | 216 | mockInnerPaned.EXPECT().SetEndChild(mockWidget2).Once() | |
| 217 | + | mockInnerPaned.EXPECT().GetAllocatedHeight().Return(0).Once() | |
| 212 | 218 | mockInnerPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once() | |
| 219 | + | mockInnerPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once() | |
| 213 | 220 | ||
| 214 | 221 | // Outer split (horizontal) gets inner paned and pane3 | |
| 215 | 222 | mockFactory.EXPECT().NewPaned(layout.OrientationHorizontal).Return(mockOuterPaned).Once() | |
| @@ -219,9 +226,11 @@ func TestBuild_NestedSplits(t *testing.T) { | |||
| 219 | 226 | mockOuterPaned.EXPECT().SetShrinkEndChild(false).Once() | |
| 220 | 227 | mockOuterPaned.EXPECT().SetStartChild(mockInnerPaned).Once() | |
| 221 | 228 | mockOuterPaned.EXPECT().SetEndChild(mockWidget3).Once() | |
| 229 | + | mockOuterPaned.EXPECT().GetAllocatedWidth().Return(0).Once() | |
| 222 | 230 | mockOuterPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once() | |
| 231 | + | mockOuterPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once() | |
| 223 | 232 | ||
| 224 | - | renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory) | |
| 233 | + | renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory, zerolog.Nop()) | |
| 225 | 234 | ||
| 226 | 235 | // Act | |
| 227 | 236 | widget, err := renderer.Build(outerSplit) | |
| @@ -248,7 +257,7 @@ func TestLookup_ExistingNode(t *testing.T) { | |||
| 248 | 257 | ||
| 249 | 258 | mockPaneViewFactory.EXPECT().CreatePaneView(node).Return(mockWidget).Once() | |
| 250 | 259 | ||
| 251 | - | renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory) | |
| 260 | + | renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory, zerolog.Nop()) | |
| 252 | 261 | _, _ = renderer.Build(node) | |
| 253 | 262 | ||
| 254 | 263 | // Act | |
| @@ -269,7 +278,7 @@ func TestLookup_NonExistentNode(t *testing.T) { | |||
| 269 | 278 | ||
| 270 | 279 | mockPaneViewFactory.EXPECT().CreatePaneView(node).Return(mockWidget).Once() | |
| 271 | 280 | ||
| 272 | - | renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory) | |
| 281 | + | renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory, zerolog.Nop()) | |
| 273 | 282 | _, _ = renderer.Build(node) | |
| 274 | 283 | ||
| 275 | 284 | // Act | |
| @@ -290,7 +299,7 @@ func TestLookupNode_Existing(t *testing.T) { | |||
| 290 | 299 | ||
| 291 | 300 | mockPaneViewFactory.EXPECT().CreatePaneView(node).Return(mockWidget).Once() | |
| 292 | 301 | ||
| 293 | - | renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory) | |
| 302 | + | renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory, zerolog.Nop()) | |
| 294 | 303 | _, _ = renderer.Build(node) | |
| 295 | 304 | ||
| 296 | 305 | // Act | |
| @@ -306,7 +315,7 @@ func TestLookupNode_NonExistent(t *testing.T) { | |||
| 306 | 315 | mockFactory := mocks.NewMockWidgetFactory(t) | |
| 307 | 316 | mockPaneViewFactory := mocks.NewMockPaneViewFactory(t) | |
| 308 | 317 | ||
| 309 | - | renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory) | |
| 318 | + | renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory, zerolog.Nop()) | |
| 310 | 319 | ||
| 311 | 320 | // Act | |
| 312 | 321 | widget, found := renderer.LookupNode("non-existent") | |
| @@ -327,7 +336,7 @@ func TestClear_RemovesAllMappings(t *testing.T) { | |||
| 327 | 336 | ||
| 328 | 337 | mockPaneViewFactory.EXPECT().CreatePaneView(node).Return(mockWidget).Once() | |
| 329 | 338 | ||
| 330 | - | renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory) | |
| 339 | + | renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory, zerolog.Nop()) | |
| 331 | 340 | _, _ = renderer.Build(node) | |
| 332 | 341 | ||
| 333 | 342 | require.Equal(t, 1, renderer.NodeCount()) | |
| @@ -371,9 +380,11 @@ func TestGetNodeIDs_ReturnsAllIDs(t *testing.T) { | |||
| 371 | 380 | mockPaned.EXPECT().SetShrinkEndChild(false).Once() | |
| 372 | 381 | mockPaned.EXPECT().SetStartChild(mockWidget1).Once() | |
| 373 | 382 | mockPaned.EXPECT().SetEndChild(mockWidget2).Once() | |
| 383 | + | mockPaned.EXPECT().GetAllocatedWidth().Return(0).Once() | |
| 374 | 384 | mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once() | |
| 385 | + | mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once() | |
| 375 | 386 | ||
| 376 | - | renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory) | |
| 387 | + | renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory, zerolog.Nop()) | |
| 377 | 388 | _, _ = renderer.Build(splitNode) | |
| 378 | 389 | ||
| 379 | 390 | // Act | |
| @@ -402,7 +413,7 @@ func TestBuild_ClearsPreviousMappings(t *testing.T) { | |||
| 402 | 413 | mockPaneViewFactory.EXPECT().CreatePaneView(node1).Return(mockWidget1).Once() | |
| 403 | 414 | mockPaneViewFactory.EXPECT().CreatePaneView(node2).Return(mockWidget2).Once() | |
| 404 | 415 | ||
| 405 | - | renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory) | |
| 416 | + | renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory, zerolog.Nop()) | |
| 406 | 417 | ||
| 407 | 418 | // First build | |
| 408 | 419 | _, _ = renderer.Build(node1) | |
| @@ -425,7 +436,7 @@ func TestBuild_NilPaneViewFactory_ReturnsNilForLeaves(t *testing.T) { | |||
| 425 | 436 | pane := entity.NewPane(entity.PaneID("pane-1")) | |
| 426 | 437 | node := &entity.PaneNode{ID: "node-1", Pane: pane} | |
| 427 | 438 | ||
| 428 | - | renderer := layout.NewTreeRenderer(mockFactory, nil) | |
| 439 | + | renderer := layout.NewTreeRenderer(mockFactory, nil, zerolog.Nop()) | |
| 429 | 440 | ||
| 430 | 441 | // Act | |
| 431 | 442 | widget, err := renderer.Build(node) | |
| @@ -446,7 +457,7 @@ func TestBuild_EmptyNodeID_NotStored(t *testing.T) { | |||
| 446 | 457 | ||
| 447 | 458 | mockPaneViewFactory.EXPECT().CreatePaneView(node).Return(mockWidget).Once() | |
| 448 | 459 | ||
| 449 | - | renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory) | |
| 460 | + | renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory, zerolog.Nop()) | |
| 450 | 461 | ||
| 451 | 462 | // Act | |
| 452 | 463 | widget, err := renderer.Build(node) | |
| @@ -488,9 +499,11 @@ func TestUpdateSplitRatio_ExistingNode(t *testing.T) { | |||
| 488 | 499 | mockPaned.EXPECT().SetShrinkEndChild(false).Once() | |
| 489 | 500 | mockPaned.EXPECT().SetStartChild(mockWidget1).Once() | |
| 490 | 501 | mockPaned.EXPECT().SetEndChild(mockWidget2).Once() | |
| 502 | + | mockPaned.EXPECT().GetAllocatedWidth().Return(0).Once() | |
| 491 | 503 | mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once() | |
| 504 | + | mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once() | |
| 492 | 505 | ||
| 493 | - | renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory) | |
| 506 | + | renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory, zerolog.Nop()) | |
| 494 | 507 | _, _ = renderer.Build(splitNode) | |
| 495 | 508 | ||
| 496 | 509 | // Act | |
| @@ -505,7 +518,7 @@ func TestUpdateSplitRatio_NonExistentNode(t *testing.T) { | |||
| 505 | 518 | mockFactory := mocks.NewMockWidgetFactory(t) | |
| 506 | 519 | mockPaneViewFactory := mocks.NewMockPaneViewFactory(t) | |
| 507 | 520 | ||
| 508 | - | renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory) | |
| 521 | + | renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory, zerolog.Nop()) | |
| 509 | 522 | ||
| 510 | 523 | // Act | |
| 511 | 524 | err := renderer.UpdateSplitRatio("non-existent", 0.7) | |
| @@ -3,6 +3,7 @@ package layout_test | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "testing" | 4 | "testing" | ||
| 5 | 5 | ||||
| 6 | + | "github.com/rs/zerolog" | |||
| 6 | "github.com/stretchr/testify/assert" | 7 | "github.com/stretchr/testify/assert" | ||
| 7 | "github.com/stretchr/testify/mock" | 8 | "github.com/stretchr/testify/mock" | ||
| 8 | "github.com/stretchr/testify/require" | 9 | "github.com/stretchr/testify/require" | ||
| @@ -18,7 +19,7 @@ func TestNewTreeRenderer_CreatesRenderer(t *testing.T) { | |||||
| 18 | mockPaneViewFactory := mocks.NewMockPaneViewFactory(t) | 19 | mockPaneViewFactory := mocks.NewMockPaneViewFactory(t) | ||
| 19 | 20 | ||||
| 20 | // Act | 21 | // Act | ||
| 21 | - | renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory) | 22 | + | renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory, zerolog.Nop()) |
| 22 | 23 | ||||
| 23 | // Assert | 24 | // Assert | ||
| 24 | require.NotNil(t, renderer) | 25 | require.NotNil(t, renderer) | ||
| @@ -30,7 +31,7 @@ func TestBuild_NilRoot_ReturnsError(t *testing.T) { | |||||
| 30 | mockFactory := mocks.NewMockWidgetFactory(t) | 31 | mockFactory := mocks.NewMockWidgetFactory(t) | ||
| 31 | mockPaneViewFactory := mocks.NewMockPaneViewFactory(t) | 32 | mockPaneViewFactory := mocks.NewMockPaneViewFactory(t) | ||
| 32 | 33 | ||||
| 33 | - | renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory) | 34 | + | renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory, zerolog.Nop()) |
| 34 | 35 | ||||
| 35 | // Act | 36 | // Act | ||
| 36 | widget, err := renderer.Build(nil) | 37 | widget, err := renderer.Build(nil) | ||
| @@ -54,7 +55,7 @@ func TestBuild_SingleLeafPane(t *testing.T) { | |||||
| 54 | 55 | ||||
| 55 | mockPaneViewFactory.EXPECT().CreatePaneView(node).Return(mockWidget).Once() | 56 | mockPaneViewFactory.EXPECT().CreatePaneView(node).Return(mockWidget).Once() | ||
| 56 | 57 | ||||
| 57 | - | renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory) | 58 | + | renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory, zerolog.Nop()) |
| 58 | 59 | ||||
| 59 | // Act | 60 | // Act | ||
| 60 | widget, err := renderer.Build(node) | 61 | widget, err := renderer.Build(node) | ||
| @@ -100,9 +101,11 @@ func TestBuild_HorizontalSplit(t *testing.T) { | |||||
| 100 | mockPaned.EXPECT().SetShrinkEndChild(false).Once() | 101 | mockPaned.EXPECT().SetShrinkEndChild(false).Once() | ||
| 101 | mockPaned.EXPECT().SetStartChild(mockLeftWidget).Once() | 102 | mockPaned.EXPECT().SetStartChild(mockLeftWidget).Once() | ||
| 102 | mockPaned.EXPECT().SetEndChild(mockRightWidget).Once() | 103 | mockPaned.EXPECT().SetEndChild(mockRightWidget).Once() | ||
| 104 | + | mockPaned.EXPECT().GetAllocatedWidth().Return(0).Once() | |||
| 103 | mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once() | 105 | mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once() | ||
| 106 | + | mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once() | |||
| 104 | 107 | ||||
| 105 | - | renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory) | 108 | + | renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory, zerolog.Nop()) |
| 106 | 109 | ||||
| 107 | // Act | 110 | // Act | ||
| 108 | widget, err := renderer.Build(splitNode) | 111 | widget, err := renderer.Build(splitNode) | ||
| @@ -145,9 +148,11 @@ func TestBuild_VerticalSplit(t *testing.T) { | |||||
| 145 | mockPaned.EXPECT().SetShrinkEndChild(false).Once() | 148 | mockPaned.EXPECT().SetShrinkEndChild(false).Once() | ||
| 146 | mockPaned.EXPECT().SetStartChild(mockTopWidget).Once() | 149 | mockPaned.EXPECT().SetStartChild(mockTopWidget).Once() | ||
| 147 | mockPaned.EXPECT().SetEndChild(mockBottomWidget).Once() | 150 | mockPaned.EXPECT().SetEndChild(mockBottomWidget).Once() | ||
| 151 | + | mockPaned.EXPECT().GetAllocatedHeight().Return(0).Once() | |||
| 148 | mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once() | 152 | mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once() | ||
| 153 | + | mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once() | |||
| 149 | 154 | ||||
| 150 | - | renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory) | 155 | + | renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory, zerolog.Nop()) |
| 151 | 156 | ||||
| 152 | // Act | 157 | // Act | ||
| 153 | widget, err := renderer.Build(splitNode) | 158 | widget, err := renderer.Build(splitNode) | ||
| @@ -209,7 +214,9 @@ func TestBuild_NestedSplits(t *testing.T) { | |||||
| 209 | mockInnerPaned.EXPECT().SetShrinkEndChild(false).Once() | 214 | mockInnerPaned.EXPECT().SetShrinkEndChild(false).Once() | ||
| 210 | mockInnerPaned.EXPECT().SetStartChild(mockWidget1).Once() | 215 | mockInnerPaned.EXPECT().SetStartChild(mockWidget1).Once() | ||
| 211 | mockInnerPaned.EXPECT().SetEndChild(mockWidget2).Once() | 216 | mockInnerPaned.EXPECT().SetEndChild(mockWidget2).Once() | ||
| 217 | + | mockInnerPaned.EXPECT().GetAllocatedHeight().Return(0).Once() | |||
| 212 | mockInnerPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once() | 218 | mockInnerPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once() | ||
| 219 | + | mockInnerPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once() | |||
| 213 | 220 | ||||
| 214 | // Outer split (horizontal) gets inner paned and pane3 | 221 | // Outer split (horizontal) gets inner paned and pane3 | ||
| 215 | mockFactory.EXPECT().NewPaned(layout.OrientationHorizontal).Return(mockOuterPaned).Once() | 222 | mockFactory.EXPECT().NewPaned(layout.OrientationHorizontal).Return(mockOuterPaned).Once() | ||
| @@ -219,9 +226,11 @@ func TestBuild_NestedSplits(t *testing.T) { | |||||
| 219 | mockOuterPaned.EXPECT().SetShrinkEndChild(false).Once() | 226 | mockOuterPaned.EXPECT().SetShrinkEndChild(false).Once() | ||
| 220 | mockOuterPaned.EXPECT().SetStartChild(mockInnerPaned).Once() | 227 | mockOuterPaned.EXPECT().SetStartChild(mockInnerPaned).Once() | ||
| 221 | mockOuterPaned.EXPECT().SetEndChild(mockWidget3).Once() | 228 | mockOuterPaned.EXPECT().SetEndChild(mockWidget3).Once() | ||
| 229 | + | mockOuterPaned.EXPECT().GetAllocatedWidth().Return(0).Once() | |||
| 222 | mockOuterPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once() | 230 | mockOuterPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once() | ||
| 231 | + | mockOuterPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once() | |||
| 223 | 232 | ||||
| 224 | - | renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory) | 233 | + | renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory, zerolog.Nop()) |
| 225 | 234 | ||||
| 226 | // Act | 235 | // Act | ||
| 227 | widget, err := renderer.Build(outerSplit) | 236 | widget, err := renderer.Build(outerSplit) | ||
| @@ -248,7 +257,7 @@ func TestLookup_ExistingNode(t *testing.T) { | |||||
| 248 | 257 | ||||
| 249 | mockPaneViewFactory.EXPECT().CreatePaneView(node).Return(mockWidget).Once() | 258 | mockPaneViewFactory.EXPECT().CreatePaneView(node).Return(mockWidget).Once() | ||
| 250 | 259 | ||||
| 251 | - | renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory) | 260 | + | renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory, zerolog.Nop()) |
| 252 | _, _ = renderer.Build(node) | 261 | _, _ = renderer.Build(node) | ||
| 253 | 262 | ||||
| 254 | // Act | 263 | // Act | ||
| @@ -269,7 +278,7 @@ func TestLookup_NonExistentNode(t *testing.T) { | |||||
| 269 | 278 | ||||
| 270 | mockPaneViewFactory.EXPECT().CreatePaneView(node).Return(mockWidget).Once() | 279 | mockPaneViewFactory.EXPECT().CreatePaneView(node).Return(mockWidget).Once() | ||
| 271 | 280 | ||||
| 272 | - | renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory) | 281 | + | renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory, zerolog.Nop()) |
| 273 | _, _ = renderer.Build(node) | 282 | _, _ = renderer.Build(node) | ||
| 274 | 283 | ||||
| 275 | // Act | 284 | // Act | ||
| @@ -290,7 +299,7 @@ func TestLookupNode_Existing(t *testing.T) { | |||||
| 290 | 299 | ||||
| 291 | mockPaneViewFactory.EXPECT().CreatePaneView(node).Return(mockWidget).Once() | 300 | mockPaneViewFactory.EXPECT().CreatePaneView(node).Return(mockWidget).Once() | ||
| 292 | 301 | ||||
| 293 | - | renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory) | 302 | + | renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory, zerolog.Nop()) |
| 294 | _, _ = renderer.Build(node) | 303 | _, _ = renderer.Build(node) | ||
| 295 | 304 | ||||
| 296 | // Act | 305 | // Act | ||
| @@ -306,7 +315,7 @@ func TestLookupNode_NonExistent(t *testing.T) { | |||||
| 306 | mockFactory := mocks.NewMockWidgetFactory(t) | 315 | mockFactory := mocks.NewMockWidgetFactory(t) | ||
| 307 | mockPaneViewFactory := mocks.NewMockPaneViewFactory(t) | 316 | mockPaneViewFactory := mocks.NewMockPaneViewFactory(t) | ||
| 308 | 317 | ||||
| 309 | - | renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory) | 318 | + | renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory, zerolog.Nop()) |
| 310 | 319 | ||||
| 311 | // Act | 320 | // Act | ||
| 312 | widget, found := renderer.LookupNode("non-existent") | 321 | widget, found := renderer.LookupNode("non-existent") | ||
| @@ -327,7 +336,7 @@ func TestClear_RemovesAllMappings(t *testing.T) { | |||||
| 327 | 336 | ||||
| 328 | mockPaneViewFactory.EXPECT().CreatePaneView(node).Return(mockWidget).Once() | 337 | mockPaneViewFactory.EXPECT().CreatePaneView(node).Return(mockWidget).Once() | ||
| 329 | 338 | ||||
| 330 | - | renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory) | 339 | + | renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory, zerolog.Nop()) |
| 331 | _, _ = renderer.Build(node) | 340 | _, _ = renderer.Build(node) | ||
| 332 | 341 | ||||
| 333 | require.Equal(t, 1, renderer.NodeCount()) | 342 | require.Equal(t, 1, renderer.NodeCount()) | ||
| @@ -371,9 +380,11 @@ func TestGetNodeIDs_ReturnsAllIDs(t *testing.T) { | |||||
| 371 | mockPaned.EXPECT().SetShrinkEndChild(false).Once() | 380 | mockPaned.EXPECT().SetShrinkEndChild(false).Once() | ||
| 372 | mockPaned.EXPECT().SetStartChild(mockWidget1).Once() | 381 | mockPaned.EXPECT().SetStartChild(mockWidget1).Once() | ||
| 373 | mockPaned.EXPECT().SetEndChild(mockWidget2).Once() | 382 | mockPaned.EXPECT().SetEndChild(mockWidget2).Once() | ||
| 383 | + | mockPaned.EXPECT().GetAllocatedWidth().Return(0).Once() | |||
| 374 | mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once() | 384 | mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once() | ||
| 385 | + | mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once() | |||
| 375 | 386 | ||||
| 376 | - | renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory) | 387 | + | renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory, zerolog.Nop()) |
| 377 | _, _ = renderer.Build(splitNode) | 388 | _, _ = renderer.Build(splitNode) | ||
| 378 | 389 | ||||
| 379 | // Act | 390 | // Act | ||
| @@ -402,7 +413,7 @@ func TestBuild_ClearsPreviousMappings(t *testing.T) { | |||||
| 402 | mockPaneViewFactory.EXPECT().CreatePaneView(node1).Return(mockWidget1).Once() | 413 | mockPaneViewFactory.EXPECT().CreatePaneView(node1).Return(mockWidget1).Once() | ||
| 403 | mockPaneViewFactory.EXPECT().CreatePaneView(node2).Return(mockWidget2).Once() | 414 | mockPaneViewFactory.EXPECT().CreatePaneView(node2).Return(mockWidget2).Once() | ||
| 404 | 415 | ||||
| 405 | - | renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory) | 416 | + | renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory, zerolog.Nop()) |
| 406 | 417 | ||||
| 407 | // First build | 418 | // First build | ||
| 408 | _, _ = renderer.Build(node1) | 419 | _, _ = renderer.Build(node1) | ||
| @@ -425,7 +436,7 @@ func TestBuild_NilPaneViewFactory_ReturnsNilForLeaves(t *testing.T) { | |||||
| 425 | pane := entity.NewPane(entity.PaneID("pane-1")) | 436 | pane := entity.NewPane(entity.PaneID("pane-1")) | ||
| 426 | node := &entity.PaneNode{ID: "node-1", Pane: pane} | 437 | node := &entity.PaneNode{ID: "node-1", Pane: pane} | ||
| 427 | 438 | ||||
| 428 | - | renderer := layout.NewTreeRenderer(mockFactory, nil) | 439 | + | renderer := layout.NewTreeRenderer(mockFactory, nil, zerolog.Nop()) |
| 429 | 440 | ||||
| 430 | // Act | 441 | // Act | ||
| 431 | widget, err := renderer.Build(node) | 442 | widget, err := renderer.Build(node) | ||
| @@ -446,7 +457,7 @@ func TestBuild_EmptyNodeID_NotStored(t *testing.T) { | |||||
| 446 | 457 | ||||
| 447 | mockPaneViewFactory.EXPECT().CreatePaneView(node).Return(mockWidget).Once() | 458 | mockPaneViewFactory.EXPECT().CreatePaneView(node).Return(mockWidget).Once() | ||
| 448 | 459 | ||||
| 449 | - | renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory) | 460 | + | renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory, zerolog.Nop()) |
| 450 | 461 | ||||
| 451 | // Act | 462 | // Act | ||
| 452 | widget, err := renderer.Build(node) | 463 | widget, err := renderer.Build(node) | ||
| @@ -488,9 +499,11 @@ func TestUpdateSplitRatio_ExistingNode(t *testing.T) { | |||||
| 488 | mockPaned.EXPECT().SetShrinkEndChild(false).Once() | 499 | mockPaned.EXPECT().SetShrinkEndChild(false).Once() | ||
| 489 | mockPaned.EXPECT().SetStartChild(mockWidget1).Once() | 500 | mockPaned.EXPECT().SetStartChild(mockWidget1).Once() | ||
| 490 | mockPaned.EXPECT().SetEndChild(mockWidget2).Once() | 501 | mockPaned.EXPECT().SetEndChild(mockWidget2).Once() | ||
| 502 | + | mockPaned.EXPECT().GetAllocatedWidth().Return(0).Once() | |||
| 491 | mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once() | 503 | mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once() | ||
| 504 | + | mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once() | |||
| 492 | 505 | ||||
| 493 | - | renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory) | 506 | + | renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory, zerolog.Nop()) |
| 494 | _, _ = renderer.Build(splitNode) | 507 | _, _ = renderer.Build(splitNode) | ||
| 495 | 508 | ||||
| 496 | // Act | 509 | // Act | ||
| @@ -505,7 +518,7 @@ func TestUpdateSplitRatio_NonExistentNode(t *testing.T) { | |||||
| 505 | mockFactory := mocks.NewMockWidgetFactory(t) | 518 | mockFactory := mocks.NewMockWidgetFactory(t) | ||
| 506 | mockPaneViewFactory := mocks.NewMockPaneViewFactory(t) | 519 | mockPaneViewFactory := mocks.NewMockPaneViewFactory(t) | ||
| 507 | 520 | ||||
| 508 | - | renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory) | 521 | + | renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory, zerolog.Nop()) |
| 509 | 522 | ||||
| 510 | // Act | 523 | // Act | ||
| 511 | err := renderer.UpdateSplitRatio("non-existent", 0.7) | 524 | err := renderer.UpdateSplitRatio("non-existent", 0.7) | ||
M internal/ui/layout/widgets.go +7 -0
| @@ -30,6 +30,9 @@ type Widget interface { | |||
| 30 | 30 | SetCanFocus(canFocus bool) | |
| 31 | 31 | SetFocusOnClick(focusOnClick bool) | |
| 32 | 32 | ||
| 33 | + | // Pointer events | |
| 34 | + | SetCanTarget(canTarget bool) // If false, widget won't receive pointer events | |
| 35 | + | ||
| 33 | 36 | // Layout | |
| 34 | 37 | SetHexpand(expand bool) | |
| 35 | 38 | SetVexpand(expand bool) | |
| @@ -82,6 +85,10 @@ type PanedWidget interface { | |||
| 82 | 85 | // Signals | |
| 83 | 86 | ConnectMap(callback func()) uint32 | |
| 84 | 87 | ||
| 88 | + | // Tick callback for frame-based updates (returns signal ID) | |
| 89 | + | // Callback returns true to continue, false to stop | |
| 90 | + | AddTickCallback(callback func() bool) uint | |
| 91 | + | ||
| 85 | 92 | // Size information (for ratio calculation) | |
| 86 | 93 | GetAllocatedWidth() int | |
| 87 | 94 | GetAllocatedHeight() int | |
| @@ -30,6 +30,9 @@ type Widget interface { | |||||
| 30 | SetCanFocus(canFocus bool) | 30 | SetCanFocus(canFocus bool) | ||
| 31 | SetFocusOnClick(focusOnClick bool) | 31 | SetFocusOnClick(focusOnClick bool) | ||
| 32 | 32 | ||||
| 33 | + | // Pointer events | |||
| 34 | + | SetCanTarget(canTarget bool) // If false, widget won't receive pointer events | |||
| 35 | + | ||||
| 33 | // Layout | 36 | // Layout | ||
| 34 | SetHexpand(expand bool) | 37 | SetHexpand(expand bool) | ||
| 35 | SetVexpand(expand bool) | 38 | SetVexpand(expand bool) | ||
| @@ -82,6 +85,10 @@ type PanedWidget interface { | |||||
| 82 | // Signals | 85 | // Signals | ||
| 83 | ConnectMap(callback func()) uint32 | 86 | ConnectMap(callback func()) uint32 | ||
| 84 | 87 | ||||
| 88 | + | // Tick callback for frame-based updates (returns signal ID) | |||
| 89 | + | // Callback returns true to continue, false to stop | |||
| 90 | + | AddTickCallback(callback func() bool) uint | |||
| 91 | + | ||||
| 85 | // Size information (for ratio calculation) | 92 | // Size information (for ratio calculation) | ||
| 86 | GetAllocatedWidth() int | 93 | GetAllocatedWidth() int | ||
| 87 | GetAllocatedHeight() int | 94 | GetAllocatedHeight() int | ||