v0.28.0 3cb3770

keyboard.go RawHistoryFolder

1package input
2
3// Manual Testing: Dead Key / Compose Support
4//
5// To verify native dead key support works correctly:
6//
7//  1. Set keyboard layout to "US International with dead keys"
8//  2. Open dumber, navigate to a page with a text input (e.g. Google search)
9//  3. Click into the text input field
10//  4. Type: ' then e -- should produce e with acute accent
11//  5. Type: " then u -- should produce u with diaeresis
12//  6. Type: ` then a -- should produce a with grave accent
13//  7. Type: ~ then n -- should produce n with tilde
14//  8. Verify Ctrl+L still opens omnibox
15//  9. Verify Alt+1-9 still switches tabs
16//  10. Verify modal modes (Ctrl+T for tab mode) still work
17//  11. In omnibox, verify long-press accent picker still works (hold 'e' for 400ms)
18//  12. Verify Escape closes omnibox / exits modal modes
19//  13. Verify session manager overlay captures all keyboard input
20
21import (
22	"context"
23	"sync"
24	"time"
25
26	"github.com/bnema/dumber/internal/domain/entity"
27	"github.com/bnema/dumber/internal/logging"
28	"github.com/bnema/puregotk/v4/gdk"
29	"github.com/bnema/puregotk/v4/glib"
30	"github.com/bnema/puregotk/v4/gtk"
31	"github.com/rs/zerolog"
32)
33
34// ActionHandler is called when a keyboard action is triggered.
35// It receives the context and the action to perform.
36// Return an error if the action fails.
37type ActionHandler func(ctx context.Context, action Action) error
38
39// AccentHandler handles long-press accent detection.
40// Called for character keys that may have accent variants.
41type AccentHandler interface {
42	// OnKeyPressed is called when a key is pressed.
43	// Returns true if the accent handler is handling this key (picker visible or will show).
44	OnKeyPressed(ctx context.Context, char rune, shiftHeld bool) bool
45	// OnKeyReleased is called when a key is released.
46	OnKeyReleased(ctx context.Context, char rune)
47	// IsPickerVisible returns true if the accent picker is currently visible.
48	IsPickerVisible() bool
49	// Cancel cancels any pending long-press detection.
50	Cancel(ctx context.Context)
51}
52
53// KeyboardHandler processes keyboard events and dispatches actions.
54// It manages modal input modes and routes key events to the appropriate handlers.
55type KeyboardHandler struct {
56	shortcuts *ShortcutSet
57	modal     *ModalState
58	workspace *entity.WorkspaceConfig
59	session   *entity.SessionConfig
60
61	// Action handler callback
62	onAction ActionHandler
63	// Optional routing callback that determines how a key should be handled.
64	// Returns RouteHandleShortcuts (default), RoutePassToWidget (let focused
65	// widget handle it), or RouteAccentDetection (long-press accent for GTK entries).
66	routeKey func(KeyContext) KeyRoute
67	// Optional accent handler for dead keys support
68	accentHandler AccentHandler
69	// Optional escape hook for app-level overlays
70	onEscape func(ctx context.Context) bool
71
72	// GTK controller (nil until attached)
73	controller *gtk.EventControllerKey
74
75	// Callback retention: must stay reachable by Go GC.
76	keyPressedCb  func(gtk.EventControllerKey, uint, uint, gdk.ModifierType) bool
77	keyReleasedCb func(gtk.EventControllerKey, uint, uint, gdk.ModifierType)
78
79	ctx context.Context
80	mu  sync.RWMutex
81}
82
83// NewKeyboardHandler creates a new keyboard handler.
84func NewKeyboardHandler(ctx context.Context, workspace *entity.WorkspaceConfig, session *entity.SessionConfig) *KeyboardHandler {
85	log := logging.FromContext(ctx)
86
87	log.Debug().Msg("creating keyboard handler")
88
89	h := &KeyboardHandler{
90		shortcuts: NewShortcutSet(ctx, workspace, session),
91		modal:     NewModalState(ctx),
92		workspace: workspace,
93		session:   session,
94		ctx:       ctx,
95	}
96
97	return h
98}
99
100// SetOnAction sets the callback for when actions are triggered.
101func (h *KeyboardHandler) SetOnAction(fn ActionHandler) {
102	h.mu.Lock()
103	defer h.mu.Unlock()
104	h.onAction = fn
105}
106
107// ReloadShortcuts rebuilds the shortcut set from new config values.
108// This enables hot-reloading of keybindings without restarting.
109func (h *KeyboardHandler) ReloadShortcuts(ctx context.Context, workspace *entity.WorkspaceConfig, session *entity.SessionConfig) {
110	h.mu.Lock()
111	defer h.mu.Unlock()
112
113	log := logging.FromContext(ctx)
114	log.Debug().Msg("reloading shortcuts from config")
115
116	h.shortcuts = NewShortcutSet(ctx, workspace, session)
117	h.workspace = workspace
118	h.session = session
119}
120
121// SetOnModeChange sets the callback for mode changes (for UI updates).
122func (h *KeyboardHandler) SetOnModeChange(fn func(from, to Mode)) {
123	h.modal.SetOnModeChange(func(from, to Mode) {
124		// Switch controller phase: capture during modal, bubble for normal.
125		// This ensures plain modal keys (s, h, etc.) reach the handler
126		// before WebView, while normal typing still goes through WebKit IM.
127		if to == ModeNormal {
128			h.setControllerPhase(gtk.PhaseBubbleValue)
129		} else if from == ModeNormal {
130			h.setControllerPhase(gtk.PhaseCaptureValue)
131		}
132		// Forward to app-level callback
133		if fn != nil {
134			fn(from, to)
135		}
136	})
137}
138
139// setControllerPhase changes the propagation phase of the key controller.
140// Capture phase during modal modes so plain keys reach the handler before WebView.
141// Bubble phase in normal mode for WebKit IM/dead-key support.
142//
143// This is called synchronously from onModeChange so the phase switch takes
144// effect before the next key event. The caller must ensure GTK thread safety;
145// modal timeouts dispatch to the GTK main thread via glib.IdleAdd before
146// calling ExitMode, so the onModeChange callback always runs on the GTK thread.
147func (h *KeyboardHandler) setControllerPhase(phase gtk.PropagationPhase) {
148	if h.controller == nil {
149		return
150	}
151	h.controller.SetPropagationPhase(phase)
152}
153
154// SetRouteKey sets the callback that determines how each key event should be routed.
155// The callback receives key context and returns a KeyRoute indicating whether the
156// key should be handled by the shortcut system, passed to the focused widget
157// (for IM/compose support), or routed through accent detection.
158//
159// If not set, all keys are routed through the shortcut system (RouteHandleShortcuts).
160func (h *KeyboardHandler) SetRouteKey(fn func(KeyContext) KeyRoute) {
161	h.mu.Lock()
162	defer h.mu.Unlock()
163	h.routeKey = fn
164}
165
166// SetAccentHandler sets the handler for long-press accent detection.
167func (h *KeyboardHandler) SetAccentHandler(handler AccentHandler) {
168	h.mu.Lock()
169	defer h.mu.Unlock()
170	h.accentHandler = handler
171}
172
173// SetOnEscape sets an optional callback invoked for plain Escape in normal mode.
174// Return true to consume the key and stop further handling.
175func (h *KeyboardHandler) SetOnEscape(fn func(ctx context.Context) bool) {
176	h.mu.Lock()
177	defer h.mu.Unlock()
178	h.onEscape = fn
179}
180
181// Mode returns the current input mode.
182func (h *KeyboardHandler) Mode() Mode {
183	return h.modal.Mode()
184}
185
186// AttachTo attaches the keyboard handler to a GTK window.
187// The handler runs in the bubble phase so that WebKit's IM pipeline (including
188// dead-key / compose sequences) processes key events first. App-level shortcuts
189// are still intercepted because WebKit only consumes events it recognizes as
190// text input; unhandled events bubble up to this controller.
191func (h *KeyboardHandler) AttachTo(window *gtk.ApplicationWindow) {
192	log := logging.FromContext(h.ctx)
193
194	if window == nil {
195		log.Error().Msg("cannot attach keyboard handler to nil window")
196		return
197	}
198
199	h.controller = gtk.NewEventControllerKey()
200	if h.controller == nil {
201		log.Error().Msg("failed to create event controller key")
202		return
203	}
204
205	// Bubble phase: let WebKit IM handle key events first (dead keys, compose
206	// sequences, input methods). Events not consumed by WebKit bubble up here
207	// so app shortcuts still work.
208	h.controller.SetPropagationPhase(gtk.PhaseBubbleValue)
209
210	// Wire GTK main thread scheduler for modal timeouts. Timer goroutines
211	// must dispatch ExitMode to the GTK thread because onModeChange may
212	// call setControllerPhase (a GTK operation).
213	h.modal.SetMainThreadScheduler(func(fn func()) {
214		cb := glib.SourceFunc(func(_ uintptr) bool {
215			fn()
216			return false
217		})
218		glib.IdleAdd(&cb, 0)
219	})
220
221	// Connect key pressed handler (retain callback to prevent GC).
222	// The callback receives: keyval (translated key), keycode (hardware key position), state (modifiers)
223	h.keyPressedCb = func(_ gtk.EventControllerKey, keyval uint, keycode uint, state gdk.ModifierType) bool {
224		return h.handleKeyPress(keyval, keycode, state)
225	}
226	h.controller.ConnectKeyPressed(&h.keyPressedCb)
227
228	// Connect key released handler for accent detection.
229	h.keyReleasedCb = func(_ gtk.EventControllerKey, keyval uint, _ uint, _ gdk.ModifierType) {
230		h.handleKeyRelease(keyval)
231	}
232	h.controller.ConnectKeyReleased(&h.keyReleasedCb)
233
234	// Add controller to window
235	window.AddController(&h.controller.EventController)
236
237	log.Debug().Msg("keyboard handler attached to window")
238}
239
240// Detach removes the keyboard handler.
241// Note: GTK handles cleanup when the widget is destroyed,
242// but we clear our reference here.
243func (h *KeyboardHandler) Detach() {
244	h.controller = nil
245	h.keyPressedCb = nil
246	h.keyReleasedCb = nil
247}
248
249// handleKeyPress processes a key press event.
250// Returns true if the event was handled and should not propagate further.
251func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) bool {
252	log := logging.FromContext(h.ctx)
253
254	h.mu.RLock()
255	routeKey := h.routeKey
256	accentHandler := h.accentHandler
257	onEscape := h.onEscape
258	h.mu.RUnlock()
259
260	// Accent picker takes absolute priority when visible -- it has its own key controller
261	if accentHandler != nil && accentHandler.IsPickerVisible() {
262		return false
263	}
264
265	modifiers := Modifier(state) & modifierMask
266
267	// Escape in normal mode: check app-level escape hook first
268	if h.modal.Mode() == ModeNormal && keyval == uint(gdk.KEY_Escape) && modifiers == 0 {
269		if onEscape != nil && onEscape(h.ctx) {
270			return true
271		}
272	}
273
274	// Determine routing for this key event
275	route := RouteHandleShortcuts // default: process through shortcut system
276	if routeKey != nil && h.modal.Mode() == ModeNormal {
277		route = routeKey(KeyContext{
278			Keyval:    keyval,
279			Keycode:   keycode,
280			Modifiers: modifiers,
281		})
282	}
283
284	switch route {
285	case RoutePassToWidget:
286		// Let the focused widget handle this key directly (WebView IM, etc.)
287		// No accent detection here -- WebView has its own JS-based accent detection.
288		log.Trace().Uint("keyval", keyval).Msg("routing key to focused widget")
289		return false
290
291	case RouteAccentDetection:
292		// Try long-press accent detection for GTK Entry widgets (omnibox, find bar).
293		// If the accent handler doesn't consume the key, pass it to the widget.
294		if h.tryAccentDetection(accentHandler, keyval, modifiers) {
295			return true
296		}
297		log.Trace().Uint("keyval", keyval).Msg("accent detection declined, routing to widget")
298		return false
299
300	case RouteHandleShortcuts:
301		// Fall through to shortcut processing below
302	}
303
304	// --- Shortcut processing path ---
305
306	// Normalize uppercase letters for consistent binding lookup
307	keyval = normalizeKeyval(keyval)
308
309	binding := KeyBinding{Keyval: keyval, Modifiers: modifiers}
310	mode := h.modal.Mode()
311	action, found := h.lookupAction(log, binding, mode, modifiers, keycode)
312
313	if !found {
314		return mode != ModeNormal // Consume unrecognized keys in modal mode
315	}
316
317	return h.dispatchAction(action, mode)
318}
319
320// tryAccentDetection starts long-press detection for accent-eligible keys.
321// It is used only for the RouteAccentDetection route (GTK Entry widgets such as
322// the omnibox and find bar). RoutePassToWidget does not call tryAccentDetection;
323// WebView long-press accent behavior is handled entirely by the injected JS bridge
324// (accentDetectionScript) and the Go-side accent key detection script, not here.
325// Returns true if the key should be suppressed (i.e. RouteAccentDetection consumed it).
326func (h *KeyboardHandler) tryAccentDetection(accentHandler AccentHandler, keyval uint, modifiers Modifier) bool {
327	if accentHandler == nil {
328		return false
329	}
330	// Only consider keys without Ctrl/Alt modifiers (Shift is OK for uppercase)
331	if modifiers != 0 && modifiers != ModShift {
332		return false
333	}
334	if char := KeyvalToRune(keyval); char != 0 {
335		return accentHandler.OnKeyPressed(h.ctx, char, modifiers == ModShift)
336	}
337	return false
338}
339
340// normalizeKeyval converts uppercase A-Z to lowercase for consistent binding lookup.
341func normalizeKeyval(keyval uint) uint {
342	if keyval >= uint('A') && keyval <= uint('Z') {
343		return keyval + (uint('a') - uint('A'))
344	}
345	return keyval
346}
347
348// dispatchAction dispatches the action and handles mode-related logic.
349func (h *KeyboardHandler) dispatchAction(action Action, mode Mode) bool {
350	if h.handleModeAction(action) {
351		return true
352	}
353
354	h.mu.RLock()
355	handler := h.onAction
356	h.mu.RUnlock()
357
358	if handler != nil {
359		if err := handler(h.ctx, action); err != nil {
360			log := logging.FromContext(h.ctx)
361			log.Error().Err(err).Str("action", string(action)).Msg("action handler error")
362		}
363	}
364
365	if mode == ModeResize && isResizeAction(action) {
366		h.modal.ResetTimeout(h.ctx)
367	}
368
369	if mode != ModeNormal && ShouldAutoExitMode(action) {
370		h.modal.ExitMode(h.ctx)
371	}
372
373	return true // Consumed the key
374}
375
376func (h *KeyboardHandler) lookupAction(
377	log *zerolog.Logger,
378	binding KeyBinding,
379	mode Mode,
380	modifiers Modifier,
381	keycode uint,
382) (Action, bool) {
383	action, found := h.shortcuts.Lookup(binding, mode)
384
385	// Fallback: check hardware keycode for Alt+number shortcuts on non-QWERTY layouts.
386	if !found && mode == ModeNormal && modifiers == ModAlt {
387		if keycodeAction, ok := KeycodeToTabAction[keycode]; ok {
388			action = keycodeAction
389			found = true
390			if log != nil {
391				log.Debug().
392					Uint("keycode", keycode).
393					Str("action", string(action)).
394					Msg("tab switch via hardware keycode fallback (non-QWERTY layout)")
395			}
396		}
397	}
398
399	if !found && mode == ModeNormal && (modifiers == ModAlt || modifiers == (ModAlt|ModShift)) {
400		if bracketActions, ok := KeycodeToBracketAction[keycode]; ok {
401			if modifiers == (ModAlt | ModShift) {
402				action = bracketActions.WithShift
403			} else {
404				action = bracketActions.NoShift
405			}
406			found = action != ""
407			if found && log != nil {
408				log.Debug().
409					Uint("keycode", keycode).
410					Str("action", string(action)).
411					Msg("consume/expel via hardware keycode fallback (non-QWERTY layout)")
412			}
413		}
414	}
415
416	return action, found
417}
418
419func isResizeAction(action Action) bool {
420	switch action {
421	case ActionResizeIncreaseLeft,
422		ActionResizeIncreaseRight,
423		ActionResizeIncreaseUp,
424		ActionResizeIncreaseDown,
425		ActionResizeDecreaseLeft,
426		ActionResizeDecreaseRight,
427		ActionResizeDecreaseUp,
428		ActionResizeDecreaseDown,
429		ActionResizeIncrease,
430		ActionResizeDecrease:
431		return true
432	default:
433		return false
434	}
435}
436
437func (h *KeyboardHandler) handleModeAction(action Action) bool {
438	h.mu.RLock()
439	workspace := h.workspace
440	session := h.session
441	h.mu.RUnlock()
442
443	switch action {
444	case ActionEnterTabMode:
445		var ms int
446		if workspace != nil {
447			ms = workspace.TabMode.TimeoutMilliseconds
448		}
449		h.modal.EnterTabMode(h.ctx, time.Duration(ms)*time.Millisecond)
450		return true
451	case ActionEnterPaneMode:
452		var ms int
453		if workspace != nil {
454			ms = workspace.PaneMode.TimeoutMilliseconds
455		}
456		h.modal.EnterPaneMode(h.ctx, time.Duration(ms)*time.Millisecond)
457		return true
458	case ActionEnterSessionMode:
459		var ms int
460		if session != nil {
461			ms = session.SessionMode.TimeoutMilliseconds
462		}
463		h.modal.EnterSessionMode(h.ctx, time.Duration(ms)*time.Millisecond)
464		return true
465	case ActionEnterResizeMode:
466		if h.modal.Mode() == ModeResize {
467			h.modal.ExitMode(h.ctx)
468			return true
469		}
470		var ms int
471		if workspace != nil {
472			ms = workspace.ResizeMode.TimeoutMilliseconds
473		}
474		h.modal.EnterResizeMode(h.ctx, time.Duration(ms)*time.Millisecond)
475		return true
476	case ActionExitMode:
477		h.modal.ExitMode(h.ctx)
478		return true
479	default:
480		return false
481	}
482}
483
484// EnterTabMode programmatically enters tab mode.
485// Useful for testing or programmatic mode changes.
486func (h *KeyboardHandler) EnterTabMode() {
487	h.mu.RLock()
488	workspace := h.workspace
489	h.mu.RUnlock()
490	var ms int
491	if workspace != nil {
492		ms = workspace.TabMode.TimeoutMilliseconds
493	}
494	h.modal.EnterTabMode(h.ctx, time.Duration(ms)*time.Millisecond)
495}
496
497// EnterPaneMode programmatically enters pane mode.
498// Useful for testing or programmatic mode changes.
499func (h *KeyboardHandler) EnterPaneMode() {
500	h.mu.RLock()
501	workspace := h.workspace
502	h.mu.RUnlock()
503	var ms int
504	if workspace != nil {
505		ms = workspace.PaneMode.TimeoutMilliseconds
506	}
507	h.modal.EnterPaneMode(h.ctx, time.Duration(ms)*time.Millisecond)
508}
509
510// EnterSessionMode programmatically enters session mode.
511// Useful for testing or programmatic mode changes.
512func (h *KeyboardHandler) EnterSessionMode() {
513	h.mu.RLock()
514	session := h.session
515	h.mu.RUnlock()
516	var ms int
517	if session != nil {
518		ms = session.SessionMode.TimeoutMilliseconds
519	}
520	h.modal.EnterSessionMode(h.ctx, time.Duration(ms)*time.Millisecond)
521}
522
523// ExitMode programmatically exits modal mode.
524// Useful for testing or programmatic mode changes.
525func (h *KeyboardHandler) ExitMode() {
526	h.modal.ExitMode(h.ctx)
527}
528
529// DispatchAction processes an action externally triggered (e.g., by a global
530// shortcut). Mode-enter actions update modal state; other actions are forwarded
531// to the registered action handler.
532func (h *KeyboardHandler) DispatchAction(action Action) {
533	mode := h.modal.Mode()
534	h.dispatchAction(action, mode)
535}
536
537// handleKeyRelease processes a key release event for accent detection.
538func (h *KeyboardHandler) handleKeyRelease(keyval uint) {
539	h.mu.RLock()
540	accentHandler := h.accentHandler
541	h.mu.RUnlock()
542
543	if accentHandler == nil {
544		return
545	}
546
547	// Convert keyval to rune for the accent handler
548	if char := KeyvalToRune(keyval); char != 0 {
549		accentHandler.OnKeyReleased(h.ctx, char)
550	}
551}
552
553// KeyvalToRune converts a GTK keyval to a lowercase rune.
554// Returns 0 if the keyval is not a printable character with potential accents.
555func KeyvalToRune(keyval uint) rune {
556	// Handle lowercase a-z
557	if keyval >= uint('a') && keyval <= uint('z') {
558		return rune(keyval)
559	}
560	// Handle uppercase A-Z (convert to lowercase for accent lookup)
561	if keyval >= uint('A') && keyval <= uint('Z') {
562		return rune(keyval + ('a' - 'A'))
563	}
564	return 0
565}
566
567// KeyRoute determines how a key event should be routed.
568type KeyRoute int
569
570const (
571	// RouteHandleShortcuts means the key should be processed by the shortcut system.
572	// This is the default route -- app-level shortcuts, modal mode keys, etc.
573	RouteHandleShortcuts KeyRoute = iota
574
575	// RoutePassToWidget means the key should propagate to the focused widget.
576	// Used when overlays (session manager, tab picker) are visible, or when a
577	// WebView should receive text input for native IM/compose/dead-key processing.
578	// Long-press accent detection still runs first for accessibility: users with
579	// motor disabilities may not be able to execute rapid compose sequences.
580	RoutePassToWidget
581
582	// RouteAccentDetection means the key should go through long-press accent
583	// detection, then pass to the focused widget. Used for GTK Entry widgets
584	// (omnibox, find bar) and as a universal accessibility fallback.
585	RouteAccentDetection
586)
587
588// KeyContext provides key event information for routing decisions.
589type KeyContext struct {
590	Keyval    uint     // Translated key value
591	Keycode   uint     // Hardware keycode
592	Modifiers Modifier // Active modifiers (masked)
593}
594
595// IsShortcutModified returns true if the modifiers indicate a shortcut combination
596// (Ctrl or Alt pressed), as opposed to plain text input (no modifier or Shift-only).
597// Dead keys and compose sequences only use no-modifier or Shift, so this correctly
598// identifies keys that should be intercepted vs passed through to the IM pipeline.
599func IsShortcutModified(modifiers Modifier) bool {
600	return modifiers&(ModCtrl|ModAlt) != 0
601}
602
603// IsTextInputKey returns true if the keyval represents a text input key
604// (printable character or dead key) as opposed to a function/navigation key.
605// This is used to determine whether a key should be passed through to the
606// WebKit IM pipeline for compose/dead-key processing.
607func IsTextInputKey(keyval uint) bool {
608	// Dead keys used by compose sequences (US International, etc.)
609	// GDK dead key range: KEY_dead_grave (0xfe50) to KEY_dead_greek (0xfe8c)
610	if keyval >= 0xfe50 && keyval <= 0xfe8c {
611		return true
612	}
613
614	// Printable ASCII range (space through tilde)
615	if keyval >= 0x020 && keyval <= 0x07e {
616		return true
617	}
618	// Latin-1 supplement (non-breaking space through ÿ)
619	if keyval >= 0x0a0 && keyval <= 0x0ff {
620		return true
621	}
622	// Extended Latin and other Unicode characters mapped in GDK keyval space
623	if keyval >= 0x100 && keyval <= 0x20ff {
624		return true
625	}
626
627	return false
628}

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