Commitd617fc5eRecorded19 Jul 2026Repositorylantern

lantern: deliver Tab/Shift+Tab to the web content instead of GTK focus-nav

Message

GTK4 reserves Tab (focus-next) and Shift+Tab / ISOLeftTab (focus-prev) for widget focus navigation and consumes them BEFORE the embedded WebKitWebView's web content ever receives a keydown. A keyboard-first web app (Slate) therefore never sees Tab, and GTK moves focus right out of the view — e.g. Shift+Tab in a Slate terminal made the whole pane lose focus until you clicked back in. No page-side JS can fix this: the event does not reach the page at all (verified live — the page's keydown listener fires in Chrome but never in Lantern).

Fix: install a CAPTURE-phase GtkEventControllerKey on the window. on-key-pressed intercepts GDKKEYTab / GDKKEYISOLeftTab, forwards a synthetic keydown into the page (dispatched on the focused element / the app .frame, so the app's own keymap routes it — terminal backtab e[Z, editor indent, ...), and returns TRUE to consume the GTK event so focus stays in the view. The keyval alone distinguishes plain Tab from Shift+Tab (ISOLeftTab), so no GdkModifierType decode is needed. Every other key returns FALSE and flows to WebKit unchanged; on any error the guard returns FALSE, falling back to GTK's default rather than swallowing a keystroke.

v1 single-window: the callback reads main-view, set in create-window (matches the existing single-window callback shape). Confirmed working end-to-end in a Slate terminal (Claude) in a native Lantern window.

Changed
 lantern/src/lantern/backend/gtk.sgl | 66 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 66 insertions(+)
Diff
lantern/src/lantern/backend/gtk.sglmodified
@@ -46,6 +46,15 @@
46
;; GTK_STYLE_PROVIDER_PRIORITY_APPLICATION - app CSS beats the theme's window bg.
47
(define STYLE-PRIORITY-APPLICATION 600)
48
+49
;; GtkPropagationPhase: CAPTURE = 1 (top-down, before the target + before GTK's
+50
;; own Tab focus-navigation default fires).
+51
(define GTK-PHASE-CAPTURE 1)
+52
;; GDK keyvals for the focus-navigation keys GTK4 reserves. Shift+Tab arrives
+53
;; as ISO_Left_Tab (a distinct keyval), so the keyval alone tells plain Tab
+54
;; from Shift+Tab — no GdkModifierType decode needed.
+55
(define GDK-KEY-Tab #xff09)
+56
(define GDK-KEY-ISO-Left-Tab #xfe20)
+57
58
;; ---- lazily-bound FFI (all #f until ensure!) -------------------------
59
60
(define *ready* #f)
@@ -67,6 +76,10 @@
76
(define gtk-css-provider-load-from-string #f)
77
(define gtk-style-context-add-provider-for-display #f)
78
(define gdk-display-get-default #f)
+79
;; Key event controller (to intercept Tab/Shift+Tab before GTK's focus-nav).
+80
(define gtk-event-controller-key-new #f)
+81
(define gtk-event-controller-set-propagation-phase #f)
+82
(define gtk-widget-add-controller #f)
83
84
(define webkit-web-context-get-default #f)
85
(define webkit-web-context-get-security-manager #f)
@@ -107,6 +120,7 @@
120
(define message-cb #f)
121
(define policy-cb #f)
122
(define close-cb #f)
+123
(define keypress-cb #f)
124
125
;; ---- per-process backend state ---------------------------------------
126
;;
@@ -124,6 +138,9 @@
138
;; The display-wide CSS provider that clears the window background for
139
;; transparency. Installed once, rooted here for the process lifetime.
140
(define *transparency-provider* #f)
+141
;; The single window's web view, so the capture-phase key handler can forward
+142
;; Tab/Shift+Tab into the page (v1 is single-window; see the callback note).
+143
(define *main-view* #f)
144
145
(define (path-of request)
146
(let ((p (webkit-uri-scheme-request-get-path request)))
@@ -200,6 +217,39 @@
217
(set! *quit-requested* #t)
218
#f)))
219
+220
;; key-pressed on a CAPTURE-phase controller on the window (5-arg:
+221
;; controller keyval keycode state user-data -> gboolean).
+222
;;
+223
;; GTK4 reserves Tab (focus-next) and Shift+Tab / ISO_Left_Tab (focus-prev)
+224
;; for widget focus navigation and consumes them BEFORE the WebKitWebView's
+225
;; web content ever sees a keydown. A keyboard-first web app (Slate) therefore
+226
;; never receives Tab, and GTK moves focus right out of the view (the terminal
+227
;; "loses focus" on Shift+Tab until you click back in). No page-side JS can fix
+228
;; this — the event doesn't reach the page. So intercept the two focus-nav keys
+229
;; HERE, forward a synthetic keydown into the page (Slate's own keymap then
+230
;; routes it — terminal backtab \e[Z, editor indent, etc.), and return #t to
+231
;; consume the GTK event so focus stays in the view. Every other key returns #f
+232
;; and flows to WebKit normally. The keyval distinguishes plain Tab from
+233
;; Shift+Tab (ISO_Left_Tab), so no modifier decode is needed. On any error the
+234
;; guard returns #f — i.e. fall back to GTK's default (focus moves), never a
+235
;; swallowed keystroke.
+236
(define (on-key-pressed controller keyval keycode state user-data)
+237
(guarded "key-pressed" #f
+238
(lambda ()
+239
(if (and *main-view*
+240
(or (= keyval GDK-KEY-Tab) (= keyval GDK-KEY-ISO-Left-Tab)))
+241
(let ((shift (if (= keyval GDK-KEY-ISO-Left-Tab) "true" "false")))
+242
(webkit-web-view-eval-js *main-view*
+243
(string-append
+244
"(function(){var f=document.querySelector('.frame');if(!f)return;"
+245
"var a=document.activeElement;"
+246
"var t=(a&&f.contains(a))?a:f;"
+247
"t.dispatchEvent(new KeyboardEvent('keydown',{key:'Tab',code:'Tab',"
+248
"keyCode:9,which:9,shiftKey:" shift ",bubbles:true,cancelable:true}));})();")
+249
-1 NULL NULL NULL NULL NULL)
+250
#t)
+251
#f))))
+252
253
;; GdkRGBA = 4 floats in GTK4. set_background_color copies, so free at once.
254
(define (set-view-background view r g b a)
255
(let ((rgba (dict->c-struct (dict red: r green: g blue: b alpha: a) gdk-rgba-layout)))
@@ -257,6 +307,10 @@
307
(set! gtk-style-context-add-provider-for-display
308
(c-function libgtk "gtk_style_context_add_provider_for_display" (list ffi/pointer ffi/pointer ffi/uint) ffi/void))
309
(set! gdk-display-get-default (c-function libgtk "gdk_display_get_default" (list) ffi/pointer))
+310
(set! gtk-event-controller-key-new (c-function libgtk "gtk_event_controller_key_new" (list) ffi/pointer))
+311
(set! gtk-event-controller-set-propagation-phase
+312
(c-function libgtk "gtk_event_controller_set_propagation_phase" (list ffi/pointer ffi/int) ffi/void))
+313
(set! gtk-widget-add-controller (c-function libgtk "gtk_widget_add_controller" (list ffi/pointer ffi/pointer) ffi/void))
314
315
(set! webkit-web-context-get-default (c-function libwebkit "webkit_web_context_get_default" (list) ffi/pointer))
316
(set! webkit-web-context-get-security-manager
@@ -330,6 +384,8 @@
384
(set! message-cb (c-callback (list ffi/pointer ffi/pointer ffi/pointer) ffi/void on-script-message))
385
(set! policy-cb (c-callback (list ffi/pointer ffi/pointer ffi/int ffi/pointer) ffi/bool on-decide-policy))
386
(set! close-cb (c-callback (list ffi/pointer ffi/pointer) ffi/bool on-close-request))
+387
;; GtkEventControllerKey::key-pressed = (controller keyval keycode state user-data) -> gboolean
+388
(set! keypress-cb (c-callback (list ffi/pointer ffi/uint ffi/uint ffi/uint ffi/pointer) ffi/bool on-key-pressed))
389
390
(set! *ready* #t))))
391
@@ -371,6 +427,16 @@
427
;; Origin gating: every navigation attempt passes through decide-policy.
428
(signal-connect! view "decide-policy" policy-cb)
429
(signal-connect! win "close-request" close-cb)
+430
;; Deliver Tab/Shift+Tab to the web content instead of letting GTK4
+431
;; consume them for widget focus navigation (which yanks focus out of
+432
;; the view). A capture-phase key controller on the window fires before
+433
;; GTK's focus-nav; on-key-pressed forwards those keys into the page and
+434
;; consumes the GTK event. (v1 single-window: the callback reads *main-view*.)
+435
(set! *main-view* view)
+436
(let ((kc (gobject-borrow (gtk-event-controller-key-new))))
+437
(gtk-event-controller-set-propagation-phase kc GTK-PHASE-CAPTURE)
+438
(signal-connect! kc "key-pressed" keypress-cb)
+439
(gtk-widget-add-controller win kc))
440
(dict win: win view: view ucm: ucm)))
441
442
(define (load-uri window uri)