detached 5884f49

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 @@
11 package webkit
22
33 import (
4+ "context"
5+ "errors"
46 "fmt"
57 "sync"
68 "sync/atomic"
79
10+ "github.com/bnema/dumber/internal/logging"
811 "github.com/bnema/puregotk-webkit/webkit"
12+ "github.com/jwijenbergh/puregotk/v4/gio"
913 "github.com/jwijenbergh/puregotk/v4/gtk"
1014 "github.com/rs/zerolog"
1115 )
@@ -72,6 +76,7 @@ func LookupWebView(id WebViewID) *WebView {
7276 type WebView struct {
7377 id WebViewID
7478 inner *webkit.WebView
79+ ucm *webkit.UserContentManager
7580
7681 // State (protected by mutex)
7782 destroyed atomic.Bool
@@ -96,6 +101,11 @@ type WebView struct {
96101
97102 logger zerolog.Logger
98103 mu sync.RWMutex
104+
105+ frontendAttached atomic.Bool
106+
107+ // asyncCallbacks keeps references to async JS callbacks to prevent GC
108+ asyncCallbacks []interface{}
99109 }
100110
101111 // NewWebView creates a new WebView with the given context and settings.
@@ -111,6 +121,7 @@ func NewWebView(wkCtx *WebKitContext, settings *SettingsManager, logger zerolog.
111121
112122 wv := &WebView{
113123 inner: inner,
124+ ucm: inner.GetUserContentManager(),
114125 logger: logger.With().Str("component", "webview").Logger(),
115126 signalIDs: make([]uint32, 0, 4),
116127 }
@@ -148,6 +159,7 @@ func NewWebViewWithRelated(parent *WebView, settings *SettingsManager, logger ze
148159
149160 wv := &WebView{
150161 inner: inner,
162+ ucm: inner.GetUserContentManager(),
151163 logger: logger.With().Str("component", "webview-popup").Logger(),
152164 signalIDs: make([]uint32, 0, 6),
153165 }
@@ -172,6 +184,7 @@ func NewWebViewWithRelated(parent *WebView, settings *SettingsManager, logger ze
172184 func (wv *WebView) connectSignals() {
173185 // load-changed signal
174186 loadChangedCb := func(inner webkit.WebView, event webkit.LoadEvent) {
187+
175188 wv.mu.Lock()
176189 wv.uri = inner.GetUri()
177190 wv.title = inner.GetTitle()
@@ -257,23 +270,28 @@ func (wv *WebView) ID() WebViewID {
257270 return wv.id
258271 }
259272
273+// UserContentManager returns the content manager associated with this WebView.
274+func (wv *WebView) UserContentManager() *webkit.UserContentManager {
275+ return wv.ucm
276+}
277+
260278 // Widget returns the underlying webkit.WebView for GTK embedding.
261279 func (wv *WebView) Widget() *webkit.WebView {
262280 return wv.inner
263281 }
264282
265283 // LoadURI loads the given URI.
266-func (wv *WebView) LoadURI(uri string) error {
284+func (wv *WebView) LoadURI(ctx context.Context, uri string) error {
267285 if wv.destroyed.Load() {
268286 return fmt.Errorf("webview %d is destroyed", wv.id)
269287 }
270288 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")
272290 return nil
273291 }
274292
275293 // 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 {
277295 if wv.destroyed.Load() {
278296 return fmt.Errorf("webview %d is destroyed", wv.id)
279297 }
@@ -282,7 +300,7 @@ func (wv *WebView) LoadHTML(content, baseURI string) error {
282300 }
283301
284302 // Reload reloads the current page.
285-func (wv *WebView) Reload() error {
303+func (wv *WebView) Reload(ctx context.Context) error {
286304 if wv.destroyed.Load() {
287305 return fmt.Errorf("webview %d is destroyed", wv.id)
288306 }
@@ -291,7 +309,7 @@ func (wv *WebView) Reload() error {
291309 }
292310
293311 // ReloadBypassCache reloads the current page, bypassing the cache.
294-func (wv *WebView) ReloadBypassCache() error {
312+func (wv *WebView) ReloadBypassCache(ctx context.Context) error {
295313 if wv.destroyed.Load() {
296314 return fmt.Errorf("webview %d is destroyed", wv.id)
297315 }
@@ -300,7 +318,7 @@ func (wv *WebView) ReloadBypassCache() error {
300318 }
301319
302320 // Stop stops the current load operation.
303-func (wv *WebView) Stop() error {
321+func (wv *WebView) Stop(ctx context.Context) error {
304322 if wv.destroyed.Load() {
305323 return fmt.Errorf("webview %d is destroyed", wv.id)
306324 }
@@ -309,7 +327,7 @@ func (wv *WebView) Stop() error {
309327 }
310328
311329 // GoBack navigates back in history.
312-func (wv *WebView) GoBack() error {
330+func (wv *WebView) GoBack(ctx context.Context) error {
313331 if wv.destroyed.Load() {
314332 return fmt.Errorf("webview %d is destroyed", wv.id)
315333 }
@@ -321,7 +339,7 @@ func (wv *WebView) GoBack() error {
321339 }
322340
323341 // GoForward navigates forward in history.
324-func (wv *WebView) GoForward() error {
342+func (wv *WebView) GoForward(ctx context.Context) error {
325343 if wv.destroyed.Load() {
326344 return fmt.Errorf("webview %d is destroyed", wv.id)
327345 }
@@ -375,7 +393,7 @@ func (wv *WebView) EstimatedProgress() float64 {
375393 }
376394
377395 // 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 {
379397 if wv.destroyed.Load() {
380398 return fmt.Errorf("webview %d is destroyed", wv.id)
381399 }
@@ -391,6 +409,20 @@ func (wv *WebView) GetZoomLevel() float64 {
391409 return wv.inner.GetZoomLevel()
392410 }
393411
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+
394426 // IsDestroyed returns true if the WebView has been destroyed.
395427 func (wv *WebView) IsDestroyed() bool {
396428 return wv.destroyed.Load()
@@ -411,3 +443,152 @@ func (wv *WebView) Destroy() {
411443
412444 wv.logger.Debug().Uint64("id", uint64(wv.id)).Msg("webview destroyed")
413445 }
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 webkit1 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 WebViewID77 id WebViewID
74 inner *webkit.WebView78 inner *webkit.WebView
79+ ucm *webkit.UserContentManager
75 80
76 // State (protected by mutex)81 // State (protected by mutex)
77 destroyed atomic.Bool82 destroyed atomic.Bool
@@ -96,6 +101,11 @@ type WebView struct {
96 101
97 logger zerolog.Logger102 logger zerolog.Logger
98 mu sync.RWMutex103 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 signal185 // 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.id270 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.inner280 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 nil290 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()
3030 func (w *gtkWidget) HasFocus() bool { return w.inner.HasFocus() }
3131 func (w *gtkWidget) SetCanFocus(canFocus bool) { w.inner.SetCanFocus(canFocus) }
3232 func (w *gtkWidget) SetFocusOnClick(focus bool) { w.inner.SetFocusOnClick(focus) }
33+func (w *gtkWidget) SetCanTarget(canTarget bool) { w.inner.SetCanTarget(canTarget) }
3334 func (w *gtkWidget) SetHexpand(expand bool) { w.inner.SetHexpand(expand) }
3435 func (w *gtkWidget) SetVexpand(expand bool) { w.inner.SetVexpand(expand) }
3536 func (w *gtkWidget) GetHexpand() bool { return w.inner.GetHexpand() }
@@ -64,6 +65,7 @@ func (p *gtkPaned) GrabFocus() bool { return p.inner.GrabFocus() }
6465 func (p *gtkPaned) HasFocus() bool { return p.inner.HasFocus() }
6566 func (p *gtkPaned) SetCanFocus(canFocus bool) { p.inner.SetCanFocus(canFocus) }
6667 func (p *gtkPaned) SetFocusOnClick(focus bool) { p.inner.SetFocusOnClick(focus) }
68+func (p *gtkPaned) SetCanTarget(canTarget bool) { p.inner.SetCanTarget(canTarget) }
6769 func (p *gtkPaned) SetHexpand(expand bool) { p.inner.SetHexpand(expand) }
6870 func (p *gtkPaned) SetVexpand(expand bool) { p.inner.SetVexpand(expand) }
6971 func (p *gtkPaned) GetHexpand() bool { return p.inner.GetHexpand() }
@@ -137,6 +139,13 @@ func (p *gtkPaned) ConnectMap(callback func()) uint32 {
137139 return p.inner.ConnectMap(&cb)
138140 }
139141
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+
140149 func (p *gtkPaned) GetAllocatedWidth() int { return p.inner.GetAllocatedWidth() }
141150 func (p *gtkPaned) GetAllocatedHeight() int { return p.inner.GetAllocatedHeight() }
142151
@@ -153,6 +162,7 @@ func (b *gtkBox) GrabFocus() bool { return b.inner.GrabFocus() }
153162 func (b *gtkBox) HasFocus() bool { return b.inner.HasFocus() }
154163 func (b *gtkBox) SetCanFocus(canFocus bool) { b.inner.SetCanFocus(canFocus) }
155164 func (b *gtkBox) SetFocusOnClick(focus bool) { b.inner.SetFocusOnClick(focus) }
165+func (b *gtkBox) SetCanTarget(canTarget bool) { b.inner.SetCanTarget(canTarget) }
156166 func (b *gtkBox) SetHexpand(expand bool) { b.inner.SetHexpand(expand) }
157167 func (b *gtkBox) SetVexpand(expand bool) { b.inner.SetVexpand(expand) }
158168 func (b *gtkBox) GetHexpand() bool { return b.inner.GetHexpand() }
@@ -237,6 +247,7 @@ func (o *gtkOverlay) GrabFocus() bool { return o.inner.GrabFocus()
237247 func (o *gtkOverlay) HasFocus() bool { return o.inner.HasFocus() }
238248 func (o *gtkOverlay) SetCanFocus(canFocus bool) { o.inner.SetCanFocus(canFocus) }
239249 func (o *gtkOverlay) SetFocusOnClick(focus bool) { o.inner.SetFocusOnClick(focus) }
250+func (o *gtkOverlay) SetCanTarget(canTarget bool) { o.inner.SetCanTarget(canTarget) }
240251 func (o *gtkOverlay) SetHexpand(expand bool) { o.inner.SetHexpand(expand) }
241252 func (o *gtkOverlay) SetVexpand(expand bool) { o.inner.SetVexpand(expand) }
242253 func (o *gtkOverlay) GetHexpand() bool { return o.inner.GetHexpand() }
@@ -329,6 +340,7 @@ func (l *gtkLabel) GrabFocus() bool { return l.inner.GrabFocus() }
329340 func (l *gtkLabel) HasFocus() bool { return l.inner.HasFocus() }
330341 func (l *gtkLabel) SetCanFocus(canFocus bool) { l.inner.SetCanFocus(canFocus) }
331342 func (l *gtkLabel) SetFocusOnClick(focus bool) { l.inner.SetFocusOnClick(focus) }
343+func (l *gtkLabel) SetCanTarget(canTarget bool) { l.inner.SetCanTarget(canTarget) }
332344 func (l *gtkLabel) SetHexpand(expand bool) { l.inner.SetHexpand(expand) }
333345 func (l *gtkLabel) SetVexpand(expand bool) { l.inner.SetVexpand(expand) }
334346 func (l *gtkLabel) GetHexpand() bool { return l.inner.GetHexpand() }
@@ -370,6 +382,7 @@ func (btn *gtkButton) GrabFocus() bool { return btn.inner.GrabFocu
370382 func (btn *gtkButton) HasFocus() bool { return btn.inner.HasFocus() }
371383 func (btn *gtkButton) SetCanFocus(canFocus bool) { btn.inner.SetCanFocus(canFocus) }
372384 func (btn *gtkButton) SetFocusOnClick(focus bool) { btn.inner.SetFocusOnClick(focus) }
385+func (btn *gtkButton) SetCanTarget(canTarget bool) { btn.inner.SetCanTarget(canTarget) }
373386 func (btn *gtkButton) SetHexpand(expand bool) { btn.inner.SetHexpand(expand) }
374387 func (btn *gtkButton) SetVexpand(expand bool) { btn.inner.SetVexpand(expand) }
375388 func (btn *gtkButton) GetHexpand() bool { return btn.inner.GetHexpand() }
@@ -430,6 +443,7 @@ func (img *gtkImage) GrabFocus() bool { return img.inner.GrabFocus
430443 func (img *gtkImage) HasFocus() bool { return img.inner.HasFocus() }
431444 func (img *gtkImage) SetCanFocus(canFocus bool) { img.inner.SetCanFocus(canFocus) }
432445 func (img *gtkImage) SetFocusOnClick(focus bool) { img.inner.SetFocusOnClick(focus) }
446+func (img *gtkImage) SetCanTarget(canTarget bool) { img.inner.SetCanTarget(canTarget) }
433447 func (img *gtkImage) SetHexpand(expand bool) { img.inner.SetHexpand(expand) }
434448 func (img *gtkImage) SetVexpand(expand bool) { img.inner.SetVexpand(expand) }
435449 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
7777 return _c
7878 }
7979
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+
80131 // ConnectMap provides a mock function for the type MockPanedWidget
81132 func (_mock *MockPanedWidget) ConnectMap(callback func()) uint32 {
82133 ret := _mock.Called(callback)
@@ -77,6 +77,57 @@ func (_c *MockPanedWidget_AddCssClass_Call) RunAndReturn(run func(cssClass strin
77 return _c77 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 MockPanedWidget131 // 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
22
33 import (
44 "sync"
5+
6+ "github.com/rs/zerolog"
57 )
68
79 // SplitView wraps a GTK Paned widget for creating split pane layouts.
@@ -12,22 +14,26 @@ type SplitView struct {
1214 startChild Widget
1315 endChild Widget
1416 ratio float64 // 0.0-1.0, relative position of divider
17+ logger zerolog.Logger
1518
1619 mu sync.RWMutex
1720 }
1821
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+
1926 // NewSplitView creates a new split view with the given orientation and children.
2027 // 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 {
2229 paned := factory.NewPaned(orientation)
2330
2431 // 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
2534 paned.SetResizeStartChild(true)
2635 paned.SetResizeEndChild(true)
27-
28- // Prevent children from shrinking below minimum size
29- paned.SetShrinkStartChild(false)
30- paned.SetShrinkEndChild(false)
36+ paned.SetVisible(true)
3137
3238 sv := &SplitView{
3339 paned: paned,
@@ -35,21 +41,50 @@ func NewSplitView(factory WidgetFactory, orientation Orientation, startChild, en
3541 startChild: startChild,
3642 endChild: endChild,
3743 ratio: clampRatio(ratio),
44+ logger: logger.With().Str("component", "split-view").Logger(),
3845 }
3946
4047 // Set children
4148 if startChild != nil {
49+ startChild.SetVisible(true)
4250 paned.SetStartChild(startChild)
4351 }
4452 if endChild != nil {
53+ endChild.SetVisible(true)
4554 paned.SetEndChild(endChild)
4655 }
4756
57+ // Try to apply ratio immediately (in case widget is already allocated)
58+ if sv.ApplyRatio() {
59+ return sv
60+ }
61+
4862 // Apply ratio after widget is mapped (when we know the allocated size)
4963 paned.ConnectMap(func() {
5064 sv.ApplyRatio()
5165 })
5266
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+
5388 return sv
5489 }
5590
@@ -74,7 +109,8 @@ func (sv *SplitView) GetRatio() float64 {
74109
75110 // ApplyRatio converts the ratio to a pixel position and applies it.
76111 // 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 {
78114 sv.mu.RLock()
79115 ratio := sv.ratio
80116 orientation := sv.orientation
@@ -82,19 +118,33 @@ func (sv *SplitView) ApplyRatio() {
82118
83119 // Get the relevant dimension based on orientation
84120 var totalSize int
121+ var orientStr string
85122 if orientation == OrientationHorizontal {
86123 totalSize = sv.paned.GetAllocatedWidth()
124+ orientStr = "horizontal"
87125 } else {
88126 totalSize = sv.paned.GetAllocatedHeight()
127+ orientStr = "vertical"
89128 }
90129
91130 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
93136 }
94137
95138 // Calculate position from ratio
96139 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")
97146 sv.paned.SetPosition(position)
147+ return true
98148 }
99149
100150 // 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 Widget14 startChild Widget
13 endChild Widget15 endChild Widget
14 ratio float64 // 0.0-1.0, relative position of divider16 ratio float64 // 0.0-1.0, relative position of divider
17+ logger zerolog.Logger
15 18
16 mu sync.RWMutex19 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 resize31 // 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 children47 // 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 sv88 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.ratio115 ratio := sv.ratio
80 orientation := sv.orientation116 orientation := sv.orientation
@@ -82,19 +118,33 @@ func (sv *SplitView) ApplyRatio() {
82 118
83 // Get the relevant dimension based on orientation119 // Get the relevant dimension based on orientation
84 var totalSize int120 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 allocated131+ 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 ratio138 // 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
33 import (
44 "testing"
55
6+ "github.com/rs/zerolog"
67 "github.com/stretchr/testify/assert"
78 "github.com/stretchr/testify/mock"
89 "github.com/stretchr/testify/require"
@@ -11,6 +12,27 @@ import (
1112 "github.com/bnema/dumber/internal/ui/layout/mocks"
1213 )
1314
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+
1436 func TestNewSplitView_Horizontal(t *testing.T) {
1537 // Arrange
1638 mockFactory := mocks.NewMockWidgetFactory(t)
@@ -19,16 +41,12 @@ func TestNewSplitView_Horizontal(t *testing.T) {
1941 mockEndChild := mocks.NewMockWidget(t)
2042
2143 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)
2645 mockPaned.EXPECT().SetStartChild(mockStartChild).Once()
2746 mockPaned.EXPECT().SetEndChild(mockEndChild).Once()
28- mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once()
2947
3048 // 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)
3250
3351 // Assert
3452 require.NotNil(t, sv)
@@ -46,16 +64,12 @@ func TestNewSplitView_Vertical(t *testing.T) {
4664 mockEndChild := mocks.NewMockWidget(t)
4765
4866 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)
5368 mockPaned.EXPECT().SetStartChild(mockStartChild).Once()
5469 mockPaned.EXPECT().SetEndChild(mockEndChild).Once()
55- mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once()
5670
5771 // 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)
5973
6074 // Assert
6175 require.NotNil(t, sv)
@@ -68,15 +82,11 @@ func TestNewSplitView_NilChildren(t *testing.T) {
6882 mockPaned := mocks.NewMockPanedWidget(t)
6983
7084 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)
7686 // SetStartChild and SetEndChild should NOT be called when children are nil
7787
7888 // 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)
8090
8191 // Assert
8292 require.NotNil(t, sv)
@@ -104,13 +114,9 @@ func TestSetRatio_ValidRange(t *testing.T) {
104114 mockPaned := mocks.NewMockPanedWidget(t)
105115
106116 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)
112118
113- sv := layout.NewSplitView(mockFactory, layout.OrientationHorizontal, nil, nil, 0.5)
119+ sv := layout.NewSplitView(mockFactory, zerolog.Nop(), layout.OrientationHorizontal, nil, nil, 0.5)
114120
115121 // Act
116122 sv.SetRatio(tt.input)
@@ -140,13 +146,9 @@ func TestSetRatio_OutOfRange_Clamped(t *testing.T) {
140146 mockPaned := mocks.NewMockPanedWidget(t)
141147
142148 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)
148150
149- sv := layout.NewSplitView(mockFactory, layout.OrientationHorizontal, nil, nil, 0.5)
151+ sv := layout.NewSplitView(mockFactory, zerolog.Nop(), layout.OrientationHorizontal, nil, nil, 0.5)
150152
151153 // Act
152154 sv.SetRatio(tt.input)
@@ -165,14 +167,10 @@ func TestSwapStart_ReplacesChild(t *testing.T) {
165167 mockNewChild := mocks.NewMockWidget(t)
166168
167169 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)
172171 mockPaned.EXPECT().SetStartChild(mockOldChild).Once()
173- mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once()
174172
175- sv := layout.NewSplitView(mockFactory, layout.OrientationHorizontal, mockOldChild, nil, 0.5)
173+ sv := layout.NewSplitView(mockFactory, zerolog.Nop(), layout.OrientationHorizontal, mockOldChild, nil, 0.5)
176174
177175 // Expect swap operations
178176 mockPaned.EXPECT().SetStartChild(nil).Once() // Remove old
@@ -193,14 +191,10 @@ func TestSwapEnd_ReplacesChild(t *testing.T) {
193191 mockNewChild := mocks.NewMockWidget(t)
194192
195193 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)
200195 mockPaned.EXPECT().SetEndChild(mockOldChild).Once()
201- mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once()
202196
203- sv := layout.NewSplitView(mockFactory, layout.OrientationHorizontal, nil, mockOldChild, 0.5)
197+ sv := layout.NewSplitView(mockFactory, zerolog.Nop(), layout.OrientationHorizontal, nil, mockOldChild, 0.5)
204198
205199 // Expect swap operations
206200 mockPaned.EXPECT().SetEndChild(nil).Once() // Remove old
@@ -220,13 +214,9 @@ func TestSwapStart_FromNil(t *testing.T) {
220214 mockNewChild := mocks.NewMockWidget(t)
221215
222216 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)
228218
229- sv := layout.NewSplitView(mockFactory, layout.OrientationHorizontal, nil, nil, 0.5)
219+ sv := layout.NewSplitView(mockFactory, zerolog.Nop(), layout.OrientationHorizontal, nil, nil, 0.5)
230220
231221 // Expect only adding new child (no removal since old was nil)
232222 mockPaned.EXPECT().SetStartChild(mockNewChild).Once()
@@ -245,14 +235,10 @@ func TestSwapEnd_ToNil(t *testing.T) {
245235 mockOldChild := mocks.NewMockWidget(t)
246236
247237 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)
252239 mockPaned.EXPECT().SetEndChild(mockOldChild).Once()
253- mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once()
254240
255- sv := layout.NewSplitView(mockFactory, layout.OrientationHorizontal, nil, mockOldChild, 0.5)
241+ sv := layout.NewSplitView(mockFactory, zerolog.Nop(), layout.OrientationHorizontal, nil, mockOldChild, 0.5)
256242
257243 // Expect removal (SetEndChild(nil) called for clearing old)
258244 mockPaned.EXPECT().SetEndChild(nil).Once()
@@ -270,13 +256,9 @@ func TestWidget_ReturnsPanedWidget(t *testing.T) {
270256 mockPaned := mocks.NewMockPanedWidget(t)
271257
272258 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)
278260
279- sv := layout.NewSplitView(mockFactory, layout.OrientationHorizontal, nil, nil, 0.5)
261+ sv := layout.NewSplitView(mockFactory, zerolog.Nop(), layout.OrientationHorizontal, nil, nil, 0.5)
280262
281263 // Act
282264 widget := sv.Widget()
@@ -291,13 +273,9 @@ func TestSetPosition_DelegatesToPaned(t *testing.T) {
291273 mockPaned := mocks.NewMockPanedWidget(t)
292274
293275 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)
299277
300- sv := layout.NewSplitView(mockFactory, layout.OrientationHorizontal, nil, nil, 0.5)
278+ sv := layout.NewSplitView(mockFactory, zerolog.Nop(), layout.OrientationHorizontal, nil, nil, 0.5)
301279
302280 mockPaned.EXPECT().SetPosition(400).Once()
303281
@@ -313,13 +291,9 @@ func TestGetPosition_DelegatesToPaned(t *testing.T) {
313291 mockPaned := mocks.NewMockPanedWidget(t)
314292
315293 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)
321295
322- sv := layout.NewSplitView(mockFactory, layout.OrientationHorizontal, nil, nil, 0.5)
296+ sv := layout.NewSplitView(mockFactory, zerolog.Nop(), layout.OrientationHorizontal, nil, nil, 0.5)
323297
324298 mockPaned.EXPECT().GetPosition().Return(400).Once()
325299
@@ -336,13 +310,9 @@ func TestSetWideHandle_DelegatesToPaned(t *testing.T) {
336310 mockPaned := mocks.NewMockPanedWidget(t)
337311
338312 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)
344314
345- sv := layout.NewSplitView(mockFactory, layout.OrientationHorizontal, nil, nil, 0.5)
315+ sv := layout.NewSplitView(mockFactory, zerolog.Nop(), layout.OrientationHorizontal, nil, nil, 0.5)
346316
347317 mockPaned.EXPECT().SetWideHandle(true).Once()
348318
@@ -370,14 +340,10 @@ func TestNewSplitView_InitialRatio_Clamped(t *testing.T) {
370340 mockPaned := mocks.NewMockPanedWidget(t)
371341
372342 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)
378344
379345 // 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)
381347
382348 // Assert
383349 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 // Arrange37 // 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 // Act48 // 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 // Assert51 // 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 // Act71 // 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 // Assert74 // 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 nil86 // SetStartChild and SetEndChild should NOT be called when children are nil
77 87
78 // Act88 // 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 // Assert91 // 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 // Act121 // 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 // Act153 // 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 operations175 // Expect swap operations
178 mockPaned.EXPECT().SetStartChild(nil).Once() // Remove old176 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 operations199 // Expect swap operations
206 mockPaned.EXPECT().SetEndChild(nil).Once() // Remove old200 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 // Act263 // 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 // Act345 // 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 // Assert348 // 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 (
55 "sync"
66
77 "github.com/bnema/dumber/internal/domain/entity"
8+ "github.com/rs/zerolog"
89 )
910
1011 // ErrNilRoot is returned when attempting to build from a nil root node.
@@ -26,15 +27,17 @@ type PaneViewFactory interface {
2627 type TreeRenderer struct {
2728 factory WidgetFactory
2829 paneViewFactory PaneViewFactory
30+ logger zerolog.Logger
2931 nodeToWidget map[string]Widget
3032 mu sync.RWMutex
3133 }
3234
3335 // 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 {
3537 return &TreeRenderer{
3638 factory: factory,
3739 paneViewFactory: paneViewFactory,
40+ logger: logger.With().Str("component", "tree-renderer").Logger(),
3841 nodeToWidget: make(map[string]Widget),
3942 }
4043 }
@@ -119,7 +122,7 @@ func (tr *TreeRenderer) renderSplit(node *entity.PaneNode) (Widget, error) {
119122 }
120123
121124 // 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)
123126
124127 return splitView.Widget(), nil
125128 }
@@ -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 WidgetFactory28 factory WidgetFactory
28 paneViewFactory PaneViewFactory29 paneViewFactory PaneViewFactory
30+ logger zerolog.Logger
29 nodeToWidget map[string]Widget31 nodeToWidget map[string]Widget
30 mu sync.RWMutex32 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 view124 // 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(), nil127 return splitView.Widget(), nil
125 }128 }
M internal/ui/layout/tree_test.go +30 -17
@@ -3,6 +3,7 @@ package layout_test
33 import (
44 "testing"
55
6+ "github.com/rs/zerolog"
67 "github.com/stretchr/testify/assert"
78 "github.com/stretchr/testify/mock"
89 "github.com/stretchr/testify/require"
@@ -18,7 +19,7 @@ func TestNewTreeRenderer_CreatesRenderer(t *testing.T) {
1819 mockPaneViewFactory := mocks.NewMockPaneViewFactory(t)
1920
2021 // Act
21- renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory)
22+ renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory, zerolog.Nop())
2223
2324 // Assert
2425 require.NotNil(t, renderer)
@@ -30,7 +31,7 @@ func TestBuild_NilRoot_ReturnsError(t *testing.T) {
3031 mockFactory := mocks.NewMockWidgetFactory(t)
3132 mockPaneViewFactory := mocks.NewMockPaneViewFactory(t)
3233
33- renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory)
34+ renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory, zerolog.Nop())
3435
3536 // Act
3637 widget, err := renderer.Build(nil)
@@ -54,7 +55,7 @@ func TestBuild_SingleLeafPane(t *testing.T) {
5455
5556 mockPaneViewFactory.EXPECT().CreatePaneView(node).Return(mockWidget).Once()
5657
57- renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory)
58+ renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory, zerolog.Nop())
5859
5960 // Act
6061 widget, err := renderer.Build(node)
@@ -100,9 +101,11 @@ func TestBuild_HorizontalSplit(t *testing.T) {
100101 mockPaned.EXPECT().SetShrinkEndChild(false).Once()
101102 mockPaned.EXPECT().SetStartChild(mockLeftWidget).Once()
102103 mockPaned.EXPECT().SetEndChild(mockRightWidget).Once()
104+ mockPaned.EXPECT().GetAllocatedWidth().Return(0).Once()
103105 mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once()
106+ mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once()
104107
105- renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory)
108+ renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory, zerolog.Nop())
106109
107110 // Act
108111 widget, err := renderer.Build(splitNode)
@@ -145,9 +148,11 @@ func TestBuild_VerticalSplit(t *testing.T) {
145148 mockPaned.EXPECT().SetShrinkEndChild(false).Once()
146149 mockPaned.EXPECT().SetStartChild(mockTopWidget).Once()
147150 mockPaned.EXPECT().SetEndChild(mockBottomWidget).Once()
151+ mockPaned.EXPECT().GetAllocatedHeight().Return(0).Once()
148152 mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once()
153+ mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once()
149154
150- renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory)
155+ renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory, zerolog.Nop())
151156
152157 // Act
153158 widget, err := renderer.Build(splitNode)
@@ -209,7 +214,9 @@ func TestBuild_NestedSplits(t *testing.T) {
209214 mockInnerPaned.EXPECT().SetShrinkEndChild(false).Once()
210215 mockInnerPaned.EXPECT().SetStartChild(mockWidget1).Once()
211216 mockInnerPaned.EXPECT().SetEndChild(mockWidget2).Once()
217+ mockInnerPaned.EXPECT().GetAllocatedHeight().Return(0).Once()
212218 mockInnerPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once()
219+ mockInnerPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once()
213220
214221 // Outer split (horizontal) gets inner paned and pane3
215222 mockFactory.EXPECT().NewPaned(layout.OrientationHorizontal).Return(mockOuterPaned).Once()
@@ -219,9 +226,11 @@ func TestBuild_NestedSplits(t *testing.T) {
219226 mockOuterPaned.EXPECT().SetShrinkEndChild(false).Once()
220227 mockOuterPaned.EXPECT().SetStartChild(mockInnerPaned).Once()
221228 mockOuterPaned.EXPECT().SetEndChild(mockWidget3).Once()
229+ mockOuterPaned.EXPECT().GetAllocatedWidth().Return(0).Once()
222230 mockOuterPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once()
231+ mockOuterPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once()
223232
224- renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory)
233+ renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory, zerolog.Nop())
225234
226235 // Act
227236 widget, err := renderer.Build(outerSplit)
@@ -248,7 +257,7 @@ func TestLookup_ExistingNode(t *testing.T) {
248257
249258 mockPaneViewFactory.EXPECT().CreatePaneView(node).Return(mockWidget).Once()
250259
251- renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory)
260+ renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory, zerolog.Nop())
252261 _, _ = renderer.Build(node)
253262
254263 // Act
@@ -269,7 +278,7 @@ func TestLookup_NonExistentNode(t *testing.T) {
269278
270279 mockPaneViewFactory.EXPECT().CreatePaneView(node).Return(mockWidget).Once()
271280
272- renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory)
281+ renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory, zerolog.Nop())
273282 _, _ = renderer.Build(node)
274283
275284 // Act
@@ -290,7 +299,7 @@ func TestLookupNode_Existing(t *testing.T) {
290299
291300 mockPaneViewFactory.EXPECT().CreatePaneView(node).Return(mockWidget).Once()
292301
293- renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory)
302+ renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory, zerolog.Nop())
294303 _, _ = renderer.Build(node)
295304
296305 // Act
@@ -306,7 +315,7 @@ func TestLookupNode_NonExistent(t *testing.T) {
306315 mockFactory := mocks.NewMockWidgetFactory(t)
307316 mockPaneViewFactory := mocks.NewMockPaneViewFactory(t)
308317
309- renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory)
318+ renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory, zerolog.Nop())
310319
311320 // Act
312321 widget, found := renderer.LookupNode("non-existent")
@@ -327,7 +336,7 @@ func TestClear_RemovesAllMappings(t *testing.T) {
327336
328337 mockPaneViewFactory.EXPECT().CreatePaneView(node).Return(mockWidget).Once()
329338
330- renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory)
339+ renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory, zerolog.Nop())
331340 _, _ = renderer.Build(node)
332341
333342 require.Equal(t, 1, renderer.NodeCount())
@@ -371,9 +380,11 @@ func TestGetNodeIDs_ReturnsAllIDs(t *testing.T) {
371380 mockPaned.EXPECT().SetShrinkEndChild(false).Once()
372381 mockPaned.EXPECT().SetStartChild(mockWidget1).Once()
373382 mockPaned.EXPECT().SetEndChild(mockWidget2).Once()
383+ mockPaned.EXPECT().GetAllocatedWidth().Return(0).Once()
374384 mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once()
385+ mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once()
375386
376- renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory)
387+ renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory, zerolog.Nop())
377388 _, _ = renderer.Build(splitNode)
378389
379390 // Act
@@ -402,7 +413,7 @@ func TestBuild_ClearsPreviousMappings(t *testing.T) {
402413 mockPaneViewFactory.EXPECT().CreatePaneView(node1).Return(mockWidget1).Once()
403414 mockPaneViewFactory.EXPECT().CreatePaneView(node2).Return(mockWidget2).Once()
404415
405- renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory)
416+ renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory, zerolog.Nop())
406417
407418 // First build
408419 _, _ = renderer.Build(node1)
@@ -425,7 +436,7 @@ func TestBuild_NilPaneViewFactory_ReturnsNilForLeaves(t *testing.T) {
425436 pane := entity.NewPane(entity.PaneID("pane-1"))
426437 node := &entity.PaneNode{ID: "node-1", Pane: pane}
427438
428- renderer := layout.NewTreeRenderer(mockFactory, nil)
439+ renderer := layout.NewTreeRenderer(mockFactory, nil, zerolog.Nop())
429440
430441 // Act
431442 widget, err := renderer.Build(node)
@@ -446,7 +457,7 @@ func TestBuild_EmptyNodeID_NotStored(t *testing.T) {
446457
447458 mockPaneViewFactory.EXPECT().CreatePaneView(node).Return(mockWidget).Once()
448459
449- renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory)
460+ renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory, zerolog.Nop())
450461
451462 // Act
452463 widget, err := renderer.Build(node)
@@ -488,9 +499,11 @@ func TestUpdateSplitRatio_ExistingNode(t *testing.T) {
488499 mockPaned.EXPECT().SetShrinkEndChild(false).Once()
489500 mockPaned.EXPECT().SetStartChild(mockWidget1).Once()
490501 mockPaned.EXPECT().SetEndChild(mockWidget2).Once()
502+ mockPaned.EXPECT().GetAllocatedWidth().Return(0).Once()
491503 mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once()
504+ mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once()
492505
493- renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory)
506+ renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory, zerolog.Nop())
494507 _, _ = renderer.Build(splitNode)
495508
496509 // Act
@@ -505,7 +518,7 @@ func TestUpdateSplitRatio_NonExistentNode(t *testing.T) {
505518 mockFactory := mocks.NewMockWidgetFactory(t)
506519 mockPaneViewFactory := mocks.NewMockPaneViewFactory(t)
507520
508- renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory)
521+ renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory, zerolog.Nop())
509522
510523 // Act
511524 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 // Act21 // Act
21- renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory)22+ renderer := layout.NewTreeRenderer(mockFactory, mockPaneViewFactory, zerolog.Nop())
22 23
23 // Assert24 // 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 // Act36 // 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 // Act60 // 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 // Act110 // 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 // Act157 // 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 pane3221 // 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 // Act235 // 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 // Act263 // 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 // Act284 // 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 // Act305 // 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 // Act320 // 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 // Act390 // 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 build418 // 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 // Act441 // 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 // Act462 // 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 // Act509 // 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 // Act523 // 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 {
3030 SetCanFocus(canFocus bool)
3131 SetFocusOnClick(focusOnClick bool)
3232
33+ // Pointer events
34+ SetCanTarget(canTarget bool) // If false, widget won't receive pointer events
35+
3336 // Layout
3437 SetHexpand(expand bool)
3538 SetVexpand(expand bool)
@@ -82,6 +85,10 @@ type PanedWidget interface {
8285 // Signals
8386 ConnectMap(callback func()) uint32
8487
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+
8592 // Size information (for ratio calculation)
8693 GetAllocatedWidth() int
8794 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 // Layout36 // 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 // Signals85 // Signals
83 ConnectMap(callback func()) uint3286 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() int93 GetAllocatedWidth() int
87 GetAllocatedHeight() int94 GetAllocatedHeight() int

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close