From 4ef2df194ab47112e30504a37e0394f206640a9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=99=BD=E9=B8=BD?= <18717730+xiaobai050@users.noreply.github.com> Date: Mon, 14 Sep 2026 17:39:28 +0800 Subject: [PATCH] macOS: Preserve long IME commits outside key handling --- docs/changelog.rst | 3 + glfw/cocoa_window.m | 16 ++- kitty_tests/check_build.py | 18 +++ kitty_tests/cocoa_ime_commit.m | 202 ++++++++++++++++++++++++++++++++ kitty_tests/cocoa_ime_commit.md | 40 +++++++ 5 files changed, 277 insertions(+), 2 deletions(-) create mode 100644 kitty_tests/cocoa_ime_commit.m create mode 100644 kitty_tests/cocoa_ime_commit.md diff --git a/docs/changelog.rst b/docs/changelog.rst index f19e5af15..55b000301 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -202,6 +202,9 @@ Detailed list of changes 0.49.0 [future] ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +- macOS: Fix long text from input methods being discarded when committed + outside a key event + - Support for :doc:`/custom-shaders` for adding various graphical effects (:iss:`10344`) - Various throughput performance improvements for a 15-35% real world improvement depending on workload diff --git a/glfw/cocoa_window.m b/glfw/cocoa_window.m index 22011e306..517207764 100644 --- a/glfw/cocoa_window.m +++ b/glfw/cocoa_window.m @@ -2105,6 +2105,18 @@ _glfwPlatformUpdateIMEState(_GLFWwindow *w, const GLFWIMEUpdateEvent *ev) { _glfw.ns.text[0] = 0; } } + // Outside key handling there is no key event to accumulate text for. This + // path already commits immediately, so do not constrain a full IME result + // to the fixed-size buffer used by the key handlers. + if (!in_key_handler) { + if (!is_ascii_control_char(utf8[0])) { + debug_input("Sending text to kitty from insertText called from event loop: %s\n", utf8); + GLFWkeyevent glfw_keyevent = {.text = utf8, .ime_state = GLFW_IME_COMMIT_TEXT}; + _glfwInputKeyboard(window, &glfw_keyevent); + } + _glfw.ns.text[0] = 0; + return; + } // insertText can be called multiple times for a single key event size_t existing_length = strnlen(_glfw.ns.text, sizeof(_glfw.ns.text)); size_t required_length = strlen(utf8) + 1; @@ -2112,9 +2124,9 @@ _glfwPlatformUpdateIMEState(_GLFWwindow *w, const GLFWIMEUpdateEvent *ev) { if (available_length >= required_length) { memcpy(_glfw.ns.text + existing_length, utf8, required_length); // copies the null terminator from utf8 as well _glfw.ns.text[sizeof(_glfw.ns.text) - 1] = 0; - if ((!in_key_handler || in_key_handler == 2) && _glfw.ns.text[0]) { + if (in_key_handler == 2 && _glfw.ns.text[0]) { if (!is_ascii_control_char(_glfw.ns.text[0])) { - debug_input("Sending text to kitty from insertText called from %s: %s\n", in_key_handler ? "flagsChanged" : "event loop", _glfw.ns.text); + debug_input("Sending text to kitty from insertText called from flagsChanged: %s\n", _glfw.ns.text); GLFWkeyevent glfw_keyevent = {.text = _glfw.ns.text, .ime_state = GLFW_IME_COMMIT_TEXT}; _glfwInputKeyboard(window, &glfw_keyevent); } diff --git a/kitty_tests/check_build.py b/kitty_tests/check_build.py index 63f21b302..6b3a798e2 100644 --- a/kitty_tests/check_build.py +++ b/kitty_tests/check_build.py @@ -138,6 +138,24 @@ class TestBuild(BaseTest): self.assertEqual(cp.returncode, 0, cp.stdout) self.assertIn('dictation forwarding probe passed', cp.stdout) + def test_macos_ime_commit(self) -> None: + from kitty.constants import glfw_path, is_macos + + if not is_macos or not shutil.which('clang'): + self.skipTest('Cocoa IME regression test is macOS only and requires clang') + cocoa_module = glfw_path('cocoa') + src = os.path.join(os.path.dirname(__file__), 'cocoa_ime_commit.m') + symbols = subprocess.check_output(['nm', '-g', cocoa_module], text=True) + asan_flags = ['-fsanitize=address,undefined', '-fno-omit-frame-pointer'] if '__asan_init' in symbols else [] + with tempfile.TemporaryDirectory() as tdir: + exe = os.path.join(tdir, 'cocoa_ime_commit') + cmd = ['clang', '-D_GLFW_COCOA', '-fno-objc-arc', '-framework', 'Cocoa', '-framework', 'Carbon'] + cp = subprocess.run(cmd + asan_flags + [src, '-o', exe], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, timeout=60) + self.assertEqual(cp.returncode, 0, cp.stdout) + cp = subprocess.run([exe, cocoa_module], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, timeout=30) + self.assertEqual(cp.returncode, 0, cp.stdout) + self.assertTrue(json.loads(cp.stdout)['all_passed'], cp.stdout) + def test_glfw_modules(self) -> None: from kitty.constants import glfw_path, is_macos diff --git a/kitty_tests/cocoa_ime_commit.m b/kitty_tests/cocoa_ime_commit.m new file mode 100644 index 000000000..93e91a0d0 --- /dev/null +++ b/kitty_tests/cocoa_ime_commit.m @@ -0,0 +1,202 @@ +// License: GPL v3 +// Headless regression test for the actual Cocoa module's IME receiver. +// Build/run instructions and fixture limits: cocoa_ime_commit.md. +#import +#import +#include +#include +#include +#undef MAX +#undef MIN +#include "../glfw/internal.h" + +static id view; +static Ivar handlerIvar; +static NSMutableData *delivered; +static NSMutableArray *results; +static NSUInteger commitCount, clearCount, previewBytes; +static BOOL allPassed = YES; + +static void +capture(GLFWwindow *window, GLFWkeyevent *event) { + (void)window; + if (event->ime_state == GLFW_IME_COMMIT_TEXT) { + ++commitCount; + if (event->text) [delivered appendBytes:event->text length:strlen(event->text)]; + } else if (event->ime_state == GLFW_IME_PREEDIT_CHANGED) { + if (!event->text || !event->text[0]) ++clearCount; + else previewBytes = strlen(event->text); + } +} + +static void +set_handler(int mode) { + memcpy((char *)view + ivar_getOffset(handlerIvar), &mode, sizeof(mode)); +} + +static NSString * +repeat(NSString *unit, NSUInteger n) { + NSMutableString *s = [NSMutableString string]; + for (NSUInteger i = 0; i < n; ++i) [s appendString:unit]; + return s; +} + +static void +reset_observed(void) { + [delivered setLength:0]; + commitCount = clearCount = 0; +} + +static void +prepare(NSString *marked) { + set_handler(0); + // Reset both the native text buffer and preedit through the real method. + [view setMarkedText:@"reset" selectedRange:NSMakeRange(5, 0) replacementRange:NSMakeRange(NSNotFound, 0)]; + [view setMarkedText:marked selectedRange:NSMakeRange(marked.length, 0) replacementRange:NSMakeRange(NSNotFound, 0)]; + reset_observed(); +} + +static void +insert(id text) { + [view insertText:text replacementRange:NSMakeRange(NSNotFound, 0)]; +} + +static void +check(NSString *name, NSString *expected, NSUInteger commits, NSUInteger clears, BOOL marked) { + NSData *bytes = [expected dataUsingEncoding:NSUTF8StringEncoding]; + BOOL same = [delivered isEqualToData:bytes]; + BOOL passed = same && commitCount == commits && clearCount == clears && [view hasMarkedText] == marked; + allPassed &= passed; + [results addObject:@{ + @"case" : name, + @"expected_utf8_bytes" : @(bytes.length), + @"committed_utf8_bytes" : @(delivered.length), + @"commit_events" : @(commitCount), + @"preedit_clear_events" : @(clearCount), + @"has_marked_text_after" : @([view hasMarkedText]), + @"content_matches" : @(same), + @"passed" : @(passed) + }]; +} + +int +main(int argc, const char **argv) { + @autoreleasepool { + if (argc != 2) { + fprintf(stderr, "usage: cocoa_ime_commit /path/to/glfw-cocoa.so\n"); + return 2; + } + void *module = dlopen(argv[1], RTLD_NOW | RTLD_LOCAL); + if (!module) { + fprintf(stderr, "dlopen: %s\n", dlerror()); + return 2; + } + Class klass = objc_getClass("GLFWContentView"); + Ivar windowIvar = class_getInstanceVariable(klass, "window"); + Ivar markedIvar = class_getInstanceVariable(klass, "markedText"); + handlerIvar = class_getInstanceVariable(klass, "in_key_handler"); + if (!klass || !windowIvar || !markedIvar || !handlerIvar) { + fprintf(stderr, "native receiver layout missing\n"); + return 2; + } + view = class_createInstance(klass, 0); + _GLFWwindow *fixtureWindow = calloc(1, sizeof(*fixtureWindow)); + if (!view || !fixtureWindow) return 2; + fixtureWindow->callbacks.keyboard = capture; + memcpy((char *)view + ivar_getOffset(windowIvar), &fixtureWindow, sizeof(fixtureWindow)); + object_setIvar(view, markedIvar, [[NSMutableAttributedString alloc] init]); + delivered = [[NSMutableData alloc] init]; + results = [[NSMutableArray alloc] init]; + + NSArray *cases = @[ + @[ @"ascii-510", repeat(@"a", 510) ], + @[ @"ascii-511", repeat(@"a", 511) ], + @[ @"ascii-512", repeat(@"a", 512) ], + @[ @"ascii-513", repeat(@"a", 513) ], + @[ @"han-170-bytes-510", repeat(@"文", 170) ], + @[ @"han-171-bytes-513", repeat(@"文", 171) ], + @[ @"mixed-bytes-511", [repeat(@"文", 170) stringByAppendingString:@"a"] ], + @[ @"mixed-bytes-512", [repeat(@"文", 170) stringByAppendingString:@"ab"] ], + @[ @"han-383-bytes-1149", repeat(@"文", 383) ], + @[ @"emoji-bytes-512", repeat(@"😀", 128) ], + @[ @"han-bytes-90000", repeat(@"文", 30000) ], + @[ @"short-after-long", @"done" ] + ]; + for (NSArray *c in cases) { + NSString *input = c[1]; + prepare(input); + BOOL previewMatches = previewBytes == [input lengthOfBytesUsingEncoding:NSUTF8StringEncoding]; + insert(input); + check(c[0], input, 1, 1, NO); + if (!previewMatches) { + allPassed = NO; + fprintf(stderr, "Full preedit was not delivered for %s\n", [c[0] UTF8String]); + } + } + + prepare(@""); + NSString *unmarked = repeat(@"x", 2048); + insert(unmarked); + check(@"unmarked-long", unmarked, 1, 0, NO); + + prepare(@"候选"); + NSString *attributedText = repeat(@"文😀e\u0301\n", 128); + insert([[[NSAttributedString alloc] initWithString:attributedText] autorelease]); + check(@"attributed-long", attributedText, 1, 1, NO); + + prepare(@""); + insert(@"甲"); + insert(@"乙"); + check(@"separate-event-loop-commits", @"甲乙", 2, 0, NO); + + prepare(@"待确认"); + insert(nil); + check(@"nil-keeps-preedit", @"", 0, 0, YES); + insert(@""); + check(@"empty-keeps-preedit", @"", 0, 0, YES); + insert(@"\n"); + check(@"control-keeps-preedit", @"", 0, 0, YES); + insert(@"\x7f"); + check(@"delete-keeps-preedit", @"", 0, 0, YES); + + // Simulate insertText callbacks while keyDown is collecting text. They + // must not bypass the surrounding key's dispatch by emitting commits. + prepare(@""); + set_handler(1); + insert(@"甲"); + insert(@"乙"); + check(@"key-handler-defers-two-callbacks", @"", 0, 0, NO); + // Use the existing modifier branch to observe the pending accumulator; + // this is a fixture probe, not a simulated physical modifier gesture. + set_handler(2); + insert(@"丙"); + check(@"existing-buffer-still-accumulates", @"甲乙丙", 1, 0, NO); + + prepare(@"候选"); + set_handler(1); + insert(@"已确认"); + check(@"key-handler-unmarks-without-early-commit", @"", 0, 0, NO); + + // The narrowly scoped fix intentionally leaves modifier buffering alone. + prepare(@""); + set_handler(2); + insert(repeat(@"m", 511)); + check(@"modifier-short-behavior", repeat(@"m", 511), 1, 0, NO); + prepare(@""); + set_handler(2); + insert(repeat(@"m", 512)); + check(@"modifier-overflow-unchanged", @"", 0, 0, NO); + + NSDictionary *report = @{ + @"native_module" : @(argv[1]), + @"all_passed" : @(allPassed), + @"cases" : results, + @"capture_boundary" : @"native GLFW keyboard callback; no input method, GUI or terminal child", + @"key_handler_tests" : @"receiver callback state fixtures; not full physical key dispatch" + }; + NSData *json = [NSJSONSerialization dataWithJSONObject:report options:NSJSONWritingPrettyPrinted error:nil]; + fwrite(json.bytes, 1, json.length, stdout); + fputc('\n', stdout); + return allPassed ? 0 : 1; + } +} diff --git a/kitty_tests/cocoa_ime_commit.md b/kitty_tests/cocoa_ime_commit.md new file mode 100644 index 000000000..e8d377650 --- /dev/null +++ b/kitty_tests/cocoa_ime_commit.md @@ -0,0 +1,40 @@ +# Cocoa IME commit regression + +This standalone macOS test calls the actual `GLFWContentView` methods from a +specified `glfw-cocoa.so`. It does not start an input method or open a window. +After building from source, run it through the normal test entry point: + +```sh +./test.py macos_ime_commit +``` + +The runner skips this test outside macOS or if `clang` is unavailable. It also +links the probe with the sanitizer runtime when the Cocoa module uses ASan. +For a standalone run against an unsanitized module from a matching checkout: + +```sh +mkdir -p build/ime-test +clang -D_GLFW_COCOA -fno-objc-arc -framework Cocoa -framework Carbon \ + kitty_tests/cocoa_ime_commit.m -o build/ime-test/cocoa_ime_commit +build/ime-test/cocoa_ime_commit "$PWD/kitty/glfw-cocoa.so" +``` + +The test covers UTF-8 length boundaries, long Chinese and emoji commits, +attributed strings with combining characters and embedded newlines, +preedit clearing, separate callbacks, and existing control/empty input handling. +It also checks that key-handler callbacks continue staging text and that the +modifier branch retains its previous behavior, including its existing limit. +The change under test only removes the limit on event-loop commits. + +The fixture allocates the native receiver without initializing NSView/window +lifecycle, and supplies only the ivars and keyboard callback used by the tested +methods. These small fixture objects deliberately live until process exit; +NSView destruction is not invoked on the uninitialized view. No NSApplication, +NSWindow, input context, Accessibility action or synthetic GUI event is created. + +Callback mode fixtures test staging within `insertText`, not a complete physical +keyDown/flagsChanged sequence. Delivery is observed at the native GLFW callback, +not at a terminal child or editor. Run each native module in a separate process +to avoid Objective-C class-name collisions. Against the pre-fix module this test +must fail the oversized event-loop cases; against the repaired module it must +report `all_passed: true` and exit zero.