diff --git a/README.md b/README.md index 75df1d7..aa5bc0d 100644 --- a/README.md +++ b/README.md @@ -185,17 +185,22 @@ switch back to browse mode when finished interacting. ## Pass command keys to an application -On Xorg, **Cthulhu+Z** toggles command bypass in either keyboard layout. -Cthulhu announces and displays **Cthulhu command keys off.** While bypass is -active, application shortcuts receive keys normally used for Cthulhu commands, -including the configured Cthulhu modifier keys. Automatic speech, key echo, -and Braille continue under their existing settings. Cthulhu+Z remains available -to restore commands and announces **Cthulhu command keys on.** +**Cthulhu+Z** toggles command bypass in either keyboard layout, on Xorg +and on Wayland sessions providing the shared accessibility keyboard +interface. Cthulhu announces and displays **Cthulhu command keys off.** +While bypass is active, application shortcuts receive keys normally used +for Cthulhu commands, including the configured Cthulhu modifier keys. +Automatic speech, key echo, and Braille continue under their existing +settings. Cthulhu+Z remains available to restore commands and announces +**Cthulhu command keys on.** Switching applications or locking the screen also restores commands. Moving between windows in the same application keeps bypass active. Bypass is temporary and does not change your saved preferences or browse/focus mode. Existing XTerm -handoff still takes precedence. Wayland command bypass is not yet supported. +handoff still takes precedence. On Wayland, reader-modifier chords other than +Cthulhu+Z are swallowed rather than replayed to the application because the +shared interface cannot synthesize keys; unmodified and browse keys pass +through normally. See [command bypass verification](docs/testing/command-bypass.md). diff --git a/docs/testing/browser-clickables.md b/docs/testing/browser-clickables.md index 32259ef..52dd088 100644 --- a/docs/testing/browser-clickables.md +++ b/docs/testing/browser-clickables.md @@ -53,3 +53,16 @@ buttons, Tab/Shift+Tab, and reading through a continuously updating page. The user's installed Brave session, physical input, audible speech, and physical Braille remain live acceptance checks. Human diff review and live testing precede submission. + +## Known follow-up: browse anchoring across Electron panels + +Observed 2026-11-11 in an Electron game with separate code and upgrades +panels: browse-mode button and form-field navigation starting from the +code panel reported no targets although the upgrades panel contains real +buttons, while native Tab reached those buttons immediately. After Tab +moved native focus into the upgrades panel, browse navigation found the +buttons as well. The browse cursor appears anchored to the document that +owned focus when browsing began and does not cross into the sibling panel +document on its own. Reproduce with a multi-panel Electron document whose +panels capture as separate documents, then check whether structural and +caret navigation should cross that boundary or re-anchor explicitly. diff --git a/docs/testing/command-bypass.md b/docs/testing/command-bypass.md index 4a87f3d..22ab5b2 100644 --- a/docs/testing/command-bypass.md +++ b/docs/testing/command-bypass.md @@ -1,7 +1,8 @@ # Command bypass -Cthulhu+Z temporarily passes reader command keys to the current application on -Xorg. It retains the bypass shortcut and keeps speech and Braille running. +Cthulhu+Z temporarily passes reader command keys to the current application +on Xorg and on the Wayland shared accessibility keyboard interface. It +retains the bypass shortcut and keeps speech and Braille running. Switching applications, leaving the foreground client, or locking restores commands. Same-process window changes and browser content changes preserve bypass. Accessible application identity is the fallback when native process @@ -11,18 +12,30 @@ The shared focus gate owns bypass state; command dispatch changes it and the accessibility event loop observes foreground/application departure. The X11 command owner applies requested routing and grabs after held keys release. Unbound modifier chords use existing replay, with Caps Lock's native action -included while bypass is active. OCR review ends on entry. Secure input and -XTerm handoff retain their existing precedence. Reader shutdown discards state. -Wayland reports bypass unavailable; its current backend has no replay path. +included while bypass is active. The Wayland owner instead re-registers an +empty grab set (retaining only the reader-modifier watch that carries the +toggle) after held keys release, so unmodified and browse keys reach the +application while watched keys still report monitoring and echo. The shared +interface cannot synthesize keys, so on Wayland reader-modifier chords +other than the retained shortcut are swallowed rather than replayed. OCR +review ends on entry. Secure input and XTerm handoff retain their existing +precedence. Reader shutdown discards state. + +The Wayland backend also implements internal UI sessions through the +interface's GrabKeyboard, so the Actions list, browser lists, and the emoji +menu work there. ShowActions captures its session at the chord, Escape +reports and dismisses, and secure-input transitions revoke the session. +Local-only approval input remains unavailable on Wayland because the +protocol cannot distinguish device-backed from synthesized keys. ## Automated checks Run affected Go packages and build the native listener test executable: ```sh -go test ./internal/input ./internal/app ./internal/platform/xorg -count=1 +go test ./internal/input ./internal/app ./internal/platform/xorg ./internal/platform/wayland -count=1 +dbus-run-session -- env CTHULHU_NATIVE_DBUS_TEST=1 go test ./internal/platform/wayland -count=1 go test -c -o /tmp/cthulhu-bypass-xorg.test ./internal/platform/xorg -CTHULHU_XRECORD_TEST=/tmp/cthulhu-bypass-xorg.test CTHULHU_NATIVE_LISTENER_ONLY=1 CTHULHU_NATIVE_X11_TESTS='^TestNativeCommandBypass$' python3 tests/native_x11.py ``` The native harness owns a private Xvfb display and D-Bus session. It checks both @@ -32,6 +45,9 @@ Caps Lock's actual lock state; and restoration of browse and global commands. Go tests check stale-command suppression, continued speech/Braille output, application departure without accessibility events, unknown foreground samples, same-application windows, and recovery when bindings are changed during bypass. +The Wayland fake-monitor tests check that bypass empties the grab set while +the retained toggle still dispatches, and that an internal UI session grabs +and releases the keyboard while routing keys only to the session. These checks do not establish installed-binary behavior, audible speech, physical keyboard input, or physical Braille output. @@ -59,6 +75,11 @@ physical keyboard input, or physical Braille output. bypass to remain active. If Caps Lock is a configured modifier, press and release it alone during bypass: expect normal lock behavior. Turn it off again before restoring reader commands. +7. On Wayland, repeat steps 2-6 with these differences: in step 2, + Cthulhu+T during bypass must not read the time and is swallowed rather + than reaching the terminal. Also verify Cthulhu+Shift+A opens the + Actions list, arrows and Enter work inside it, and Escape returns keys + to the application. Report the exact speech, Braille text, focused control, and application key behavior for any mismatch. Inspect ~/devel/cthulhu-go.log after an unexpected diff --git a/internal/platform/wayland/keyboard.go b/internal/platform/wayland/keyboard.go index 64156c0..3d8ce3a 100644 --- a/internal/platform/wayland/keyboard.go +++ b/internal/platform/wayland/keyboard.go @@ -43,6 +43,7 @@ type Keyboard struct { done chan struct{} wake chan struct{} updates chan keyboardUpdate + uiUpdates chan uiUpdate commands chan input.CommandEvent locks chan input.LockState failures chan error @@ -52,12 +53,17 @@ type Keyboard struct { secureGeneration atomic.Uint64 enabled atomic.Bool browse atomic.Bool + bypass atomic.Bool nativeButton atomic.Bool applied atomic.Bool browseApplied atomic.Bool + bypassApplied atomic.Bool active atomic.Bool learn atomic.Bool learnEcho atomic.Bool + uiSession atomic.Uint64 + uiSequence atomic.Uint64 + uiClose atomic.Uint64 overflow atomic.Uint64 intent input.IntentTracker } @@ -69,7 +75,7 @@ func New(modifiers []string, layout input.KeyboardLayout, logger *slog.Logger) * keyboard := &Keyboard{ logger: logger, configuration: keyboardConfiguration{layout: layout, modifiers: append([]string(nil), modifiers...)}, - wake: make(chan struct{}, 1), updates: make(chan keyboardUpdate), + wake: make(chan struct{}, 1), updates: make(chan keyboardUpdate), uiUpdates: make(chan uiUpdate), commands: make(chan input.CommandEvent, 32), locks: make(chan input.LockState, 16), failures: make(chan error, 1), events: make(chan input.Event, 64), } @@ -155,6 +161,16 @@ func (keyboard *Keyboard) SetBrowseNavigation(enabled bool) { keyboard.browse.Store(enabled) keyboard.notify() } + +// SetCommandBypass drops every key grab except the bypass shortcut after +// held keys release. Watched keys still report monitoring and echo; the +// shared interface has no key synthesis, so reader-modifier chords other +// than the retained shortcut are swallowed rather than replayed. +func (keyboard *Keyboard) SetCommandBypass(enabled bool) { + keyboard.bypass.Store(enabled) + keyboard.notify() +} +func (keyboard *Keyboard) CommandBypassEnabled() bool { return keyboard.bypassApplied.Load() } func (keyboard *Keyboard) SetNativeButtonFocus(focused bool) { keyboard.nativeButton.Store(focused) keyboard.notify() diff --git a/internal/platform/wayland/protocol.go b/internal/platform/wayland/protocol.go index da8aa70..98c03ba 100644 --- a/internal/platform/wayland/protocol.go +++ b/internal/platform/wayland/protocol.go @@ -56,6 +56,7 @@ type keyboardOwner struct { keyboard *Keyboard connection *dbus.Conn owner string + ctx context.Context configuration keyboardConfiguration policy keyboardPolicy bindings input.CommandBindings @@ -63,6 +64,7 @@ type keyboardOwner struct { held map[uint16]uint32 captured map[uint16]bool learning bool + uiSession uint64 started time.Time sequence uint32 generation uint64 @@ -80,7 +82,7 @@ func (owner *keyboardOwner) requested_policy() keyboardPolicy { owner.learning = false keyboard.learn.Store(false) } - return keyboardPolicy{enabled: keyboard.enabled.Load(), browse: keyboard.browse.Load(), button: keyboard.nativeButton.Load(), secure: keyboard.secure.Load(), learn: owner.learning} + return keyboardPolicy{enabled: keyboard.enabled.Load(), browse: keyboard.browse.Load(), button: keyboard.nativeButton.Load(), secure: keyboard.secure.Load(), learn: owner.learning, bypass: keyboard.bypass.Load(), ui: owner.uiSession != 0} } func (owner *keyboardOwner) retire_secure_generation() { @@ -96,6 +98,14 @@ func (owner *keyboardOwner) retire_secure_generation() { clear(owner.captured) owner.learning = false owner.keyboard.learn.Store(false) + if owner.uiSession != 0 { + session := owner.uiSession + owner.dismiss_internal_ui() + select { + case owner.keyboard.commands <- input.CommandEvent{Command: input.InternalUIDismiss, UISession: session}: + default: + } + } owner.keyboard.active.Store(false) } @@ -104,9 +114,10 @@ func (owner *keyboardOwner) configure(ctx context.Context, configuration keyboar if err := owner.call(ctx, "SetKeyGrabs", symbols, grabs); err != nil { return err } - if policy.learn != owner.policy.learn { + grabKeyboard := policy.learn || policy.ui + if grabKeyboard != (owner.policy.learn || owner.policy.ui) { method := "UngrabKeyboard" - if policy.learn { + if grabKeyboard { method = "GrabKeyboard" } if err := owner.call(ctx, method); err != nil { @@ -116,7 +127,8 @@ func (owner *keyboardOwner) configure(ctx context.Context, configuration keyboar owner.configuration, owner.policy, owner.bindings = configuration, policy, bindings owner.router = input.NewCommandRouter(symbols, bindings) owner.keyboard.applied.Store(policy.enabled && !policy.secure) - owner.keyboard.browseApplied.Store(policy.browse && policy.enabled && !policy.secure) + owner.keyboard.browseApplied.Store(policy.browse && !policy.bypass && policy.enabled && !policy.secure) + owner.keyboard.bypassApplied.Store(policy.bypass && policy.enabled && !policy.secure) owner.keyboard.learnEcho.Store(policy.learn) return nil } @@ -125,6 +137,8 @@ func (keyboard *Keyboard) run(ctx context.Context, configuration keyboardConfigu defer close(keyboard.done) defer keyboard.applied.Store(false) defer keyboard.browseApplied.Store(false) + defer keyboard.bypassApplied.Store(false) + defer keyboard.uiSession.Store(0) defer keyboard.active.Store(false) defer keyboard.learn.Store(false) defer keyboard.learnEcho.Store(false) @@ -180,12 +194,17 @@ func (keyboard *Keyboard) run(ctx context.Context, configuration keyboardConfigu } func (owner *keyboardOwner) loop(ctx context.Context, queue *signalQueue) error { + owner.ctx = ctx keyboard := owner.keyboard var pending *keyboardUpdate + var pendingUI *uiUpdate defer func() { if pending != nil { pending.result <- errors.New("Wayland keyboard stopped") } + if pendingUI != nil { + pendingUI.result <- uiReply{err: errors.New("Wayland keyboard stopped")} + } }() timer := time.NewTicker(20 * time.Millisecond) defer timer.Stop() @@ -219,8 +238,38 @@ func (owner *keyboardOwner) loop(ctx context.Context, queue *signalQueue) error return err } } + // A close request revokes the session even if its acknowledgement + // times out waiting for held keys, mirroring the Xorg owner. The + // pending update (if any) then just confirms the ungrab. + if owner.uiSession != 0 && keyboard.uiClose.Load() >= owner.uiSession { + owner.dismiss_internal_ui() + if err := owner.configure(ctx, owner.configuration, owner.requested_policy()); err != nil { + return err + } + policy = owner.requested_policy() + } if !stopping && len(owner.held) == 0 { - if pending != nil { + if pendingUI != nil { + update := pendingUI + pendingUI = nil + reply := uiReply{err: update.ctx.Err()} + if reply.err == nil { + if update.closeSession != 0 { + if update.closeSession == owner.uiSession { + owner.dismiss_internal_ui() + } + if owner.policy.ui { + reply.err = owner.configure(update.ctx, owner.configuration, owner.requested_policy()) + } + } else { + reply.session, reply.err = owner.begin_internal_ui(update.ctx) + } + } + update.result <- reply + if reply.err != nil && update.closeSession != 0 { + return reply.err + } + } else if pending != nil { update := pending pending = nil err := update.ctx.Err() @@ -240,7 +289,7 @@ func (owner *keyboardOwner) loop(ctx context.Context, queue *signalQueue) error keyboard.mutex.Unlock() } update.result <- err - } else if policy != owner.policy { + } else if policy != owner.policy && (policy.bypass == owner.policy.bypass || len(owner.held) == 0) { if err := owner.configure(ctx, owner.configuration, policy); err != nil { return err } @@ -250,18 +299,31 @@ func (owner *keyboardOwner) loop(ctx context.Context, queue *signalQueue) error if pending != nil || stopping { updates = nil } + uiUpdates := keyboard.uiUpdates + if pendingUI != nil || stopping { + uiUpdates = nil + } var canceled <-chan struct{} if pending != nil { canceled = pending.ctx.Done() } + var canceledUI <-chan struct{} + if pendingUI != nil { + canceledUI = pendingUI.ctx.Done() + } select { case <-timer.C: case <-keyboard.wake: case <-canceled: pending.result <- pending.ctx.Err() pending = nil + case <-canceledUI: + pendingUI.result <- uiReply{err: pendingUI.ctx.Err()} + pendingUI = nil case update := <-updates: pending = &update + case update := <-uiUpdates: + pendingUI = &update case <-queue.overflow: return errors.New("Wayland keyboard signal queue overflow") case <-owner.connection.Context().Done(): diff --git a/internal/platform/wayland/protocol_test.go b/internal/platform/wayland/protocol_test.go index 0c41f75..e4ac8d6 100644 --- a/internal/platform/wayland/protocol_test.go +++ b/internal/platform/wayland/protocol_test.go @@ -411,3 +411,51 @@ func TestNativeKeyboardStartsSecureWithoutMonitoringOrGrabs(t *testing.T) { keyboard.SetSecureInputActive(false) wait_snapshot(t, monitor, func(state MonitorSnapshot) bool { return state.watching && len(state.modifiers) == 1 }) } + +func TestNativeBypassAndInternalUIGrabLifecycle(t *testing.T) { + monitor := start_monitor(t) + keyboard := start_keyboard(t, monitor) + keyboard.SetBrowseNavigation(true) + wait_snapshot(t, monitor, func(state MonitorSnapshot) bool { return len(state.grabs) != 0 }) + keyboard.SetCommandBypass(true) + wait_snapshot(t, monitor, func(state MonitorSnapshot) bool { return len(state.grabs) == 0 }) + deadline := time.Now().Add(3 * time.Second) + for !keyboard.CommandBypassEnabled() { + if time.Now().After(deadline) { + t.Fatal("bypass was not applied") + } + time.Sleep(time.Millisecond) + } + // The retained toggle chord still dispatches through the modifier watch. + monitor.key(t, 0xff63, 118, 0, false) + monitor.key(t, 'z', 52, 0, false) + await_command(t, keyboard, input.ToggleCommandBypass) + monitor.key(t, 'z', 52, 0, true) + monitor.key(t, 0xff63, 118, 0, true) + keyboard.SetCommandBypass(false) + wait_snapshot(t, monitor, func(state MonitorSnapshot) bool { return len(state.grabs) != 0 }) + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + session, err := keyboard.BeginInternalUI(ctx) + if err != nil || session == 0 { + t.Fatalf("begin internal UI: session %d, %v", session, err) + } + if !keyboard.InternalUIActive() || keyboard.InternalUISession() != session { + t.Fatal("internal UI session not published") + } + wait_snapshot(t, monitor, func(state MonitorSnapshot) bool { return state.grabbed }) + monitor.key(t, 'a', 38, 0, false) + event := await_command(t, keyboard, input.InternalUIKey) + if event.UISession != session || event.Text != "a" { + t.Fatalf("internal UI key = %+v", event) + } + monitor.key(t, 'a', 38, 0, true) + if err := keyboard.EndInternalUI(ctx, session); err != nil { + t.Fatal(err) + } + wait_snapshot(t, monitor, func(state MonitorSnapshot) bool { return !state.grabbed }) + if keyboard.InternalUIActive() || keyboard.InternalUISession() != 0 { + t.Fatal("internal UI session survived end") + } +} diff --git a/internal/platform/wayland/routing.go b/internal/platform/wayland/routing.go index 8d53ba8..7e4c338 100644 --- a/internal/platform/wayland/routing.go +++ b/internal/platform/wayland/routing.go @@ -23,7 +23,7 @@ import ( ) type keyGrab struct{ Symbol, Modifiers uint32 } -type keyboardPolicy struct{ enabled, browse, button, secure, learn bool } +type keyboardPolicy struct{ enabled, browse, button, secure, learn, bypass, ui bool } func modifier_symbols(names []string) ([]uint32, error) { var symbols []uint32 @@ -53,6 +53,10 @@ func key_name(symbol uint32) string { func lower_symbol(symbol uint32) uint32 { return uint32(C.xkb_keysym_to_lower(C.xkb_keysym_t(symbol))) } +func symbol_utf32(symbol uint32) uint32 { + return uint32(C.xkb_keysym_to_utf32(C.xkb_keysym_t(symbol))) +} + // KeyboardMonitor sends keysyms rather than a keymap, and the symbol for a // shifted key is the shifted keysym: Shift+3 arrives as numbersign on a US // layout. Letter case is defined by XKB alone, but a digit or punctuation @@ -165,6 +169,9 @@ func make_grabs(configuration keyboardConfiguration, policy keyboardPolicy) ([]u } } } + if policy.bypass { + bindings = input.BypassCommandBindings(bindings) + } if !policy.enabled || policy.secure { return []uint32{}, []keyGrab{}, bindings } @@ -250,10 +257,13 @@ func (owner *keyboardOwner) handle_key(key keyEvent) error { } if key.released { delete(owner.captured, key.code) - } else if consumed || owner.learning { + } else if consumed || owner.learning || owner.uiSession != 0 { owner.captured[key.code] = true } - keyboard.active.Store(len(owner.captured) != 0 || owner.learning) + keyboard.active.Store(len(owner.captured) != 0 || owner.learning || owner.uiSession != 0) + if owner.uiSession != 0 { + return owner.handle_ui_key(key, symbol, chord, repeated, timestamp) + } if !key.released && !repeated && owner.learning { command = input.LearnKey switch { @@ -276,7 +286,16 @@ func (owner *keyboardOwner) handle_key(key keyEvent) error { keyboard.learnEcho.Store(true) } if command != "" && !repeated { - event := input.CommandEvent{Command: command, Timestamp: timestamp, Chord: chord, KeyName: key_name(symbol), LearnModeStarted: command == input.ToggleLearnMode && owner.learning} + uiSession := uint64(0) + if command == input.ShowActions && !key.released { + // Capture exclusively before reporting the command, as on Xorg. + // An unavailable modal grab is a failed command, not a failed + // reader: session zero makes dispatch announce unavailability. + if session, err := owner.begin_internal_ui(owner.ctx); err == nil { + uiSession = session + } + } + event := input.CommandEvent{Command: command, UISession: uiSession, Timestamp: timestamp, Chord: chord, KeyName: key_name(symbol), LearnModeStarted: command == input.ToggleLearnMode && owner.learning} select { case keyboard.commands <- event: default: diff --git a/internal/platform/wayland/routing_test.go b/internal/platform/wayland/routing_test.go index 08770e9..4df0874 100644 --- a/internal/platform/wayland/routing_test.go +++ b/internal/platform/wayland/routing_test.go @@ -317,3 +317,70 @@ func TestUnknownModifierBitsDoNotDispatchTruncatedChord(t *testing.T) { t.Fatal("wide modifier mask became Cthulhu+Q") } } + +func TestCommandBypassDropsGrabsButKeepsToggle(t *testing.T) { + keyboard := New([]string{"Insert"}, input.DesktopKeyboardLayout, nil) + policy := keyboardPolicy{enabled: true, browse: true, bypass: true} + symbols, grabs, bindings := make_grabs(keyboard.configuration, policy) + if len(grabs) != 0 { + t.Fatalf("bypass retained key grabs: %v", grabs) + } + owner := &keyboardOwner{keyboard: keyboard, configuration: keyboard.configuration, policy: policy, + bindings: bindings, router: input.NewCommandRouter(symbols, bindings), + held: make(map[uint16]uint32), captured: make(map[uint16]bool), started: time.Now()} + // Browse keys are no longer grabbed, so the compositor would deliver + // them to the application; the watched event still reports monitoring. + send_key(t, owner, 'h', 43, 0, false) + send_key(t, owner, 'h', 43, 0, true) + if len(keyboard.commands) != 0 { + t.Fatal("browse key dispatched a command during bypass") + } + if len(keyboard.events) == 0 { + t.Fatal("bypass lost watched typing for echo") + } + // Reader chords other than the retained toggle dispatch nothing. + send_key(t, owner, 0xff63, 118, 0, false) + send_key(t, owner, 't', 20, 0, false) + send_key(t, owner, 't', 20, 0, true) + send_key(t, owner, 0xff63, 118, 0, true) + if len(keyboard.commands) != 0 { + t.Fatal("reader chord dispatched during bypass") + } + send_key(t, owner, 0xff63, 118, 0, false) + send_key(t, owner, 'z', 52, 0, false) + next_command(t, keyboard, input.ToggleCommandBypass) +} + +func TestInternalUIKeyRoutingAndEscapeDismiss(t *testing.T) { + owner := test_owner(t, input.DesktopKeyboardLayout, "Insert") + owner.uiSession = 7 + owner.keyboard.uiSession.Store(7) + send_key(t, owner, 'a', 38, 0, false) + event := next_command(t, owner.keyboard, input.InternalUIKey) + if event.UISession != 7 || event.Text != "a" { + t.Fatalf("UI text event = %+v", event) + } + send_key(t, owner, 'a', 38, 0, true) + send_key(t, owner, 0xff09, 23, 0, false) + event = next_command(t, owner.keyboard, input.InternalUIKey) + if event.Chord.Symbol != 0xff09 || event.Text != "" { + t.Fatalf("UI tab event = %+v", event) + } + send_key(t, owner, 0xff09, 23, 0, true) + // Reader chords never become field contents. + send_key(t, owner, 0xff63, 118, 0, false) + send_key(t, owner, 'x', 53, 0, false) + send_key(t, owner, 'x', 53, 0, true) + send_key(t, owner, 0xff63, 118, 0, true) + if len(owner.keyboard.commands) != 0 { + t.Fatal("reader chord reached internal UI") + } + send_key(t, owner, 0xff1b, 9, 0, false) + event = next_command(t, owner.keyboard, input.InternalUIKey) + if event.UISession != 7 || event.Chord.Symbol != 0xff1b { + t.Fatalf("UI escape event = %+v", event) + } + if owner.uiSession != 0 || owner.keyboard.InternalUIActive() { + t.Fatal("Escape did not dismiss the UI session") + } +} diff --git a/internal/platform/wayland/ui.go b/internal/platform/wayland/ui.go new file mode 100644 index 0000000..aef54d5 --- /dev/null +++ b/internal/platform/wayland/ui.go @@ -0,0 +1,150 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package wayland + +import ( + "context" + "errors" + + "git.stormux.org/storm/cthulhu/internal/input" +) + +type uiReply struct { + session uint64 + err error +} + +type uiUpdate struct { + ctx context.Context + closeSession uint64 + result chan uiReply +} + +func (keyboard *Keyboard) InternalUISession() uint64 { return keyboard.uiSession.Load() } +func (keyboard *Keyboard) InternalUIActive() bool { return keyboard.uiSession.Load() != 0 } + +// InternalUIEchoActive follows the session itself; the owner retains the +// compositor grab until the session's last held key is released and the +// ungrab is applied, so echo suppression never outlives key capture by +// more than one policy cycle. +func (keyboard *Keyboard) InternalUIEchoActive() bool { return keyboard.uiSession.Load() != 0 } + +// BeginInternalUI acknowledges exclusive capture through the shared +// interface's GrabKeyboard. EndInternalUI waits for physical releases, +// and cannot close a newer UI. There is deliberately no BeginLocalUI: +// the protocol cannot distinguish device-backed from synthesized keys, +// so local approval input stays unavailable on this backend. +func (keyboard *Keyboard) BeginInternalUI(ctx context.Context) (uint64, error) { + return keyboard.update_internal_ui(ctx, 0) +} + +func (keyboard *Keyboard) EndInternalUI(ctx context.Context, session uint64) error { + if session == 0 { + return errors.New("missing internal UI session") + } + // Revoke promptly even when the acknowledgement must wait for a held key. + if session > keyboard.uiSequence.Load() { + return errors.New("unknown internal UI session") + } + for previous := keyboard.uiClose.Load(); previous < session; previous = keyboard.uiClose.Load() { + if keyboard.uiClose.CompareAndSwap(previous, session) { + break + } + } + _, err := keyboard.update_internal_ui(ctx, session) + return err +} + +func (keyboard *Keyboard) update_internal_ui(ctx context.Context, closeSession uint64) (uint64, error) { + keyboard.mutex.Lock() + done := keyboard.done + keyboard.mutex.Unlock() + if done == nil { + return 0, errors.New("Wayland keyboard is not running") + } + update := uiUpdate{ctx: ctx, closeSession: closeSession, result: make(chan uiReply, 1)} + select { + case keyboard.uiUpdates <- update: + case <-ctx.Done(): + return 0, ctx.Err() + case <-done: + return 0, errors.New("Wayland keyboard stopped") + } + select { + case result := <-update.result: + return result.session, result.err + case <-ctx.Done(): + return 0, ctx.Err() + case <-done: + return 0, errors.New("Wayland keyboard stopped") + } +} + +// begin_internal_ui runs on the owner goroutine. The compositor grab is +// applied before the session is acknowledged, mirroring the Xorg owner. +func (owner *keyboardOwner) begin_internal_ui(ctx context.Context) (uint64, error) { + if !owner.policy.enabled || owner.policy.secure || owner.learning || owner.uiSession != 0 { + return 0, errors.New("internal UI input unavailable") + } + policy := owner.policy + policy.ui = true + if err := owner.configure(ctx, owner.configuration, policy); err != nil { + return 0, err + } + owner.uiSession = owner.keyboard.uiSequence.Add(1) + owner.keyboard.uiSession.Store(owner.uiSession) + return owner.uiSession, nil +} + +// dismiss_internal_ui only clears session identity. The owner loop's +// next policy sync releases the compositor grab, mirroring how secure +// generations retire held state outside handle_key. +func (owner *keyboardOwner) dismiss_internal_ui() { + owner.uiSession = 0 + owner.keyboard.uiSession.Store(0) +} + +// handle_ui_key consumes one key for the active session. Reader modifiers +// and their chords never become field contents; Escape reports the key +// and dismisses the session, matching the Xorg owner. +func (owner *keyboardOwner) handle_ui_key(key keyEvent, symbol uint32, chord input.CommandChord, repeated bool, timestamp uint32) error { + if key.released || repeated { + return nil + } + if owner.router.IsModifier(key.symbol) { + return nil + } + if chord.CthulhuModifier && symbol != 0xff1b { + return nil + } + event := input.CommandEvent{ + Command: input.InternalUIKey, + UISession: owner.uiSession, + Timestamp: timestamp, + Chord: chord, + Text: key_text(key, symbol), + } + if symbol == 0xff1b { + owner.dismiss_internal_ui() + } + select { + case owner.keyboard.commands <- event: + return nil + default: + return errors.New("Wayland command queue overflow") + } +} + +// key_text prefers the character reported by the shared interface and +// falls back to the keysym's Unicode mapping. Control characters never +// become field text. +func key_text(key keyEvent, symbol uint32) string { + for _, candidate := range []uint32{key.character, symbol_utf32(symbol), symbol_utf32(key.symbol)} { + if candidate >= 0x20 && candidate != 0x7f && candidate <= 0x10ffff { + return string(rune(candidate)) + } + } + return "" +}