Merge pull request #133 from bnema/feat/omnibox-click-outside-hide
feat(omnibox): hide on click outside bounds
1 file · +86 -0
M internal/ui/component/omnibox.go +86 -0
| @@ -133,6 +133,10 @@ type Omnibox struct { | |||
| 133 | 133 | ||
| 134 | 134 | // Callback retention: keep GTK signal callbacks reachable by Go GC. | |
| 135 | 135 | retainedCallbacks []interface{} | |
| 136 | + | ||
| 137 | + | // Click outside handler | |
| 138 | + | clickOutsideController *gtk.GestureClick | |
| 139 | + | clickOutsideCb func(gtk.GestureClick, int, float64, float64) | |
| 136 | 140 | } | |
| 137 | 141 | ||
| 138 | 142 | // OmniboxConfig holds configuration for creating an Omnibox. | |
| @@ -202,6 +206,86 @@ func (o *Omnibox) SetParentOverlay(overlay layout.OverlayWidget) { | |||
| 202 | 206 | o.mu.Lock() | |
| 203 | 207 | defer o.mu.Unlock() | |
| 204 | 208 | o.parentOverlay = overlay | |
| 209 | + | o.setupClickOutsideHandler() | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | // setupClickOutsideHandler attaches a click gesture to the parent overlay | |
| 213 | + | // that hides the omnibox when clicking outside its bounds. | |
| 214 | + | func (o *Omnibox) setupClickOutsideHandler() { | |
| 215 | + | if o.parentOverlay == nil { | |
| 216 | + | return | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | log := logging.FromContext(o.ctx) | |
| 220 | + | ||
| 221 | + | // Create gesture click controller | |
| 222 | + | o.clickOutsideController = gtk.NewGestureClick() | |
| 223 | + | if o.clickOutsideController == nil { | |
| 224 | + | log.Error().Msg("failed to create click outside controller") | |
| 225 | + | return | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | // Listen to primary button only | |
| 229 | + | o.clickOutsideController.SetButton(1) // GDK_BUTTON_PRIMARY | |
| 230 | + | ||
| 231 | + | // Set capture phase so we see clicks before they reach the omnibox | |
| 232 | + | o.clickOutsideController.SetPropagationPhase(gtk.PhaseCaptureValue) | |
| 233 | + | ||
| 234 | + | // Connect pressed handler | |
| 235 | + | o.clickOutsideCb = func(_ gtk.GestureClick, _ int, x, y float64) { | |
| 236 | + | o.handleClickOutside(x, y) | |
| 237 | + | } | |
| 238 | + | o.clickOutsideController.ConnectPressed(&o.clickOutsideCb) | |
| 239 | + | ||
| 240 | + | // Add controller to parent overlay | |
| 241 | + | gtkWidget := o.parentOverlay.GtkWidget() | |
| 242 | + | if gtkWidget != nil { | |
| 243 | + | gtkWidget.AddController(&o.clickOutsideController.EventController) | |
| 244 | + | } | |
| 245 | + | ||
| 246 | + | log.Debug().Msg("click outside handler attached to parent overlay") | |
| 247 | + | } | |
| 248 | + | ||
| 249 | + | // handleClickOutside checks if a click is outside the omnibox bounds and hides if so. | |
| 250 | + | func (o *Omnibox) handleClickOutside(clickX, clickY float64) { | |
| 251 | + | o.mu.RLock() | |
| 252 | + | visible := o.visible | |
| 253 | + | o.mu.RUnlock() | |
| 254 | + | ||
| 255 | + | if !visible || o.outerBox == nil || o.parentOverlay == nil { | |
| 256 | + | return | |
| 257 | + | } | |
| 258 | + | ||
| 259 | + | // Get omnibox position relative to parent overlay using graphene points | |
| 260 | + | srcPoint := &graphene.Point{X: 0, Y: 0} | |
| 261 | + | outPoint := &graphene.Point{} | |
| 262 | + | ok := o.outerBox.ComputePoint(o.parentOverlay.GtkWidget(), srcPoint, outPoint) | |
| 263 | + | if !ok { | |
| 264 | + | return | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | boxX := float64(outPoint.X) | |
| 268 | + | boxY := float64(outPoint.Y) | |
| 269 | + | ||
| 270 | + | // Get omnibox dimensions | |
| 271 | + | boxWidth := float64(o.outerBox.GetAllocatedWidth()) | |
| 272 | + | boxHeight := float64(o.outerBox.GetAllocatedHeight()) | |
| 273 | + | ||
| 274 | + | // Check if click is inside omnibox bounds | |
| 275 | + | isInside := clickX >= boxX && clickX <= boxX+boxWidth && | |
| 276 | + | clickY >= boxY && clickY <= boxY+boxHeight | |
| 277 | + | ||
| 278 | + | if !isInside { | |
| 279 | + | logging.FromContext(o.ctx).Debug(). | |
| 280 | + | Float64("clickX", clickX). | |
| 281 | + | Float64("clickY", clickY). | |
| 282 | + | Float64("boxX", boxX). | |
| 283 | + | Float64("boxY", boxY). | |
| 284 | + | Float64("boxWidth", boxWidth). | |
| 285 | + | Float64("boxHeight", boxHeight). | |
| 286 | + | Msg("click outside omnibox, hiding") | |
| 287 | + | o.Hide(o.ctx) | |
| 288 | + | } | |
| 205 | 289 | } | |
| 206 | 290 | ||
| 207 | 291 | // WidgetAsLayout returns the omnibox's outer widget as a layout.Widget. | |
| @@ -2098,6 +2182,8 @@ func (o *Omnibox) Destroy() { | |||
| 2098 | 2182 | ||
| 2099 | 2183 | o.parentOverlay = nil | |
| 2100 | 2184 | o.retainedCallbacks = nil | |
| 2185 | + | o.clickOutsideController = nil | |
| 2186 | + | o.clickOutsideCb = nil | |
| 2101 | 2187 | ||
| 2102 | 2188 | if o.outerBox != nil { | |
| 2103 | 2189 | o.outerBox.Unparent() | |
| @@ -133,6 +133,10 @@ type Omnibox struct { | |||||
| 133 | 133 | ||||
| 134 | // Callback retention: keep GTK signal callbacks reachable by Go GC. | 134 | // Callback retention: keep GTK signal callbacks reachable by Go GC. | ||
| 135 | retainedCallbacks []interface{} | 135 | retainedCallbacks []interface{} | ||
| 136 | + | ||||
| 137 | + | // Click outside handler | |||
| 138 | + | clickOutsideController *gtk.GestureClick | |||
| 139 | + | clickOutsideCb func(gtk.GestureClick, int, float64, float64) | |||
| 136 | } | 140 | } | ||
| 137 | 141 | ||||
| 138 | // OmniboxConfig holds configuration for creating an Omnibox. | 142 | // OmniboxConfig holds configuration for creating an Omnibox. | ||
| @@ -202,6 +206,86 @@ func (o *Omnibox) SetParentOverlay(overlay layout.OverlayWidget) { | |||||
| 202 | o.mu.Lock() | 206 | o.mu.Lock() | ||
| 203 | defer o.mu.Unlock() | 207 | defer o.mu.Unlock() | ||
| 204 | o.parentOverlay = overlay | 208 | o.parentOverlay = overlay | ||
| 209 | + | o.setupClickOutsideHandler() | |||
| 210 | + | } | |||
| 211 | + | ||||
| 212 | + | // setupClickOutsideHandler attaches a click gesture to the parent overlay | |||
| 213 | + | // that hides the omnibox when clicking outside its bounds. | |||
| 214 | + | func (o *Omnibox) setupClickOutsideHandler() { | |||
| 215 | + | if o.parentOverlay == nil { | |||
| 216 | + | return | |||
| 217 | + | } | |||
| 218 | + | ||||
| 219 | + | log := logging.FromContext(o.ctx) | |||
| 220 | + | ||||
| 221 | + | // Create gesture click controller | |||
| 222 | + | o.clickOutsideController = gtk.NewGestureClick() | |||
| 223 | + | if o.clickOutsideController == nil { | |||
| 224 | + | log.Error().Msg("failed to create click outside controller") | |||
| 225 | + | return | |||
| 226 | + | } | |||
| 227 | + | ||||
| 228 | + | // Listen to primary button only | |||
| 229 | + | o.clickOutsideController.SetButton(1) // GDK_BUTTON_PRIMARY | |||
| 230 | + | ||||
| 231 | + | // Set capture phase so we see clicks before they reach the omnibox | |||
| 232 | + | o.clickOutsideController.SetPropagationPhase(gtk.PhaseCaptureValue) | |||
| 233 | + | ||||
| 234 | + | // Connect pressed handler | |||
| 235 | + | o.clickOutsideCb = func(_ gtk.GestureClick, _ int, x, y float64) { | |||
| 236 | + | o.handleClickOutside(x, y) | |||
| 237 | + | } | |||
| 238 | + | o.clickOutsideController.ConnectPressed(&o.clickOutsideCb) | |||
| 239 | + | ||||
| 240 | + | // Add controller to parent overlay | |||
| 241 | + | gtkWidget := o.parentOverlay.GtkWidget() | |||
| 242 | + | if gtkWidget != nil { | |||
| 243 | + | gtkWidget.AddController(&o.clickOutsideController.EventController) | |||
| 244 | + | } | |||
| 245 | + | ||||
| 246 | + | log.Debug().Msg("click outside handler attached to parent overlay") | |||
| 247 | + | } | |||
| 248 | + | ||||
| 249 | + | // handleClickOutside checks if a click is outside the omnibox bounds and hides if so. | |||
| 250 | + | func (o *Omnibox) handleClickOutside(clickX, clickY float64) { | |||
| 251 | + | o.mu.RLock() | |||
| 252 | + | visible := o.visible | |||
| 253 | + | o.mu.RUnlock() | |||
| 254 | + | ||||
| 255 | + | if !visible || o.outerBox == nil || o.parentOverlay == nil { | |||
| 256 | + | return | |||
| 257 | + | } | |||
| 258 | + | ||||
| 259 | + | // Get omnibox position relative to parent overlay using graphene points | |||
| 260 | + | srcPoint := &graphene.Point{X: 0, Y: 0} | |||
| 261 | + | outPoint := &graphene.Point{} | |||
| 262 | + | ok := o.outerBox.ComputePoint(o.parentOverlay.GtkWidget(), srcPoint, outPoint) | |||
| 263 | + | if !ok { | |||
| 264 | + | return | |||
| 265 | + | } | |||
| 266 | + | ||||
| 267 | + | boxX := float64(outPoint.X) | |||
| 268 | + | boxY := float64(outPoint.Y) | |||
| 269 | + | ||||
| 270 | + | // Get omnibox dimensions | |||
| 271 | + | boxWidth := float64(o.outerBox.GetAllocatedWidth()) | |||
| 272 | + | boxHeight := float64(o.outerBox.GetAllocatedHeight()) | |||
| 273 | + | ||||
| 274 | + | // Check if click is inside omnibox bounds | |||
| 275 | + | isInside := clickX >= boxX && clickX <= boxX+boxWidth && | |||
| 276 | + | clickY >= boxY && clickY <= boxY+boxHeight | |||
| 277 | + | ||||
| 278 | + | if !isInside { | |||
| 279 | + | logging.FromContext(o.ctx).Debug(). | |||
| 280 | + | Float64("clickX", clickX). | |||
| 281 | + | Float64("clickY", clickY). | |||
| 282 | + | Float64("boxX", boxX). | |||
| 283 | + | Float64("boxY", boxY). | |||
| 284 | + | Float64("boxWidth", boxWidth). | |||
| 285 | + | Float64("boxHeight", boxHeight). | |||
| 286 | + | Msg("click outside omnibox, hiding") | |||
| 287 | + | o.Hide(o.ctx) | |||
| 288 | + | } | |||
| 205 | } | 289 | } | ||
| 206 | 290 | ||||
| 207 | // WidgetAsLayout returns the omnibox's outer widget as a layout.Widget. | 291 | // WidgetAsLayout returns the omnibox's outer widget as a layout.Widget. | ||
| @@ -2098,6 +2182,8 @@ func (o *Omnibox) Destroy() { | |||||
| 2098 | 2182 | ||||
| 2099 | o.parentOverlay = nil | 2183 | o.parentOverlay = nil | ||
| 2100 | o.retainedCallbacks = nil | 2184 | o.retainedCallbacks = nil | ||
| 2185 | + | o.clickOutsideController = nil | |||
| 2186 | + | o.clickOutsideCb = nil | |||
| 2101 | 2187 | ||||
| 2102 | if o.outerBox != nil { | 2188 | if o.outerBox != nil { | ||
| 2103 | o.outerBox.Unparent() | 2189 | o.outerBox.Unparent() | ||