This commit is contained in:
Kovid Goyal
2026-03-15 08:34:46 +05:30
3 changed files with 150 additions and 0 deletions

View File

@@ -839,6 +839,24 @@ is_apple_jis_layout_function_key(NSEvent *event) {
return [event keyCode] == 0x66 /* kVK_JIS_Eisu */ || [event keyCode] == 0x68 /* kVK_JIS_Kana */; 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;
// 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;
}
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) { GLFWAPI GLFWapplicationshouldhandlereopenfun glfwSetApplicationShouldHandleReopen(GLFWapplicationshouldhandlereopenfun callback) {
GLFWapplicationshouldhandlereopenfun previous = handle_reopen_callback; GLFWapplicationshouldhandlereopenfun previous = handle_reopen_callback;
handle_reopen_callback = callback; handle_reopen_callback = callback;
@@ -949,6 +967,10 @@ int _glfwPlatformInit(bool *supports_window_occlusion)
debug_key("-------------- flags changed -----------------\n"); debug_key("-------------- flags changed -----------------\n");
debug_key("%s\n", [[event description] UTF8String]); debug_key("%s\n", [[event description] UTF8String]);
last_keydown_shortcut_event.virtual_key_code = 0xffff; 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 // 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 (last_keydown_shortcut_event.input_source_switch_modifiers) {
if (!([event modifierFlags] & last_keydown_shortcut_event.input_source_switch_modifiers)) if (!([event modifierFlags] & last_keydown_shortcut_event.input_source_switch_modifiers))

View File

@@ -99,6 +99,22 @@ polymorphic_string_as_utf8(id string) {
return [characters UTF8String]; 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 static uint32_t
vk_code_to_functional_key_code(uint8_t key_code) { // {{{ vk_code_to_functional_key_code(uint8_t key_code) { // {{{
switch(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 // With the default macOS keybindings, pressing certain key combinations
// (e.g. Ctrl+/, Ctrl+Cmd+Down/Left/Right) will produce a beep sound. // (e.g. Ctrl+/, Ctrl+Cmd+Down/Left/Right) will produce a beep sound.
debug_key("\n\tTextInputCtx: doCommandBySelector: (%s)\n", [NSStringFromSelector(selector) UTF8String]); debug_key("\n\tTextInputCtx: doCommandBySelector: (%s)\n", [NSStringFromSelector(selector) UTF8String]);
if (forward_dictation_selector_to_app(selector, nil)) return;
} }
@end // }}} @end // }}}
@@ -1930,6 +1947,17 @@ void _glfwPlatformUpdateIMEState(_GLFWwindow *w, const GLFWIMEUpdateEvent *ev) {
- (void)doCommandBySelector:(SEL)selector - (void)doCommandBySelector:(SEL)selector
{ {
debug_key("\n\tdoCommandBySelector: (%s)\n", [NSStringFromSelector(selector) UTF8String]); 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 - (BOOL)isAccessibilityElement

View File

@@ -2,10 +2,13 @@
# License: GPLv3 Copyright: 2021, Kovid Goyal <kovid at kovidgoyal.net> # License: GPLv3 Copyright: 2021, Kovid Goyal <kovid at kovidgoyal.net>
import json
import os import os
import stat import stat
import subprocess import subprocess
import sys import sys
import tempfile
import textwrap
import unittest import unittest
from functools import partial from functools import partial
@@ -35,6 +38,103 @@ class TestBuild(BaseTest):
for name in 'cell border bgimage tint graphics'.split(): for name in 'cell border bgimage tint graphics'.split():
Program(name) 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 <AppKit/AppKit.h>
#import <dlfcn.h>
#import <objc/runtime.h>
#import <objc/message.h>
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");
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:");
#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 = [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");
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: def test_glfw_modules(self) -> None:
from kitty.constants import glfw_path, is_macos from kitty.constants import glfw_path, is_macos
linux_backends = ['x11'] linux_backends = ['x11']