From b66c6c4932810fa493c1c9e21d7fcfbff7c7fd8e Mon Sep 17 00:00:00 2001 From: Alexey Shurygin Date: Sun, 15 Mar 2026 01:15:05 +0300 Subject: [PATCH 1/2] Fix: Dictation does not start on double Fn key press on Mac OS --- glfw/cocoa_init.m | 20 ++++++++ glfw/cocoa_window.m | 28 +++++++++++ kitty_tests/check_build.py | 96 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 144 insertions(+) diff --git a/glfw/cocoa_init.m b/glfw/cocoa_init.m index e258c3d65..c5a1205cc 100644 --- a/glfw/cocoa_init.m +++ b/glfw/cocoa_init.m @@ -839,6 +839,22 @@ is_apple_jis_layout_function_key(NSEvent *event) { return [event keyCode] == 0x66 /* kVK_JIS_Eisu */ || [event keyCode] == 0x68 /* kVK_JIS_Kana */; } +static bool +has_apple_fn_global_shortcut(void) { + NSDictionary *hitoolbox_settings = [[NSUserDefaults standardUserDefaults] persistentDomainForName:@"com.apple.HIToolbox"]; + id obj = [hitoolbox_settings objectForKey:@"AppleFnUsageType"]; + if (![obj isKindOfClass:[NSNumber class]]) return false; + return [obj integerValue] != 0; +} + +static bool +is_apple_fn_global_shortcut(NSEvent *event) { + if ([event keyCode] != 0x3f /* kVK_Function */) return false; + NSEventModifierFlags mods = USEFUL_MODS([event modifierFlags]); + if (mods != 0 && mods != NSEventModifierFlagFunction) return false; + return has_apple_fn_global_shortcut(); +} + GLFWAPI GLFWapplicationshouldhandlereopenfun glfwSetApplicationShouldHandleReopen(GLFWapplicationshouldhandlereopenfun callback) { GLFWapplicationshouldhandlereopenfun previous = handle_reopen_callback; handle_reopen_callback = callback; @@ -949,6 +965,10 @@ int _glfwPlatformInit(bool *supports_window_occlusion) debug_key("-------------- flags changed -----------------\n"); debug_key("%s\n", [[event description] UTF8String]); last_keydown_shortcut_event.virtual_key_code = 0xffff; + if (!_glfw.ignoreOSKeyboardProcessing && !_glfw.keyboard_grabbed && is_apple_fn_global_shortcut(event)) { + debug_key("flagsChanged triggered global fn shortcut ignoring\n"); + return event; + } // switching to the next input source is only confirmed when all modifier keys are released if (last_keydown_shortcut_event.input_source_switch_modifiers) { if (!([event modifierFlags] & last_keydown_shortcut_event.input_source_switch_modifiers)) diff --git a/glfw/cocoa_window.m b/glfw/cocoa_window.m index 768f3b545..d7587b27d 100644 --- a/glfw/cocoa_window.m +++ b/glfw/cocoa_window.m @@ -99,6 +99,22 @@ polymorphic_string_as_utf8(id string) { return [characters UTF8String]; } +static bool +forward_dictation_selector_to_app(SEL selector, id sender) { + static SEL start_dictation_selector = NULL, stop_dictation_selector = NULL; + if (start_dictation_selector == NULL) { + start_dictation_selector = NSSelectorFromString(@"startDictation:"); + stop_dictation_selector = NSSelectorFromString(@"stopDictation:"); + } + if (selector != start_dictation_selector && selector != stop_dictation_selector) return false; + if ([NSApp respondsToSelector:selector]) { + debug_key("Forwarding %s to NSApp\n", [NSStringFromSelector(selector) UTF8String]); + [NSApp performSelector:selector withObject:sender]; + return true; + } + return false; +} + static uint32_t vk_code_to_functional_key_code(uint8_t key_code) { // {{{ switch(key_code) { @@ -842,6 +858,7 @@ static void update_titlebar_button_visibility_after_fullscreen_transition(_GLFWw // With the default macOS keybindings, pressing certain key combinations // (e.g. Ctrl+/, Ctrl+Cmd+Down/Left/Right) will produce a beep sound. debug_key("\n\tTextInputCtx: doCommandBySelector: (%s)\n", [NSStringFromSelector(selector) UTF8String]); + if (forward_dictation_selector_to_app(selector, nil)) return; } @end // }}} @@ -1930,6 +1947,17 @@ void _glfwPlatformUpdateIMEState(_GLFWwindow *w, const GLFWIMEUpdateEvent *ev) { - (void)doCommandBySelector:(SEL)selector { debug_key("\n\tdoCommandBySelector: (%s)\n", [NSStringFromSelector(selector) UTF8String]); + if (forward_dictation_selector_to_app(selector, self)) return; +} + +- (void)startDictation:(id)sender +{ + forward_dictation_selector_to_app(_cmd, sender); +} + +- (void)stopDictation:(id)sender +{ + forward_dictation_selector_to_app(_cmd, sender); } - (BOOL)isAccessibilityElement diff --git a/kitty_tests/check_build.py b/kitty_tests/check_build.py index 086175558..fbbdcf9dc 100644 --- a/kitty_tests/check_build.py +++ b/kitty_tests/check_build.py @@ -2,10 +2,13 @@ # License: GPLv3 Copyright: 2021, Kovid Goyal +import json import os import stat import subprocess import sys +import tempfile +import textwrap import unittest from functools import partial @@ -35,6 +38,99 @@ class TestBuild(BaseTest): for name in 'cell border bgimage tint graphics'.split(): Program(name) + def test_macos_dictation_forwarding(self) -> None: + from kitty.constants import glfw_path, is_macos + if not is_macos: + self.skipTest('Dictation smoke test is macOS only') + cocoa_module = glfw_path('cocoa') + probe = textwrap.dedent('''\ + #import + #import + #import + + static int start_calls = 0; + static int stop_calls = 0; + static id last_sender = nil; + + static void fake_start_dictation(id self, SEL _cmd, id sender) { + (void)self; (void)_cmd; + start_calls++; + last_sender = sender; + } + + static void fake_stop_dictation(id self, SEL _cmd, id sender) { + (void)self; (void)_cmd; + stop_calls++; + last_sender = sender; + } + + static void require_true(BOOL condition, const char *message) { + if (!condition) { + fprintf(stderr, "FAIL: %s\\n", message); + exit(1); + } + } + + int main(void) { + @autoreleasepool { + [NSApplication sharedApplication]; + void *handle = dlopen(@@COCOA_MODULE@@, RTLD_NOW | RTLD_GLOBAL); + require_true(handle != NULL, dlerror()); + + SEL start = NSSelectorFromString(@"startDictation:"); + SEL stop = NSSelectorFromString(@"stopDictation:"); + Method start_method = class_getInstanceMethod([NSApplication class], start); + Method stop_method = class_getInstanceMethod([NSApplication class], stop); + require_true(start_method != NULL, "NSApplication startDictation: missing"); + require_true(stop_method != NULL, "NSApplication stopDictation: missing"); + method_setImplementation(start_method, (IMP)fake_start_dictation); + method_setImplementation(stop_method, (IMP)fake_stop_dictation); + + Class view_cls = NSClassFromString(@"GLFWContentView"); + Class context_cls = NSClassFromString(@"GLFWTextInputContext"); + require_true(view_cls != Nil, "GLFWContentView class not loaded"); + require_true(context_cls != Nil, "GLFWTextInputContext class not loaded"); + + id view = [[view_cls alloc] initWithFrame:NSMakeRect(0, 0, 10, 10)]; + require_true([view respondsToSelector:start], "GLFWContentView does not expose startDictation:"); + require_true([view respondsToSelector:stop], "GLFWContentView does not expose stopDictation:"); + + #pragma clang diagnostic push + #pragma clang diagnostic ignored "-Warc-performSelector-leaks" + [view performSelector:start withObject:@"menu sender"]; + #pragma clang diagnostic pop + require_true(start_calls == 1, "startDictation: action was not forwarded to NSApplication"); + require_true([(id)last_sender isEqual:@"menu sender"], "startDictation: forwarded wrong sender"); + + [view doCommandBySelector:start]; + require_true(start_calls == 2, "doCommandBySelector:startDictation: was swallowed"); + require_true(last_sender == view, "doCommandBySelector:startDictation: should forward self as sender"); + + id context = [[context_cls alloc] initWithClient:view]; + require_true(context != nil, "GLFWTextInputContext initWithClient: failed"); + [context doCommandBySelector:stop]; + require_true(stop_calls == 1, "GLFWTextInputContext did not forward stopDictation:"); + require_true(last_sender == nil, "GLFWTextInputContext should forward nil sender"); + + printf("dictation forwarding probe passed\\n"); + } + return 0; + } + ''').replace('@@COCOA_MODULE@@', json.dumps(cocoa_module)) + with tempfile.TemporaryDirectory() as tdir: + src = os.path.join(tdir, 'dictation_probe.m') + exe = os.path.join(tdir, 'dictation_probe') + with open(src, 'w') as f: + f.write(probe) + cp = subprocess.run( + ['clang', '-framework', 'AppKit', src, '-o', exe], + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True + ) + self.assertEqual(cp.returncode, 0, cp.stdout) + cp = subprocess.run([exe], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) + self.assertEqual(cp.returncode, 0, cp.stdout) + self.assertIn('dictation forwarding probe passed', cp.stdout) + def test_glfw_modules(self) -> None: from kitty.constants import glfw_path, is_macos linux_backends = ['x11'] From 827c8dd61468237dead148dbbf1b78e0ff1b37af Mon Sep 17 00:00:00 2001 From: Alexey Shurygin Date: Sun, 15 Mar 2026 01:33:38 +0300 Subject: [PATCH 2/2] Address PR review feedback --- glfw/cocoa_init.m | 2 ++ kitty_tests/check_build.py | 10 +++++++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/glfw/cocoa_init.m b/glfw/cocoa_init.m index c5a1205cc..8bac2837d 100644 --- a/glfw/cocoa_init.m +++ b/glfw/cocoa_init.m @@ -844,6 +844,8 @@ has_apple_fn_global_shortcut(void) { NSDictionary *hitoolbox_settings = [[NSUserDefaults standardUserDefaults] persistentDomainForName:@"com.apple.HIToolbox"]; id obj = [hitoolbox_settings objectForKey:@"AppleFnUsageType"]; if (![obj isKindOfClass:[NSNumber class]]) return false; + // Non-zero AppleFnUsageType means macOS has reserved Fn/Globe for a + // system action such as input source switching, emoji picker, or dictation. return [obj integerValue] != 0; } diff --git a/kitty_tests/check_build.py b/kitty_tests/check_build.py index fbbdcf9dc..dd11e31c4 100644 --- a/kitty_tests/check_build.py +++ b/kitty_tests/check_build.py @@ -47,6 +47,7 @@ class TestBuild(BaseTest): #import #import #import + #import static int start_calls = 0; static int stop_calls = 0; @@ -91,7 +92,9 @@ class TestBuild(BaseTest): require_true(view_cls != Nil, "GLFWContentView class not loaded"); require_true(context_cls != Nil, "GLFWTextInputContext class not loaded"); - id view = [[view_cls alloc] initWithFrame:NSMakeRect(0, 0, 10, 10)]; + SEL init_with_glfw_window = NSSelectorFromString(@"initWithGlfwWindow:"); + id view = ((id (*)(id, SEL, void *)) objc_msgSend)([view_cls alloc], init_with_glfw_window, NULL); + require_true(view != nil, "GLFWContentView initWithGlfwWindow: failed"); require_true([view respondsToSelector:start], "GLFWContentView does not expose startDictation:"); require_true([view respondsToSelector:stop], "GLFWContentView does not expose stopDictation:"); @@ -106,8 +109,9 @@ class TestBuild(BaseTest): require_true(start_calls == 2, "doCommandBySelector:startDictation: was swallowed"); require_true(last_sender == view, "doCommandBySelector:startDictation: should forward self as sender"); - id context = [[context_cls alloc] initWithClient:view]; - require_true(context != nil, "GLFWTextInputContext initWithClient: failed"); + id context = [view inputContext]; + require_true(context != nil, "GLFWContentView inputContext missing"); + require_true([context isKindOfClass:context_cls], "GLFWContentView inputContext has wrong class"); [context doCommandBySelector:stop]; require_true(stop_calls == 1, "GLFWTextInputContext did not forward stopDictation:"); require_true(last_sender == nil, "GLFWTextInputContext should forward nil sender");