detached 48c09fe

fix(ui): stabilize floating pane allocation and repaint on rapid toggles

commit
48c09fed08e79c952ff4d4a07881ec7139ec3a6f
author
brice · · 2026-02-09 10:25 UTC
parent
eb849fe
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +179 -9
M internal/ui/app.go +106 -7
@@ -8,6 +8,7 @@ import (
88 "path/filepath"
99 "strings"
1010 "sync"
11+ "unsafe"
1112
1213 "github.com/bnema/dumber/internal/application/port"
1314 "github.com/bnema/dumber/internal/application/usecase"
@@ -49,6 +50,7 @@ const (
4950 floatingPaneFallbackHeight = 800
5051 floatingPaneIDPrefix = "floating-pane:"
5152 floatingSessionIDDefault = "default"
53+ floatingPaneVisibleClass = "floating-pane-visible"
5254 )
5355
5456 func gtkApplicationFlags() gio.ApplicationFlags {
@@ -1910,6 +1912,7 @@ func (a *App) createWorkspaceViewWithoutAttach(ctx context.Context, tab *entity.
19101912 log.Error().Msg("failed to create workspace view")
19111913 return
19121914 }
1915+ a.installFloatingOverlayPositioning(tab.ID, wsView.WorkspaceOverlayWidget())
19131916
19141917 // Set the workspace
19151918 if err := wsView.SetWorkspace(ctx, tab.Workspace); err != nil {
@@ -2112,25 +2115,25 @@ func decorateFloatingOverlay(overlay layout.OverlayWidget) {
21122115 }
21132116
21142117 // showFloatingWidget makes a floating pane widget interactive and visible.
2115-// Uses opacity instead of SetVisible to keep the GPU surface allocated,
2116-// preventing the black flash when WebKit needs to re-composite after unmap.
2118+// Visibility is controlled by CSS class to animate opacity while keeping the
2119+// widget mapped so WebKit can redraw without a hard pop.
21172120 func showFloatingWidget(w layout.Widget) {
21182121 if w == nil {
21192122 return
21202123 }
2121- w.SetOpacity(1)
2124+ w.AddCssClass(floatingPaneVisibleClass)
21222125 w.SetCanTarget(true)
21232126 w.SetCanFocus(true)
21242127 }
21252128
21262129 // hideFloatingWidget makes a floating pane widget invisible and non-interactive.
2127-// Uses opacity 0 instead of SetVisible(false) so GTK keeps the widget mapped
2128-// and WebKit retains its rendered surface in GPU memory.
2130+// Removes the visible CSS class instead of SetVisible(false) so GTK keeps the
2131+// widget mapped and WebKit retains its rendered surface in GPU memory.
21292132 func hideFloatingWidget(w layout.Widget) {
21302133 if w == nil {
21312134 return
21322135 }
2133- w.SetOpacity(0)
2136+ w.RemoveCssClass(floatingPaneVisibleClass)
21342137 w.SetCanTarget(false)
21352138 w.SetCanFocus(false)
21362139 }
@@ -2149,10 +2152,102 @@ func configureFloatingOverlayMeasurement(workspaceOverlay layout.OverlayWidget,
21492152 return
21502153 }
21512154
2152- workspaceOverlay.SetMeasureOverlay(floatingOverlay, true)
2155+ workspaceOverlay.SetMeasureOverlay(floatingOverlay, false)
21532156 workspaceOverlay.SetClipOverlay(floatingOverlay, false)
21542157 }
21552158
2159+func floatingAllocationRect(overlayWidth, overlayHeight, desiredWidth, desiredHeight int) (x, y, width, height int, ok bool) {
2160+ if overlayWidth <= 0 || overlayHeight <= 0 || desiredWidth <= 0 || desiredHeight <= 0 {
2161+ return 0, 0, 0, 0, false
2162+ }
2163+
2164+ width = desiredWidth
2165+ height = desiredHeight
2166+ if width > overlayWidth {
2167+ width = overlayWidth
2168+ }
2169+ if height > overlayHeight {
2170+ height = overlayHeight
2171+ }
2172+
2173+ x = (overlayWidth - width) / 2
2174+ y = (overlayHeight - height) / 2
2175+ if x < 0 {
2176+ x = 0
2177+ }
2178+ if y < 0 {
2179+ y = 0
2180+ }
2181+
2182+ return x, y, width, height, true
2183+}
2184+
2185+func writeOverlayAllocation(allocationPtr *uintptr, x, y, width, height int) bool {
2186+ if allocationPtr == nil {
2187+ return false
2188+ }
2189+
2190+ // GtkAllocation/GdkRectangle is four 32-bit signed integers in C.
2191+ base := unsafe.Pointer(allocationPtr)
2192+ *(*int32)(base) = int32(x)
2193+ *(*int32)(unsafe.Pointer(uintptr(base) + uintptr(4))) = int32(y)
2194+ *(*int32)(unsafe.Pointer(uintptr(base) + uintptr(8))) = int32(width)
2195+ *(*int32)(unsafe.Pointer(uintptr(base) + uintptr(12))) = int32(height)
2196+ return true
2197+}
2198+
2199+func (a *App) floatingAllocationForWidget(tabID entity.TabID, widgetPtr uintptr, overlayWidth, overlayHeight int) (x, y, width, height int, ok bool) {
2200+ for key, session := range a.floatingSessions {
2201+ if key.tabID != tabID || session == nil || session.widget == nil {
2202+ continue
2203+ }
2204+
2205+ gtkWidget := session.widget.GtkWidget()
2206+ if gtkWidget == nil || gtkWidget.GoPointer() != widgetPtr {
2207+ continue
2208+ }
2209+
2210+ desiredWidth := session.appliedWidth
2211+ desiredHeight := session.appliedHeight
2212+ if (desiredWidth <= 0 || desiredHeight <= 0) && session.pane != nil {
2213+ session.pane.Resize()
2214+ desiredWidth, desiredHeight = session.pane.Dimensions()
2215+ }
2216+
2217+ return floatingAllocationRect(overlayWidth, overlayHeight, desiredWidth, desiredHeight)
2218+ }
2219+
2220+ return 0, 0, 0, 0, false
2221+}
2222+
2223+func (a *App) installFloatingOverlayPositioning(tabID entity.TabID, workspaceOverlay layout.OverlayWidget) {
2224+ if workspaceOverlay == nil {
2225+ return
2226+ }
2227+
2228+ workspaceWidget := workspaceOverlay.GtkWidget()
2229+ if workspaceWidget == nil {
2230+ return
2231+ }
2232+
2233+ gtkOverlay := gtk.OverlayNewFromInternalPtr(workspaceWidget.GoPointer())
2234+ if gtkOverlay == nil {
2235+ return
2236+ }
2237+
2238+ cb := func(_ gtk.Overlay, widgetPtr uintptr, allocationPtr *uintptr) bool {
2239+ overlayWidth := workspaceOverlay.GetAllocatedWidth()
2240+ overlayHeight := workspaceOverlay.GetAllocatedHeight()
2241+ x, y, width, height, ok := a.floatingAllocationForWidget(tabID, widgetPtr, overlayWidth, overlayHeight)
2242+ if !ok {
2243+ return false
2244+ }
2245+ return writeOverlayAllocation(allocationPtr, x, y, width, height)
2246+ }
2247+
2248+ gtkOverlay.ConnectGetChildPosition(&cb)
2249+}
2250+
21562251 func (a *App) currentFloatingConfig() config.FloatingPaneConfig {
21572252 if a.deps != nil && a.deps.Config != nil {
21582253 return a.deps.Config.Workspace.FloatingPane
@@ -2485,6 +2580,10 @@ func (a *App) hideFloatingSession(ctx context.Context, session *floatingWorkspac
24852580 session.pane.Hide(ctx)
24862581 a.hideFloatingOmnibox(ctx, session)
24872582 hideFloatingWidget(session.widget)
2583+ // Force a fresh size request on next show so WebKit gets a new
2584+ // allocation cycle and repaints content after rapid toggles.
2585+ session.appliedWidth = 0
2586+ session.appliedHeight = 0
24882587 }
24892588
24902589 func (a *App) hideVisibleFloatingSessions(ctx context.Context, tabID entity.TabID, except *floatingWorkspaceSession) {
@@ -8,6 +8,7 @@ import (
8 "path/filepath"8 "path/filepath"
9 "strings"9 "strings"
10 "sync"10 "sync"
11+ "unsafe"
11 12
12 "github.com/bnema/dumber/internal/application/port"13 "github.com/bnema/dumber/internal/application/port"
13 "github.com/bnema/dumber/internal/application/usecase"14 "github.com/bnema/dumber/internal/application/usecase"
@@ -49,6 +50,7 @@ const (
49 floatingPaneFallbackHeight = 80050 floatingPaneFallbackHeight = 800
50 floatingPaneIDPrefix = "floating-pane:"51 floatingPaneIDPrefix = "floating-pane:"
51 floatingSessionIDDefault = "default"52 floatingSessionIDDefault = "default"
53+ floatingPaneVisibleClass = "floating-pane-visible"
52 )54 )
53 55
54 func gtkApplicationFlags() gio.ApplicationFlags {56 func gtkApplicationFlags() gio.ApplicationFlags {
@@ -1910,6 +1912,7 @@ func (a *App) createWorkspaceViewWithoutAttach(ctx context.Context, tab *entity.
1910 log.Error().Msg("failed to create workspace view")1912 log.Error().Msg("failed to create workspace view")
1911 return1913 return
1912 }1914 }
1915+ a.installFloatingOverlayPositioning(tab.ID, wsView.WorkspaceOverlayWidget())
1913 1916
1914 // Set the workspace1917 // Set the workspace
1915 if err := wsView.SetWorkspace(ctx, tab.Workspace); err != nil {1918 if err := wsView.SetWorkspace(ctx, tab.Workspace); err != nil {
@@ -2112,25 +2115,25 @@ func decorateFloatingOverlay(overlay layout.OverlayWidget) {
2112 }2115 }
2113 2116
2114 // showFloatingWidget makes a floating pane widget interactive and visible.2117 // showFloatingWidget makes a floating pane widget interactive and visible.
2115-// Uses opacity instead of SetVisible to keep the GPU surface allocated,2118+// Visibility is controlled by CSS class to animate opacity while keeping the
2116-// preventing the black flash when WebKit needs to re-composite after unmap.2119+// widget mapped so WebKit can redraw without a hard pop.
2117 func showFloatingWidget(w layout.Widget) {2120 func showFloatingWidget(w layout.Widget) {
2118 if w == nil {2121 if w == nil {
2119 return2122 return
2120 }2123 }
2121- w.SetOpacity(1)2124+ w.AddCssClass(floatingPaneVisibleClass)
2122 w.SetCanTarget(true)2125 w.SetCanTarget(true)
2123 w.SetCanFocus(true)2126 w.SetCanFocus(true)
2124 }2127 }
2125 2128
2126 // hideFloatingWidget makes a floating pane widget invisible and non-interactive.2129 // hideFloatingWidget makes a floating pane widget invisible and non-interactive.
2127-// Uses opacity 0 instead of SetVisible(false) so GTK keeps the widget mapped2130+// Removes the visible CSS class instead of SetVisible(false) so GTK keeps the
2128-// and WebKit retains its rendered surface in GPU memory.2131+// widget mapped and WebKit retains its rendered surface in GPU memory.
2129 func hideFloatingWidget(w layout.Widget) {2132 func hideFloatingWidget(w layout.Widget) {
2130 if w == nil {2133 if w == nil {
2131 return2134 return
2132 }2135 }
2133- w.SetOpacity(0)2136+ w.RemoveCssClass(floatingPaneVisibleClass)
2134 w.SetCanTarget(false)2137 w.SetCanTarget(false)
2135 w.SetCanFocus(false)2138 w.SetCanFocus(false)
2136 }2139 }
@@ -2149,10 +2152,102 @@ func configureFloatingOverlayMeasurement(workspaceOverlay layout.OverlayWidget,
2149 return2152 return
2150 }2153 }
2151 2154
2152- workspaceOverlay.SetMeasureOverlay(floatingOverlay, true)2155+ workspaceOverlay.SetMeasureOverlay(floatingOverlay, false)
2153 workspaceOverlay.SetClipOverlay(floatingOverlay, false)2156 workspaceOverlay.SetClipOverlay(floatingOverlay, false)
2154 }2157 }
2155 2158
2159+func floatingAllocationRect(overlayWidth, overlayHeight, desiredWidth, desiredHeight int) (x, y, width, height int, ok bool) {
2160+ if overlayWidth <= 0 || overlayHeight <= 0 || desiredWidth <= 0 || desiredHeight <= 0 {
2161+ return 0, 0, 0, 0, false
2162+ }
2163+
2164+ width = desiredWidth
2165+ height = desiredHeight
2166+ if width > overlayWidth {
2167+ width = overlayWidth
2168+ }
2169+ if height > overlayHeight {
2170+ height = overlayHeight
2171+ }
2172+
2173+ x = (overlayWidth - width) / 2
2174+ y = (overlayHeight - height) / 2
2175+ if x < 0 {
2176+ x = 0
2177+ }
2178+ if y < 0 {
2179+ y = 0
2180+ }
2181+
2182+ return x, y, width, height, true
2183+}
2184+
2185+func writeOverlayAllocation(allocationPtr *uintptr, x, y, width, height int) bool {
2186+ if allocationPtr == nil {
2187+ return false
2188+ }
2189+
2190+ // GtkAllocation/GdkRectangle is four 32-bit signed integers in C.
2191+ base := unsafe.Pointer(allocationPtr)
2192+ *(*int32)(base) = int32(x)
2193+ *(*int32)(unsafe.Pointer(uintptr(base) + uintptr(4))) = int32(y)
2194+ *(*int32)(unsafe.Pointer(uintptr(base) + uintptr(8))) = int32(width)
2195+ *(*int32)(unsafe.Pointer(uintptr(base) + uintptr(12))) = int32(height)
2196+ return true
2197+}
2198+
2199+func (a *App) floatingAllocationForWidget(tabID entity.TabID, widgetPtr uintptr, overlayWidth, overlayHeight int) (x, y, width, height int, ok bool) {
2200+ for key, session := range a.floatingSessions {
2201+ if key.tabID != tabID || session == nil || session.widget == nil {
2202+ continue
2203+ }
2204+
2205+ gtkWidget := session.widget.GtkWidget()
2206+ if gtkWidget == nil || gtkWidget.GoPointer() != widgetPtr {
2207+ continue
2208+ }
2209+
2210+ desiredWidth := session.appliedWidth
2211+ desiredHeight := session.appliedHeight
2212+ if (desiredWidth <= 0 || desiredHeight <= 0) && session.pane != nil {
2213+ session.pane.Resize()
2214+ desiredWidth, desiredHeight = session.pane.Dimensions()
2215+ }
2216+
2217+ return floatingAllocationRect(overlayWidth, overlayHeight, desiredWidth, desiredHeight)
2218+ }
2219+
2220+ return 0, 0, 0, 0, false
2221+}
2222+
2223+func (a *App) installFloatingOverlayPositioning(tabID entity.TabID, workspaceOverlay layout.OverlayWidget) {
2224+ if workspaceOverlay == nil {
2225+ return
2226+ }
2227+
2228+ workspaceWidget := workspaceOverlay.GtkWidget()
2229+ if workspaceWidget == nil {
2230+ return
2231+ }
2232+
2233+ gtkOverlay := gtk.OverlayNewFromInternalPtr(workspaceWidget.GoPointer())
2234+ if gtkOverlay == nil {
2235+ return
2236+ }
2237+
2238+ cb := func(_ gtk.Overlay, widgetPtr uintptr, allocationPtr *uintptr) bool {
2239+ overlayWidth := workspaceOverlay.GetAllocatedWidth()
2240+ overlayHeight := workspaceOverlay.GetAllocatedHeight()
2241+ x, y, width, height, ok := a.floatingAllocationForWidget(tabID, widgetPtr, overlayWidth, overlayHeight)
2242+ if !ok {
2243+ return false
2244+ }
2245+ return writeOverlayAllocation(allocationPtr, x, y, width, height)
2246+ }
2247+
2248+ gtkOverlay.ConnectGetChildPosition(&cb)
2249+}
2250+
2156 func (a *App) currentFloatingConfig() config.FloatingPaneConfig {2251 func (a *App) currentFloatingConfig() config.FloatingPaneConfig {
2157 if a.deps != nil && a.deps.Config != nil {2252 if a.deps != nil && a.deps.Config != nil {
2158 return a.deps.Config.Workspace.FloatingPane2253 return a.deps.Config.Workspace.FloatingPane
@@ -2485,6 +2580,10 @@ func (a *App) hideFloatingSession(ctx context.Context, session *floatingWorkspac
2485 session.pane.Hide(ctx)2580 session.pane.Hide(ctx)
2486 a.hideFloatingOmnibox(ctx, session)2581 a.hideFloatingOmnibox(ctx, session)
2487 hideFloatingWidget(session.widget)2582 hideFloatingWidget(session.widget)
2583+ // Force a fresh size request on next show so WebKit gets a new
2584+ // allocation cycle and repaints content after rapid toggles.
2585+ session.appliedWidth = 0
2586+ session.appliedHeight = 0
2488 }2587 }
2489 2588
2490 func (a *App) hideVisibleFloatingSessions(ctx context.Context, tabID entity.TabID, except *floatingWorkspaceSession) {2589 func (a *App) hideVisibleFloatingSessions(ctx context.Context, tabID entity.TabID, except *floatingWorkspaceSession) {
M internal/ui/floating_pane_test.go +67 -2
@@ -3,6 +3,7 @@ package ui
33 import (
44 "context"
55 "testing"
6+ "unsafe"
67
78 "github.com/bnema/dumber/internal/domain/entity"
89 "github.com/bnema/dumber/internal/ui/component"
@@ -22,16 +23,76 @@ func TestDecorateFloatingOverlay_AddsThemeClasses(t *testing.T) {
2223 decorateFloatingOverlay(overlay)
2324 }
2425
25-func TestConfigureFloatingOverlayMeasurement_UsesMeasuredOverlay(t *testing.T) {
26+func TestConfigureFloatingOverlayMeasurement_DoesNotMeasureOverlay(t *testing.T) {
2627 workspaceOverlay := layoutmocks.NewMockOverlayWidget(t)
2728 floatingOverlay := layoutmocks.NewMockWidget(t)
2829
29- workspaceOverlay.EXPECT().SetMeasureOverlay(floatingOverlay, true).Once()
30+ workspaceOverlay.EXPECT().SetMeasureOverlay(floatingOverlay, false).Once()
3031 workspaceOverlay.EXPECT().SetClipOverlay(floatingOverlay, false).Once()
3132
3233 configureFloatingOverlayMeasurement(workspaceOverlay, floatingOverlay)
3334 }
3435
36+func TestShowFloatingWidget_AddsVisibleClassAndEnablesInteraction(t *testing.T) {
37+ widget := layoutmocks.NewMockWidget(t)
38+
39+ widget.EXPECT().AddCssClass(floatingPaneVisibleClass).Once()
40+ widget.EXPECT().SetCanTarget(true).Once()
41+ widget.EXPECT().SetCanFocus(true).Once()
42+
43+ showFloatingWidget(widget)
44+}
45+
46+func TestHideFloatingWidget_RemovesVisibleClassAndDisablesInteraction(t *testing.T) {
47+ widget := layoutmocks.NewMockWidget(t)
48+
49+ widget.EXPECT().RemoveCssClass(floatingPaneVisibleClass).Once()
50+ widget.EXPECT().SetCanTarget(false).Once()
51+ widget.EXPECT().SetCanFocus(false).Once()
52+
53+ hideFloatingWidget(widget)
54+}
55+
56+func TestFloatingAllocationRect_CentersRequestedSize(t *testing.T) {
57+ x, y, width, height, ok := floatingAllocationRect(1000, 700, 820, 504)
58+
59+ require.True(t, ok)
60+ assert.Equal(t, 90, x)
61+ assert.Equal(t, 98, y)
62+ assert.Equal(t, 820, width)
63+ assert.Equal(t, 504, height)
64+}
65+
66+func TestFloatingAllocationRect_ClampsToOverlayBounds(t *testing.T) {
67+ x, y, width, height, ok := floatingAllocationRect(1000, 700, 1200, 900)
68+
69+ require.True(t, ok)
70+ assert.Equal(t, 0, x)
71+ assert.Equal(t, 0, y)
72+ assert.Equal(t, 1000, width)
73+ assert.Equal(t, 700, height)
74+}
75+
76+func TestWriteOverlayAllocation_WritesRectangleFields(t *testing.T) {
77+ type cGtkAllocation struct {
78+ X int32
79+ Y int32
80+ Width int32
81+ Height int32
82+ }
83+
84+ rect := cGtkAllocation{}
85+ allocationPtr := (*uintptr)(unsafe.Pointer(&rect))
86+
87+ ok := writeOverlayAllocation(allocationPtr, 11, 22, 33, 44)
88+
89+ require.True(t, ok)
90+ assert.Equal(t, int32(11), rect.X)
91+ assert.Equal(t, int32(22), rect.Y)
92+ assert.Equal(t, int32(33), rect.Width)
93+ assert.Equal(t, int32(44), rect.Height)
94+}
95+
3596 func TestFloatingPane_ToggleActiveWorkspaceSession(t *testing.T) {
3697 app, tabID, session := newFloatingPaneTestApp(t)
3798
@@ -248,12 +309,16 @@ func TestFloatingPane_HideFloatingSession_StopsResizeWatcher(t *testing.T) {
248309 session := newFloatingPaneSession(entity.TabID("tab-1"), "profile:test")
249310 session.resizeWatcherActive = true
250311 session.resizeTickID = 42
312+ session.appliedWidth = 960
313+ session.appliedHeight = 640
251314
252315 app := &App{}
253316 app.hideFloatingSession(context.Background(), session)
254317
255318 assert.False(t, session.resizeWatcherActive)
256319 assert.Equal(t, uint(0), session.resizeTickID)
320+ assert.Equal(t, 0, session.appliedWidth)
321+ assert.Equal(t, 0, session.appliedHeight)
257322 }
258323
259324 func newFloatingPaneTestApp(t *testing.T) (*App, entity.TabID, *floatingWorkspaceSession) {
@@ -3,6 +3,7 @@ package ui
3 import (3 import (
4 "context"4 "context"
5 "testing"5 "testing"
6+ "unsafe"
6 7
7 "github.com/bnema/dumber/internal/domain/entity"8 "github.com/bnema/dumber/internal/domain/entity"
8 "github.com/bnema/dumber/internal/ui/component"9 "github.com/bnema/dumber/internal/ui/component"
@@ -22,16 +23,76 @@ func TestDecorateFloatingOverlay_AddsThemeClasses(t *testing.T) {
22 decorateFloatingOverlay(overlay)23 decorateFloatingOverlay(overlay)
23 }24 }
24 25
25-func TestConfigureFloatingOverlayMeasurement_UsesMeasuredOverlay(t *testing.T) {26+func TestConfigureFloatingOverlayMeasurement_DoesNotMeasureOverlay(t *testing.T) {
26 workspaceOverlay := layoutmocks.NewMockOverlayWidget(t)27 workspaceOverlay := layoutmocks.NewMockOverlayWidget(t)
27 floatingOverlay := layoutmocks.NewMockWidget(t)28 floatingOverlay := layoutmocks.NewMockWidget(t)
28 29
29- workspaceOverlay.EXPECT().SetMeasureOverlay(floatingOverlay, true).Once()30+ workspaceOverlay.EXPECT().SetMeasureOverlay(floatingOverlay, false).Once()
30 workspaceOverlay.EXPECT().SetClipOverlay(floatingOverlay, false).Once()31 workspaceOverlay.EXPECT().SetClipOverlay(floatingOverlay, false).Once()
31 32
32 configureFloatingOverlayMeasurement(workspaceOverlay, floatingOverlay)33 configureFloatingOverlayMeasurement(workspaceOverlay, floatingOverlay)
33 }34 }
34 35
36+func TestShowFloatingWidget_AddsVisibleClassAndEnablesInteraction(t *testing.T) {
37+ widget := layoutmocks.NewMockWidget(t)
38+
39+ widget.EXPECT().AddCssClass(floatingPaneVisibleClass).Once()
40+ widget.EXPECT().SetCanTarget(true).Once()
41+ widget.EXPECT().SetCanFocus(true).Once()
42+
43+ showFloatingWidget(widget)
44+}
45+
46+func TestHideFloatingWidget_RemovesVisibleClassAndDisablesInteraction(t *testing.T) {
47+ widget := layoutmocks.NewMockWidget(t)
48+
49+ widget.EXPECT().RemoveCssClass(floatingPaneVisibleClass).Once()
50+ widget.EXPECT().SetCanTarget(false).Once()
51+ widget.EXPECT().SetCanFocus(false).Once()
52+
53+ hideFloatingWidget(widget)
54+}
55+
56+func TestFloatingAllocationRect_CentersRequestedSize(t *testing.T) {
57+ x, y, width, height, ok := floatingAllocationRect(1000, 700, 820, 504)
58+
59+ require.True(t, ok)
60+ assert.Equal(t, 90, x)
61+ assert.Equal(t, 98, y)
62+ assert.Equal(t, 820, width)
63+ assert.Equal(t, 504, height)
64+}
65+
66+func TestFloatingAllocationRect_ClampsToOverlayBounds(t *testing.T) {
67+ x, y, width, height, ok := floatingAllocationRect(1000, 700, 1200, 900)
68+
69+ require.True(t, ok)
70+ assert.Equal(t, 0, x)
71+ assert.Equal(t, 0, y)
72+ assert.Equal(t, 1000, width)
73+ assert.Equal(t, 700, height)
74+}
75+
76+func TestWriteOverlayAllocation_WritesRectangleFields(t *testing.T) {
77+ type cGtkAllocation struct {
78+ X int32
79+ Y int32
80+ Width int32
81+ Height int32
82+ }
83+
84+ rect := cGtkAllocation{}
85+ allocationPtr := (*uintptr)(unsafe.Pointer(&rect))
86+
87+ ok := writeOverlayAllocation(allocationPtr, 11, 22, 33, 44)
88+
89+ require.True(t, ok)
90+ assert.Equal(t, int32(11), rect.X)
91+ assert.Equal(t, int32(22), rect.Y)
92+ assert.Equal(t, int32(33), rect.Width)
93+ assert.Equal(t, int32(44), rect.Height)
94+}
95+
35 func TestFloatingPane_ToggleActiveWorkspaceSession(t *testing.T) {96 func TestFloatingPane_ToggleActiveWorkspaceSession(t *testing.T) {
36 app, tabID, session := newFloatingPaneTestApp(t)97 app, tabID, session := newFloatingPaneTestApp(t)
37 98
@@ -248,12 +309,16 @@ func TestFloatingPane_HideFloatingSession_StopsResizeWatcher(t *testing.T) {
248 session := newFloatingPaneSession(entity.TabID("tab-1"), "profile:test")309 session := newFloatingPaneSession(entity.TabID("tab-1"), "profile:test")
249 session.resizeWatcherActive = true310 session.resizeWatcherActive = true
250 session.resizeTickID = 42311 session.resizeTickID = 42
312+ session.appliedWidth = 960
313+ session.appliedHeight = 640
251 314
252 app := &App{}315 app := &App{}
253 app.hideFloatingSession(context.Background(), session)316 app.hideFloatingSession(context.Background(), session)
254 317
255 assert.False(t, session.resizeWatcherActive)318 assert.False(t, session.resizeWatcherActive)
256 assert.Equal(t, uint(0), session.resizeTickID)319 assert.Equal(t, uint(0), session.resizeTickID)
320+ assert.Equal(t, 0, session.appliedWidth)
321+ assert.Equal(t, 0, session.appliedHeight)
257 }322 }
258 323
259 func newFloatingPaneTestApp(t *testing.T) (*App, entity.TabID, *floatingWorkspaceSession) {324 func newFloatingPaneTestApp(t *testing.T) (*App, entity.TabID, *floatingWorkspaceSession) {
M internal/ui/theme/css.go +6 -0
@@ -985,6 +985,12 @@ func generateFloatingPaneCSS(p Palette) string {
985985 border: 0.0625em solid var(--border);
986986 border-radius: 0.25em;
987987 box-shadow: 0 0.5em 1.5em alpha(black, 0.25);
988+ opacity: 0;
989+ transition: opacity 110ms ease-out;
990+}
991+
992+.floating-pane-container.floating-pane-visible {
993+ opacity: 1;
988994 }
989995
990996 .floating-pane-container.pane-active {
@@ -985,6 +985,12 @@ func generateFloatingPaneCSS(p Palette) string {
985 border: 0.0625em solid var(--border);985 border: 0.0625em solid var(--border);
986 border-radius: 0.25em;986 border-radius: 0.25em;
987 box-shadow: 0 0.5em 1.5em alpha(black, 0.25);987 box-shadow: 0 0.5em 1.5em alpha(black, 0.25);
988+ opacity: 0;
989+ transition: opacity 110ms ease-out;
990+}
991+
992+.floating-pane-container.floating-pane-visible {
993+ opacity: 1;
988 }994 }
989 995
990 .floating-pane-container.pane-active {996 .floating-pane-container.pane-active {

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