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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +179 -9
M internal/ui/app.go +106 -7
| @@ -8,6 +8,7 @@ import ( | |||
| 8 | 8 | "path/filepath" | |
| 9 | 9 | "strings" | |
| 10 | 10 | "sync" | |
| 11 | + | "unsafe" | |
| 11 | 12 | ||
| 12 | 13 | "github.com/bnema/dumber/internal/application/port" | |
| 13 | 14 | "github.com/bnema/dumber/internal/application/usecase" | |
| @@ -49,6 +50,7 @@ const ( | |||
| 49 | 50 | floatingPaneFallbackHeight = 800 | |
| 50 | 51 | floatingPaneIDPrefix = "floating-pane:" | |
| 51 | 52 | floatingSessionIDDefault = "default" | |
| 53 | + | floatingPaneVisibleClass = "floating-pane-visible" | |
| 52 | 54 | ) | |
| 53 | 55 | ||
| 54 | 56 | func gtkApplicationFlags() gio.ApplicationFlags { | |
| @@ -1910,6 +1912,7 @@ func (a *App) createWorkspaceViewWithoutAttach(ctx context.Context, tab *entity. | |||
| 1910 | 1912 | log.Error().Msg("failed to create workspace view") | |
| 1911 | 1913 | return | |
| 1912 | 1914 | } | |
| 1915 | + | a.installFloatingOverlayPositioning(tab.ID, wsView.WorkspaceOverlayWidget()) | |
| 1913 | 1916 | ||
| 1914 | 1917 | // Set the workspace | |
| 1915 | 1918 | if err := wsView.SetWorkspace(ctx, tab.Workspace); err != nil { | |
| @@ -2112,25 +2115,25 @@ func decorateFloatingOverlay(overlay layout.OverlayWidget) { | |||
| 2112 | 2115 | } | |
| 2113 | 2116 | ||
| 2114 | 2117 | // 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. | |
| 2117 | 2120 | func showFloatingWidget(w layout.Widget) { | |
| 2118 | 2121 | if w == nil { | |
| 2119 | 2122 | return | |
| 2120 | 2123 | } | |
| 2121 | - | w.SetOpacity(1) | |
| 2124 | + | w.AddCssClass(floatingPaneVisibleClass) | |
| 2122 | 2125 | w.SetCanTarget(true) | |
| 2123 | 2126 | w.SetCanFocus(true) | |
| 2124 | 2127 | } | |
| 2125 | 2128 | ||
| 2126 | 2129 | // 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. | |
| 2129 | 2132 | func hideFloatingWidget(w layout.Widget) { | |
| 2130 | 2133 | if w == nil { | |
| 2131 | 2134 | return | |
| 2132 | 2135 | } | |
| 2133 | - | w.SetOpacity(0) | |
| 2136 | + | w.RemoveCssClass(floatingPaneVisibleClass) | |
| 2134 | 2137 | w.SetCanTarget(false) | |
| 2135 | 2138 | w.SetCanFocus(false) | |
| 2136 | 2139 | } | |
| @@ -2149,10 +2152,102 @@ func configureFloatingOverlayMeasurement(workspaceOverlay layout.OverlayWidget, | |||
| 2149 | 2152 | return | |
| 2150 | 2153 | } | |
| 2151 | 2154 | ||
| 2152 | - | workspaceOverlay.SetMeasureOverlay(floatingOverlay, true) | |
| 2155 | + | workspaceOverlay.SetMeasureOverlay(floatingOverlay, false) | |
| 2153 | 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 | 2251 | func (a *App) currentFloatingConfig() config.FloatingPaneConfig { | |
| 2157 | 2252 | if a.deps != nil && a.deps.Config != nil { | |
| 2158 | 2253 | return a.deps.Config.Workspace.FloatingPane | |
| @@ -2485,6 +2580,10 @@ func (a *App) hideFloatingSession(ctx context.Context, session *floatingWorkspac | |||
| 2485 | 2580 | session.pane.Hide(ctx) | |
| 2486 | 2581 | a.hideFloatingOmnibox(ctx, session) | |
| 2487 | 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 | 2589 | 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 = 800 | 50 | 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 | return | 1913 | return | ||
| 1912 | } | 1914 | } | ||
| 1915 | + | a.installFloatingOverlayPositioning(tab.ID, wsView.WorkspaceOverlayWidget()) | |||
| 1913 | 1916 | ||||
| 1914 | // Set the workspace | 1917 | // 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 | return | 2122 | 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 mapped | 2130 | + | // 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 | return | 2134 | 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 | return | 2152 | 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.FloatingPane | 2253 | 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 | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "testing" | |
| 6 | + | "unsafe" | |
| 6 | 7 | ||
| 7 | 8 | "github.com/bnema/dumber/internal/domain/entity" | |
| 8 | 9 | "github.com/bnema/dumber/internal/ui/component" | |
| @@ -22,16 +23,76 @@ func TestDecorateFloatingOverlay_AddsThemeClasses(t *testing.T) { | |||
| 22 | 23 | decorateFloatingOverlay(overlay) | |
| 23 | 24 | } | |
| 24 | 25 | ||
| 25 | - | func TestConfigureFloatingOverlayMeasurement_UsesMeasuredOverlay(t *testing.T) { | |
| 26 | + | func TestConfigureFloatingOverlayMeasurement_DoesNotMeasureOverlay(t *testing.T) { | |
| 26 | 27 | workspaceOverlay := layoutmocks.NewMockOverlayWidget(t) | |
| 27 | 28 | floatingOverlay := layoutmocks.NewMockWidget(t) | |
| 28 | 29 | ||
| 29 | - | workspaceOverlay.EXPECT().SetMeasureOverlay(floatingOverlay, true).Once() | |
| 30 | + | workspaceOverlay.EXPECT().SetMeasureOverlay(floatingOverlay, false).Once() | |
| 30 | 31 | workspaceOverlay.EXPECT().SetClipOverlay(floatingOverlay, false).Once() | |
| 31 | 32 | ||
| 32 | 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 | 96 | func TestFloatingPane_ToggleActiveWorkspaceSession(t *testing.T) { | |
| 36 | 97 | app, tabID, session := newFloatingPaneTestApp(t) | |
| 37 | 98 | ||
| @@ -248,12 +309,16 @@ func TestFloatingPane_HideFloatingSession_StopsResizeWatcher(t *testing.T) { | |||
| 248 | 309 | session := newFloatingPaneSession(entity.TabID("tab-1"), "profile:test") | |
| 249 | 310 | session.resizeWatcherActive = true | |
| 250 | 311 | session.resizeTickID = 42 | |
| 312 | + | session.appliedWidth = 960 | |
| 313 | + | session.appliedHeight = 640 | |
| 251 | 314 | ||
| 252 | 315 | app := &App{} | |
| 253 | 316 | app.hideFloatingSession(context.Background(), session) | |
| 254 | 317 | ||
| 255 | 318 | assert.False(t, session.resizeWatcherActive) | |
| 256 | 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 | 324 | 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 = true | 310 | session.resizeWatcherActive = true | ||
| 250 | session.resizeTickID = 42 | 311 | 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 { | |||
| 985 | 985 | border: 0.0625em solid var(--border); | |
| 986 | 986 | border-radius: 0.25em; | |
| 987 | 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 | 996 | .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 { | ||