Merge pull request #172 from bnema/feature/esc-hide-floating-pane
fix: hide visible floating pane on Escape
3 files · +34 -0
M internal/ui/app.go +7 -0
| @@ -625,6 +625,9 @@ func (a *App) initKeyboardHandler(ctx context.Context) { | |||
| 625 | 625 | } | |
| 626 | 626 | return a.kbDispatcher.Dispatch(ctx, action) | |
| 627 | 627 | }) | |
| 628 | + | a.keyboardHandler.SetOnEscape(func(ctx context.Context) bool { | |
| 629 | + | return a.handleGlobalEscape(ctx) | |
| 630 | + | }) | |
| 628 | 631 | a.keyboardHandler.SetOnModeChange(func(from, to input.Mode) { | |
| 629 | 632 | a.handleModeChange(ctx, from, to) | |
| 630 | 633 | }) | |
| @@ -2730,6 +2733,10 @@ func (a *App) closeActiveFloatingPane(ctx context.Context) bool { | |||
| 2730 | 2733 | return true | |
| 2731 | 2734 | } | |
| 2732 | 2735 | ||
| 2736 | + | func (a *App) handleGlobalEscape(ctx context.Context) bool { | |
| 2737 | + | return a.closeActiveFloatingPane(ctx) | |
| 2738 | + | } | |
| 2739 | + | ||
| 2733 | 2740 | func (a *App) closeAndReleaseActiveFloatingPane(ctx context.Context) bool { | |
| 2734 | 2741 | key, session, ok := a.activeFloatingSessionEntry() | |
| 2735 | 2742 | if !ok { | |
| @@ -625,6 +625,9 @@ func (a *App) initKeyboardHandler(ctx context.Context) { | |||||
| 625 | } | 625 | } | ||
| 626 | return a.kbDispatcher.Dispatch(ctx, action) | 626 | return a.kbDispatcher.Dispatch(ctx, action) | ||
| 627 | }) | 627 | }) | ||
| 628 | + | a.keyboardHandler.SetOnEscape(func(ctx context.Context) bool { | |||
| 629 | + | return a.handleGlobalEscape(ctx) | |||
| 630 | + | }) | |||
| 628 | a.keyboardHandler.SetOnModeChange(func(from, to input.Mode) { | 631 | a.keyboardHandler.SetOnModeChange(func(from, to input.Mode) { | ||
| 629 | a.handleModeChange(ctx, from, to) | 632 | a.handleModeChange(ctx, from, to) | ||
| 630 | }) | 633 | }) | ||
| @@ -2730,6 +2733,10 @@ func (a *App) closeActiveFloatingPane(ctx context.Context) bool { | |||||
| 2730 | return true | 2733 | return true | ||
| 2731 | } | 2734 | } | ||
| 2732 | 2735 | ||||
| 2736 | + | func (a *App) handleGlobalEscape(ctx context.Context) bool { | |||
| 2737 | + | return a.closeActiveFloatingPane(ctx) | |||
| 2738 | + | } | |||
| 2739 | + | ||||
| 2733 | func (a *App) closeAndReleaseActiveFloatingPane(ctx context.Context) bool { | 2740 | func (a *App) closeAndReleaseActiveFloatingPane(ctx context.Context) bool { | ||
| 2734 | key, session, ok := a.activeFloatingSessionEntry() | 2741 | key, session, ok := a.activeFloatingSessionEntry() | ||
| 2735 | if !ok { | 2742 | if !ok { | ||
M internal/ui/floating_pane_test.go +11 -0
| @@ -312,6 +312,17 @@ func TestFloatingPane_CloseActiveFloatingSession(t *testing.T) { | |||
| 312 | 312 | assert.False(t, handled) | |
| 313 | 313 | } | |
| 314 | 314 | ||
| 315 | + | func TestFloatingPane_HandleGlobalEscape_HidesVisibleFloatingPane(t *testing.T) { | |
| 316 | + | app, _, session := newFloatingPaneTestApp(t) | |
| 317 | + | ||
| 318 | + | require.NoError(t, app.ToggleFloatingPane(context.Background())) | |
| 319 | + | require.True(t, session.pane.IsVisible()) | |
| 320 | + | ||
| 321 | + | handled := app.handleGlobalEscape(context.Background()) | |
| 322 | + | assert.True(t, handled) | |
| 323 | + | assert.False(t, session.pane.IsVisible()) | |
| 324 | + | } | |
| 325 | + | ||
| 315 | 326 | func TestFloatingPane_OpenFloatingPaneSession_OptionalURL(t *testing.T) { | |
| 316 | 327 | app, _, session := newFloatingPaneTestApp(t) | |
| 317 | 328 | ||
| @@ -312,6 +312,17 @@ func TestFloatingPane_CloseActiveFloatingSession(t *testing.T) { | |||||
| 312 | assert.False(t, handled) | 312 | assert.False(t, handled) | ||
| 313 | } | 313 | } | ||
| 314 | 314 | ||||
| 315 | + | func TestFloatingPane_HandleGlobalEscape_HidesVisibleFloatingPane(t *testing.T) { | |||
| 316 | + | app, _, session := newFloatingPaneTestApp(t) | |||
| 317 | + | ||||
| 318 | + | require.NoError(t, app.ToggleFloatingPane(context.Background())) | |||
| 319 | + | require.True(t, session.pane.IsVisible()) | |||
| 320 | + | ||||
| 321 | + | handled := app.handleGlobalEscape(context.Background()) | |||
| 322 | + | assert.True(t, handled) | |||
| 323 | + | assert.False(t, session.pane.IsVisible()) | |||
| 324 | + | } | |||
| 325 | + | ||||
| 315 | func TestFloatingPane_OpenFloatingPaneSession_OptionalURL(t *testing.T) { | 326 | func TestFloatingPane_OpenFloatingPaneSession_OptionalURL(t *testing.T) { | ||
| 316 | app, _, session := newFloatingPaneTestApp(t) | 327 | app, _, session := newFloatingPaneTestApp(t) | ||
| 317 | 328 | ||||
M internal/ui/input/keyboard.go +16 -0
| @@ -44,6 +44,8 @@ type KeyboardHandler struct { | |||
| 44 | 44 | shouldBypass func(modifiers Modifier) bool | |
| 45 | 45 | // Optional accent handler for dead keys support | |
| 46 | 46 | accentHandler AccentHandler | |
| 47 | + | // Optional escape hook for app-level overlays | |
| 48 | + | onEscape func(ctx context.Context) bool | |
| 47 | 49 | ||
| 48 | 50 | // GTK controller (nil until attached) | |
| 49 | 51 | controller *gtk.EventControllerKey | |
| @@ -112,6 +114,14 @@ func (h *KeyboardHandler) SetAccentHandler(handler AccentHandler) { | |||
| 112 | 114 | h.accentHandler = handler | |
| 113 | 115 | } | |
| 114 | 116 | ||
| 117 | + | // SetOnEscape sets an optional callback invoked for plain Escape in normal mode. | |
| 118 | + | // Return true to consume the key and stop further handling. | |
| 119 | + | func (h *KeyboardHandler) SetOnEscape(fn func(ctx context.Context) bool) { | |
| 120 | + | h.mu.Lock() | |
| 121 | + | defer h.mu.Unlock() | |
| 122 | + | h.onEscape = fn | |
| 123 | + | } | |
| 124 | + | ||
| 115 | 125 | // Mode returns the current input mode. | |
| 116 | 126 | func (h *KeyboardHandler) Mode() Mode { | |
| 117 | 127 | return h.modal.Mode() | |
| @@ -176,6 +186,7 @@ func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.Modifie | |||
| 176 | 186 | h.mu.RLock() | |
| 177 | 187 | shouldBypass := h.shouldBypass | |
| 178 | 188 | accentHandler := h.accentHandler | |
| 189 | + | onEscape := h.onEscape | |
| 179 | 190 | h.mu.RUnlock() | |
| 180 | 191 | ||
| 181 | 192 | // Check if accent picker is visible - it takes priority | |
| @@ -184,6 +195,11 @@ func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.Modifie | |||
| 184 | 195 | } | |
| 185 | 196 | ||
| 186 | 197 | modifiers := Modifier(state) & modifierMask | |
| 198 | + | if h.modal.Mode() == ModeNormal && keyval == uint(gdk.KEY_Escape) && modifiers == 0 { | |
| 199 | + | if onEscape != nil && onEscape(h.ctx) { | |
| 200 | + | return true | |
| 201 | + | } | |
| 202 | + | } | |
| 187 | 203 | ||
| 188 | 204 | // Check accent detection first - works in both normal and bypass mode | |
| 189 | 205 | // This enables accent picker for omnibox (GTK SearchEntry) as well as WebView | |
| @@ -44,6 +44,8 @@ type KeyboardHandler struct { | |||||
| 44 | shouldBypass func(modifiers Modifier) bool | 44 | shouldBypass func(modifiers Modifier) bool | ||
| 45 | // Optional accent handler for dead keys support | 45 | // Optional accent handler for dead keys support | ||
| 46 | accentHandler AccentHandler | 46 | accentHandler AccentHandler | ||
| 47 | + | // Optional escape hook for app-level overlays | |||
| 48 | + | onEscape func(ctx context.Context) bool | |||
| 47 | 49 | ||||
| 48 | // GTK controller (nil until attached) | 50 | // GTK controller (nil until attached) | ||
| 49 | controller *gtk.EventControllerKey | 51 | controller *gtk.EventControllerKey | ||
| @@ -112,6 +114,14 @@ func (h *KeyboardHandler) SetAccentHandler(handler AccentHandler) { | |||||
| 112 | h.accentHandler = handler | 114 | h.accentHandler = handler | ||
| 113 | } | 115 | } | ||
| 114 | 116 | ||||
| 117 | + | // SetOnEscape sets an optional callback invoked for plain Escape in normal mode. | |||
| 118 | + | // Return true to consume the key and stop further handling. | |||
| 119 | + | func (h *KeyboardHandler) SetOnEscape(fn func(ctx context.Context) bool) { | |||
| 120 | + | h.mu.Lock() | |||
| 121 | + | defer h.mu.Unlock() | |||
| 122 | + | h.onEscape = fn | |||
| 123 | + | } | |||
| 124 | + | ||||
| 115 | // Mode returns the current input mode. | 125 | // Mode returns the current input mode. | ||
| 116 | func (h *KeyboardHandler) Mode() Mode { | 126 | func (h *KeyboardHandler) Mode() Mode { | ||
| 117 | return h.modal.Mode() | 127 | return h.modal.Mode() | ||
| @@ -176,6 +186,7 @@ func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.Modifie | |||||
| 176 | h.mu.RLock() | 186 | h.mu.RLock() | ||
| 177 | shouldBypass := h.shouldBypass | 187 | shouldBypass := h.shouldBypass | ||
| 178 | accentHandler := h.accentHandler | 188 | accentHandler := h.accentHandler | ||
| 189 | + | onEscape := h.onEscape | |||
| 179 | h.mu.RUnlock() | 190 | h.mu.RUnlock() | ||
| 180 | 191 | ||||
| 181 | // Check if accent picker is visible - it takes priority | 192 | // Check if accent picker is visible - it takes priority | ||
| @@ -184,6 +195,11 @@ func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.Modifie | |||||
| 184 | } | 195 | } | ||
| 185 | 196 | ||||
| 186 | modifiers := Modifier(state) & modifierMask | 197 | modifiers := Modifier(state) & modifierMask | ||
| 198 | + | if h.modal.Mode() == ModeNormal && keyval == uint(gdk.KEY_Escape) && modifiers == 0 { | |||
| 199 | + | if onEscape != nil && onEscape(h.ctx) { | |||
| 200 | + | return true | |||
| 201 | + | } | |||
| 202 | + | } | |||
| 187 | 203 | ||||
| 188 | // Check accent detection first - works in both normal and bypass mode | 204 | // Check accent detection first - works in both normal and bypass mode | ||
| 189 | // This enables accent picker for omnibox (GTK SearchEntry) as well as WebView | 205 | // This enables accent picker for omnibox (GTK SearchEntry) as well as WebView | ||