From e1bf9b3d920b5967e5382f865874e7119d1cb3bc Mon Sep 17 00:00:00 2001 From: Forketyfork Date: Fri, 16 Jan 2026 15:07:53 +0100 Subject: [PATCH 1/5] Add macOS accessibility text input support Enable external text input sources like Superwhisper (speech-to-text) and the emoji picker to work with SDL windows. SDL doesn't natively support NSTextInputClient for accessibility-based input. The solution adds a transparent NSTextView overlay that: - Acts as first responder to receive accessibility queries - Forwards keyboard events to SDL for normal input - Intercepts Cmd+V to handle paste (used by Superwhisper) - Captures insertText: for emoji picker and keyboard input - Exposes accessibility attributes so apps can discover the text field --- build.zig | 6 + src/c.zig | 4 + src/main.zig | 72 +++++-- src/platform/macos_text_input.m | 342 ++++++++++++++++++++++++++++++ src/platform/macos_text_input.zig | 128 +++++++++++ src/platform/sdl.zig | 4 + 6 files changed, 543 insertions(+), 13 deletions(-) create mode 100644 src/platform/macos_text_input.m create mode 100644 src/platform/macos_text_input.zig diff --git a/build.zig b/build.zig index ae8014b..28ccfd3 100644 --- a/build.zig +++ b/build.zig @@ -57,6 +57,12 @@ pub fn build(b: *std.Build) void { exe.linkFramework("CoreFoundation"); exe.linkFramework("AppKit"); + // Compile the Objective-C accessibility helper + exe.addCSourceFile(.{ + .file = b.path("src/platform/macos_text_input.m"), + .flags = &.{"-fobjc-arc"}, + }); + if (findSdkRoot()) |sdk_root| { const framework_path = b.fmt("{s}/System/Library/Frameworks", .{sdk_root}); exe.addFrameworkPath(.{ .cwd_relative = framework_path }); diff --git a/src/c.zig b/src/c.zig index 343ccaf..e5014ec 100644 --- a/src/c.zig +++ b/src/c.zig @@ -59,6 +59,10 @@ pub const SDL_PollEvent = c_import.SDL_PollEvent; pub const SDL_Delay = c_import.SDL_Delay; pub const SDL_StartTextInput = c_import.SDL_StartTextInput; pub const SDL_StopTextInput = c_import.SDL_StopTextInput; +pub const SDL_SetTextInputArea = c_import.SDL_SetTextInputArea; +pub const SDL_GetPointerProperty = c_import.SDL_GetPointerProperty; +pub const SDL_GetWindowProperties = c_import.SDL_GetWindowProperties; +pub const SDL_PROP_WINDOW_COCOA_WINDOW_POINTER: [*:0]const u8 = c_import.SDL_PROP_WINDOW_COCOA_WINDOW_POINTER; pub const SDL_SetHint = c_import.SDL_SetHint; pub const SDL_HINT_MAC_PRESS_AND_HOLD: [*:0]const u8 = c_import.SDL_HINT_MAC_PRESS_AND_HOLD; pub const SDL_HINT_QUIT_ON_LAST_WINDOW_CLOSE: [*:0]const u8 = c_import.SDL_HINT_QUIT_ON_LAST_WINDOW_CLOSE; diff --git a/src/main.zig b/src/main.zig index 1748da8..3d8efd2 100644 --- a/src/main.zig +++ b/src/main.zig @@ -10,6 +10,7 @@ const session_state = @import("session/state.zig"); const vt_stream = @import("vt_stream.zig"); const platform = @import("platform/sdl.zig"); const macos_input = @import("platform/macos_input_source.zig"); +const macos_text_input = @import("platform/macos_text_input.zig"); const input = @import("input/mapper.zig"); const renderer_mod = @import("render/renderer.zig"); const shell_mod = @import("shell.zig"); @@ -202,7 +203,24 @@ pub fn main() !void { defer platform.deinit(&sdl); platform.startTextInput(sdl.window); defer platform.stopTextInput(sdl.window); - var text_input_active = true; + // Set initial text input area to cover the window. This helps external input + // sources (emoji picker, speech-to-text) know where to deliver text. + const initial_rect = c.SDL_Rect{ .x = 0, .y = 0, .w = persistence.window.width, .h = persistence.window.height }; + platform.setTextInputArea(sdl.window, &initial_rect, 0); + + // Initialize the accessible text input helper on macOS. + // This creates a hidden text view that exposes proper accessibility attributes, + // allowing external input sources (emoji picker, speech-to-text) to find us. + var accessible_text_input = if (builtin.os.tag == .macos) blk: { + const props = c.SDL_GetWindowProperties(sdl.window); + log.debug("SDL window properties ID: {d}", .{props}); + const nswindow = c.SDL_GetPointerProperty(props, c.SDL_PROP_WINDOW_COCOA_WINDOW_POINTER, null); + log.debug("NSWindow pointer: {?}", .{nswindow}); + break :blk macos_text_input.AccessibleTextInput.init(allocator, nswindow); + } else macos_text_input.AccessibleTextInput.init(allocator, null); + defer accessible_text_input.deinit(); + accessible_text_input.start(); + var input_source_tracker = macos_input.InputSourceTracker.init(); defer input_source_tracker.deinit(); if (builtin.os.tag == .macos) { @@ -450,6 +468,10 @@ pub fn main() !void { cell_width_pixels = @divFloor(render_width, @as(c_int, @intCast(grid_cols))); cell_height_pixels = @divFloor(render_height, @as(c_int, @intCast(grid_rows))); + // Update text input area to match new window size + const resize_rect = c.SDL_Rect{ .x = 0, .y = 0, .w = render_width, .h = render_height }; + platform.setTextInputArea(sdl.window, &resize_rect, 0); + std.debug.print("Window resized to: {d}x{d} (render {d}x{d}), terminal size: {d}x{d}\n", .{ window_width_points, window_height_points, render_width, render_height, full_cols, full_rows }); persistence.window.width = window_width_points; @@ -461,24 +483,24 @@ pub fn main() !void { }; }, c.SDL_EVENT_WINDOW_FOCUS_LOST => { - if (builtin.os.tag == .macos) { - if (text_input_active) { - platform.stopTextInput(sdl.window); - text_input_active = false; - } - } + log.debug("SDL_EVENT_WINDOW_FOCUS_LOST", .{}); + // Note: We intentionally do NOT stop text input on focus loss. + // Stopping text input removes SDL's field editor, which prevents + // external input sources (emoji picker, speech-to-text apps) from + // delivering text to our window. These tools send insertText: to + // the key window's first responder, which requires the field editor + // to still be active. }, c.SDL_EVENT_WINDOW_FOCUS_GAINED => { + log.debug("SDL_EVENT_WINDOW_FOCUS_GAINED", .{}); if (builtin.os.tag == .macos) { input_source_tracker.restore() catch |err| { log.warn("Failed to restore input source: {}", .{err}); }; - // Reset text input so macOS restores the per-document input source. - if (text_input_active) { - platform.stopTextInput(sdl.window); - } - platform.startTextInput(sdl.window); - text_input_active = true; + // Note: We do NOT restart text input here anymore. Stopping and + // restarting recreates SDL's field editor, which can cause race + // conditions with external input sources (emoji picker, speech-to-text) + // that send insertText: when focus changes. } }, c.SDL_EVENT_KEYMAP_CHANGED => { @@ -490,12 +512,24 @@ pub fn main() !void { }, c.SDL_EVENT_TEXT_INPUT => { const focused = &sessions[anim_state.focused_session]; + if (scaled_event.text.text) |text_ptr| { + const text = std.mem.sliceTo(text_ptr, 0); + log.debug("SDL_EVENT_TEXT_INPUT: len={d} text=\"{s}\"", .{ text.len, text }); + } else { + log.debug("SDL_EVENT_TEXT_INPUT: null text", .{}); + } handleTextInput(focused, scaled_event.text.text) catch |err| { std.debug.print("Text input failed: {}\n", .{err}); }; }, c.SDL_EVENT_TEXT_EDITING => { const focused = &sessions[anim_state.focused_session]; + if (scaled_event.edit.text) |text_ptr| { + const text = std.mem.sliceTo(text_ptr, 0); + log.debug("SDL_EVENT_TEXT_EDITING: len={d} edit_len={d} text=\"{s}\"", .{ text.len, scaled_event.edit.length, text }); + } else { + log.debug("SDL_EVENT_TEXT_EDITING: null text", .{}); + } // Some macOS input methods (emoji picker) may deliver committed text via TEXT_EDITING. if (scaled_event.edit.text != null and scaled_event.edit.length == 0) { handleTextInput(focused, scaled_event.edit.text) catch |err| { @@ -1034,6 +1068,18 @@ pub fn main() !void { } } + // Poll for text from the accessible text input helper (macOS only). + // This receives text from external sources like emoji picker and speech-to-text. + if (accessible_text_input.pollText()) |text| { + defer allocator.free(text); + const focused = &sessions[anim_state.focused_session]; + if (focused.spawned and !focused.dead) { + focused.sendInput(text) catch |err| { + log.err("Failed to send accessible text input: {}", .{err}); + }; + } + } + try loop.run(.no_wait); var any_session_dirty = false; diff --git a/src/platform/macos_text_input.m b/src/platform/macos_text_input.m new file mode 100644 index 0000000..be86410 --- /dev/null +++ b/src/platform/macos_text_input.m @@ -0,0 +1,342 @@ +// macOS Accessibility Text Input Helper +// +// This hooks into SDL's window to make it properly respond to accessibility +// text input from external sources (emoji picker, speech-to-text apps). +// +// The approach: Add a custom accessibility element to the window that +// advertises itself as a text field and forwards received text to our app. + +#import +#import + +// Callback function type for delivering text to Zig code +typedef void (*TextInputCallback)(const char* text, void* userdata); + +// Forward declaration +@class AccessibleTextInputView; + +// Global state +static TextInputCallback g_callback = NULL; +static void* g_userdata = NULL; +static AccessibleTextInputView* g_textView = NULL; +static id g_textDidChangeObserver = nil; +static id g_windowDidBecomeKeyObserver = nil; +static NSWindow* g_window = NULL; +static NSInteger g_lastPasteboardChangeCount = 0; + +// Custom NSTextView subclass that captures external text input but forwards +// regular keyboard events to SDL's view +@interface AccessibleTextInputView : NSTextView +@property (nonatomic, weak) NSView* sdlContentView; +@property (nonatomic, strong) NSTextInputContext* customInputContext; +@end + +@implementation AccessibleTextInputView + +- (instancetype)initWithFrame:(NSRect)frameRect { + self = [super initWithFrame:frameRect]; + if (self) { + // Create input context immediately so the text input system can find us + self.customInputContext = [[NSTextInputContext alloc] initWithClient:self]; + } + return self; +} + +- (BOOL)acceptsFirstResponder { + return YES; +} + +- (BOOL)becomeFirstResponder { + return [super becomeFirstResponder]; +} + +- (BOOL)resignFirstResponder { + // Always refuse to resign - we want to stay the first responder so external + // input sources (emoji picker, dictation) can send text to us + return NO; +} + +// Forward keyboard events to SDL's content view for key handling (shortcuts, etc.) +- (void)keyDown:(NSEvent*)event { + // Intercept Cmd+V (paste) - handle it ourselves since SDL won't receive it properly + // when we're the first responder. This also enables apps like Superwhisper that + // simulate Cmd+V after putting text on the pasteboard. + NSEventModifierFlags cmdOnly = event.modifierFlags & NSEventModifierFlagDeviceIndependentFlagsMask; + if (cmdOnly == NSEventModifierFlagCommand && event.keyCode == 9) { // 'v' key + [self paste:nil]; + return; + } + + if (self.sdlContentView) { + [self.sdlContentView keyDown:event]; + } +} + +- (void)keyUp:(NSEvent*)event { + if (self.sdlContentView) { + [self.sdlContentView keyUp:event]; + } +} + +// Paste from pasteboard - handles both manual Cmd+V and apps like Superwhisper +- (void)paste:(id)sender { + NSPasteboard* pb = [NSPasteboard generalPasteboard]; + NSString* text = [pb stringForType:NSPasteboardTypeString]; + if (text && text.length > 0 && g_callback) { + g_callback([text UTF8String], g_userdata); + } +} + +- (void)flagsChanged:(NSEvent*)event { + if (self.sdlContentView) { + [self.sdlContentView flagsChanged:event]; + } +} + +// Pass mouse events through to SDL's view underneath +- (NSView*)hitTest:(NSPoint)point { + return nil; // Make this view "transparent" to mouse clicks +} + +- (BOOL)acceptsMouseMovedEvents { + return NO; +} + + + +// Provide our own input context since NSTextView's default one is null in this configuration +- (NSTextInputContext*)inputContext { + return self.customInputContext; +} + +// Override both insertText variants - some apps use the older one without replacementRange +- (void)insertText:(id)string { + [self insertText:string replacementRange:NSMakeRange(NSNotFound, 0)]; +} + +// Override insertText to capture all text input (keyboard and external) +// We forward everything through our callback since SDL can't receive insertText +// when we're the first responder +- (void)insertText:(id)string replacementRange:(NSRange)replacementRange { + NSString* text = nil; + if ([string isKindOfClass:[NSAttributedString class]]) { + text = [(NSAttributedString*)string string]; + } else if ([string isKindOfClass:[NSString class]]) { + text = (NSString*)string; + } + + if (text && text.length > 0 && g_callback) { + g_callback([text UTF8String], g_userdata); + } + + // Clear the text view after processing to keep it empty + [self setString:@""]; +} + +// Accessibility attributes to make this view discoverable by apps like Superwhisper +- (BOOL)isAccessibilityElement { + return YES; +} + +- (NSAccessibilityRole)accessibilityRole { + return NSAccessibilityTextAreaRole; +} + +- (NSString*)accessibilityRoleDescription { + return @"text input"; +} + +- (BOOL)isAccessibilityEnabled { + return YES; +} + +- (BOOL)isAccessibilityFocused { + return [[self window] firstResponder] == self; +} + +- (id)accessibilityValue { + return @""; +} + +- (void)accessibilitySetValue:(id)value forAttribute:(NSAccessibilityAttributeName)attribute { + if ([attribute isEqualToString:NSAccessibilityValueAttribute]) { + NSString* text = nil; + if ([value isKindOfClass:[NSString class]]) { + text = (NSString*)value; + } else if ([value isKindOfClass:[NSAttributedString class]]) { + text = [(NSAttributedString*)value string]; + } + + if (text && text.length > 0 && g_callback) { + g_callback([text UTF8String], g_userdata); + } + return; + } + [super accessibilitySetValue:value forAttribute:attribute]; +} + +- (void)setAccessibilityValue:(id)accessibilityValue { + if (accessibilityValue && g_callback) { + NSString* text = nil; + if ([accessibilityValue isKindOfClass:[NSString class]]) { + text = (NSString*)accessibilityValue; + } else if ([accessibilityValue isKindOfClass:[NSAttributedString class]]) { + text = [(NSAttributedString*)accessibilityValue string]; + } + if (text && text.length > 0) { + g_callback([text UTF8String], g_userdata); + return; + } + } + [super setAccessibilityValue:accessibilityValue]; +} + +@end + +// C interface for Zig + +void macos_text_input_init(void* nswindow, TextInputCallback callback, void* userdata) { + if (!nswindow || !callback) return; + + @autoreleasepool { + g_callback = callback; + g_userdata = userdata; + + // Initialize pasteboard change count to avoid pasting stale content + g_lastPasteboardChangeCount = [[NSPasteboard generalPasteboard] changeCount]; + + NSWindow* window = (__bridge NSWindow*)nswindow; + g_window = window; + NSView* contentView = [window contentView]; + if (!contentView) { + NSLog(@"[AccessibleTextInput] ERROR: No content view found"); + return; + } + + // Create the accessible text view that covers the entire window + // This ensures it's the target for accessibility-based text input + NSRect frame = contentView.bounds; + g_textView = [[AccessibleTextInputView alloc] initWithFrame:frame]; + g_textView.sdlContentView = contentView; + + // Configure the text view + [g_textView setEditable:YES]; + [g_textView setSelectable:NO]; + [g_textView setRichText:NO]; + [g_textView setImportsGraphics:NO]; + [g_textView setAllowsUndo:NO]; + + // Make it nearly transparent but still functional + // Note: alpha=0 causes inputContext to be null, breaking text input + [g_textView setAlphaValue:0.01]; // Nearly invisible but functional + [g_textView setBackgroundColor:[NSColor clearColor]]; + [g_textView setDrawsBackground:NO]; + + + // Auto-resize with the window + [g_textView setAutoresizingMask:NSViewWidthSizable | NSViewHeightSizable]; + + // Add to the window's content view + [contentView addSubview:g_textView positioned:NSWindowAbove relativeTo:nil]; + + // Make it the first responder so external input sources can find it + [window makeFirstResponder:g_textView]; + + // Observe text changes as a fallback + g_textDidChangeObserver = [[NSNotificationCenter defaultCenter] + addObserverForName:NSTextDidChangeNotification + object:g_textView + queue:[NSOperationQueue mainQueue] + usingBlock:^(NSNotification* note) { + NSString* text = [g_textView string]; + if (text && text.length > 0 && g_callback) { + g_callback([text UTF8String], g_userdata); + [g_textView setString:@""]; + } + }]; + + // Reclaim first responder when window becomes key again + // This is critical for receiving text from emoji picker, dictation, etc. + g_windowDidBecomeKeyObserver = [[NSNotificationCenter defaultCenter] + addObserverForName:NSWindowDidBecomeKeyNotification + object:window + queue:[NSOperationQueue mainQueue] + usingBlock:^(NSNotification* note) { + if (g_textView && g_window) { + id firstResponder = [g_window firstResponder]; + if (firstResponder != g_textView) { + [g_window makeFirstResponder:g_textView]; + } + // Activate the input context to signal we're ready for input + NSTextInputContext* ctx = [g_textView inputContext]; + if (ctx) { + [ctx activate]; + } + + // Check if external input source (emoji picker, etc.) put text on pasteboard + NSPasteboard* pb = [NSPasteboard generalPasteboard]; + NSInteger currentChangeCount = [pb changeCount]; + if (currentChangeCount != g_lastPasteboardChangeCount) { + NSString* pbText = [pb stringForType:NSPasteboardTypeString]; + g_lastPasteboardChangeCount = currentChangeCount; + + // Send the pasteboard text through our callback + if (pbText && pbText.length > 0 && g_callback) { + g_callback([pbText UTF8String], g_userdata); + } + } + } + }]; + + } +} + +void macos_text_input_deinit(void) { + @autoreleasepool { + if (g_windowDidBecomeKeyObserver) { + [[NSNotificationCenter defaultCenter] removeObserver:g_windowDidBecomeKeyObserver]; + g_windowDidBecomeKeyObserver = nil; + } + + if (g_textDidChangeObserver) { + [[NSNotificationCenter defaultCenter] removeObserver:g_textDidChangeObserver]; + g_textDidChangeObserver = nil; + } + + if (g_textView) { + [g_textView removeFromSuperview]; + g_textView = nil; + } + + g_callback = NULL; + g_userdata = NULL; + } +} + +void macos_text_input_focus(void) { + @autoreleasepool { + if (g_textView && [g_textView window]) { + [[g_textView window] makeFirstResponder:g_textView]; + } + } +} + +void macos_text_input_unfocus(void) { + @autoreleasepool { + if (g_textView && [g_textView window]) { + // Return focus to the window itself + [[g_textView window] makeFirstResponder:nil]; + } + } +} + +// Check if the accessible text view is currently focused +int macos_text_input_is_focused(void) { + @autoreleasepool { + if (g_textView && [g_textView window]) { + return [[g_textView window] firstResponder] == g_textView ? 1 : 0; + } + return 0; + } +} + diff --git a/src/platform/macos_text_input.zig b/src/platform/macos_text_input.zig new file mode 100644 index 0000000..4f00fff --- /dev/null +++ b/src/platform/macos_text_input.zig @@ -0,0 +1,128 @@ +const std = @import("std"); +const builtin = @import("builtin"); + +const is_macos = builtin.os.tag == .macos; + +const log = std.log.scoped(.macos_text_input); + +// External C functions from macos_text_input.m +const c = if (is_macos) struct { + pub const TextInputCallback = *const fn ([*c]const u8, ?*anyopaque) callconv(.c) void; + + pub extern fn macos_text_input_init(nswindow: ?*anyopaque, callback: TextInputCallback, userdata: ?*anyopaque) void; + pub extern fn macos_text_input_deinit() void; + pub extern fn macos_text_input_focus() void; + pub extern fn macos_text_input_unfocus() void; + pub extern fn macos_text_input_is_focused() c_int; +} else struct {}; + +// Maximum size for a single text input event (4KB should be plenty for any paste) +const MAX_TEXT_SIZE = 4096; + +pub const AccessibleTextInput = if (is_macos) struct { + pending_text: ?[]u8 = null, + allocator: std.mem.Allocator, + nswindow: ?*anyopaque, + + // Global pointer for the callback to access + var global_instance: ?*AccessibleTextInput = null; + + pub fn init(allocator: std.mem.Allocator, nswindow: ?*anyopaque) AccessibleTextInput { + const self = AccessibleTextInput{ + .allocator = allocator, + .nswindow = nswindow, + }; + return self; + } + + /// Must be called after init, once the struct is at its final memory location. + /// This registers the global instance pointer for the callback. + pub fn start(self: *AccessibleTextInput) void { + global_instance = self; + c.macos_text_input_init(self.nswindow, &trampolineCallback, null); + } + + pub fn deinit(self: *AccessibleTextInput) void { + c.macos_text_input_deinit(); + if (self.pending_text) |text| { + self.allocator.free(text); + self.pending_text = null; + } + global_instance = null; + } + + pub fn focus(self: *AccessibleTextInput) void { + _ = self; + c.macos_text_input_focus(); + } + + pub fn unfocus(self: *AccessibleTextInput) void { + _ = self; + c.macos_text_input_unfocus(); + } + + pub fn isFocused(self: *AccessibleTextInput) bool { + _ = self; + return c.macos_text_input_is_focused() != 0; + } + + /// Poll for pending text input. Returns the text and clears the pending state. + /// Caller must free the returned slice. + pub fn pollText(self: *AccessibleTextInput) ?[]u8 { + const text = self.pending_text; + self.pending_text = null; + return text; + } + + fn trampolineCallback(text_ptr: [*c]const u8, userdata: ?*anyopaque) callconv(.c) void { + _ = userdata; + const instance = global_instance orelse return; + if (text_ptr == null) return; + + const text = std.mem.sliceTo(text_ptr, 0); + if (text.len == 0) return; + + // Free any existing pending text + if (instance.pending_text) |old| { + instance.allocator.free(old); + } + + // Store the new text + instance.pending_text = instance.allocator.dupe(u8, text) catch { + log.err("Failed to allocate text buffer", .{}); + return; + }; + } +} else struct { + pub fn init(allocator: std.mem.Allocator, nswindow: ?*anyopaque) AccessibleTextInput { + _ = allocator; + _ = nswindow; + return .{}; + } + + pub fn start(self: *AccessibleTextInput) void { + _ = self; + } + + pub fn deinit(self: *AccessibleTextInput) void { + _ = self; + } + + pub fn focus(self: *AccessibleTextInput) void { + _ = self; + } + + pub fn unfocus(self: *AccessibleTextInput) void { + _ = self; + } + + pub fn isFocused(self: *AccessibleTextInput) bool { + _ = self; + return false; + } + + pub fn pollText(self: *AccessibleTextInput) ?[]u8 { + _ = self; + return null; + } +}; diff --git a/src/platform/sdl.zig b/src/platform/sdl.zig index d7adb91..b2f54c3 100644 --- a/src/platform/sdl.zig +++ b/src/platform/sdl.zig @@ -127,6 +127,10 @@ pub fn stopTextInput(window: *c.SDL_Window) void { _ = c.SDL_StopTextInput(window); } +pub fn setTextInputArea(window: *c.SDL_Window, rect: ?*const c.SDL_Rect, cursor: c_int) void { + _ = c.SDL_SetTextInputArea(window, rect, cursor); +} + pub fn deinit(p: *Platform) void { c.SDL_DestroyRenderer(p.renderer); c.SDL_DestroyWindow(p.window); From 77f8f3eba3762d006d7b994217eaed9538bf4ab4 Mon Sep 17 00:00:00 2001 From: Forketyfork Date: Fri, 16 Jan 2026 15:25:25 +0100 Subject: [PATCH 2/5] Remove pasteboard check on window focus Both emoji picker and Superwhisper simulate Cmd+V after putting text on the pasteboard. The Cmd+V interception handles both cases, so the pasteboard check on window focus was causing double-paste and also incorrectly pasting clipboard contents when switching back to the app. --- src/platform/macos_text_input.m | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/src/platform/macos_text_input.m b/src/platform/macos_text_input.m index be86410..2fd0d7a 100644 --- a/src/platform/macos_text_input.m +++ b/src/platform/macos_text_input.m @@ -22,7 +22,6 @@ static id g_textDidChangeObserver = nil; static id g_windowDidBecomeKeyObserver = nil; static NSWindow* g_window = NULL; -static NSInteger g_lastPasteboardChangeCount = 0; // Custom NSTextView subclass that captures external text input but forwards // regular keyboard events to SDL's view @@ -202,9 +201,6 @@ void macos_text_input_init(void* nswindow, TextInputCallback callback, void* use g_callback = callback; g_userdata = userdata; - // Initialize pasteboard change count to avoid pasting stale content - g_lastPasteboardChangeCount = [[NSPasteboard generalPasteboard] changeCount]; - NSWindow* window = (__bridge NSWindow*)nswindow; g_window = window; NSView* contentView = [window contentView]; @@ -272,19 +268,6 @@ void macos_text_input_init(void* nswindow, TextInputCallback callback, void* use if (ctx) { [ctx activate]; } - - // Check if external input source (emoji picker, etc.) put text on pasteboard - NSPasteboard* pb = [NSPasteboard generalPasteboard]; - NSInteger currentChangeCount = [pb changeCount]; - if (currentChangeCount != g_lastPasteboardChangeCount) { - NSString* pbText = [pb stringForType:NSPasteboardTypeString]; - g_lastPasteboardChangeCount = currentChangeCount; - - // Send the pasteboard text through our callback - if (pbText && pbText.length > 0 && g_callback) { - g_callback([pbText UTF8String], g_userdata); - } - } } }]; From 9f424e2bcfbbe0e927efc501c443eb9ba74c0550 Mon Sep 17 00:00:00 2001 From: Forketyfork Date: Fri, 16 Jan 2026 16:11:33 +0100 Subject: [PATCH 3/5] Improvements --- src/platform/macos_text_input.m | 109 ++++++++------------------------ 1 file changed, 27 insertions(+), 82 deletions(-) diff --git a/src/platform/macos_text_input.m b/src/platform/macos_text_input.m index 2fd0d7a..3ec267c 100644 --- a/src/platform/macos_text_input.m +++ b/src/platform/macos_text_input.m @@ -23,57 +23,23 @@ static id g_windowDidBecomeKeyObserver = nil; static NSWindow* g_window = NULL; -// Custom NSTextView subclass that captures external text input but forwards -// regular keyboard events to SDL's view -@interface AccessibleTextInputView : NSTextView -@property (nonatomic, weak) NSView* sdlContentView; -@property (nonatomic, strong) NSTextInputContext* customInputContext; +// Custom NSTextView subclass that captures external text input +// SDL receives keyboard events through its own mechanism +@interface AccessibleTextInputView : NSTextView @end @implementation AccessibleTextInputView -- (instancetype)initWithFrame:(NSRect)frameRect { - self = [super initWithFrame:frameRect]; - if (self) { - // Create input context immediately so the text input system can find us - self.customInputContext = [[NSTextInputContext alloc] initWithClient:self]; - } - return self; -} - - (BOOL)acceptsFirstResponder { return YES; } -- (BOOL)becomeFirstResponder { - return [super becomeFirstResponder]; -} - -- (BOOL)resignFirstResponder { - // Always refuse to resign - we want to stay the first responder so external - // input sources (emoji picker, dictation) can send text to us - return NO; -} - -// Forward keyboard events to SDL's content view for key handling (shortcuts, etc.) +// Intercept Cmd+V for paste - external apps like Superwhisper simulate this +// after putting text on the pasteboard. SDL receives other keys through its own mechanism. - (void)keyDown:(NSEvent*)event { - // Intercept Cmd+V (paste) - handle it ourselves since SDL won't receive it properly - // when we're the first responder. This also enables apps like Superwhisper that - // simulate Cmd+V after putting text on the pasteboard. NSEventModifierFlags cmdOnly = event.modifierFlags & NSEventModifierFlagDeviceIndependentFlagsMask; - if (cmdOnly == NSEventModifierFlagCommand && event.keyCode == 9) { // 'v' key + if (cmdOnly == NSEventModifierFlagCommand && event.keyCode == 9) { // Cmd+V [self paste:nil]; - return; - } - - if (self.sdlContentView) { - [self.sdlContentView keyDown:event]; - } -} - -- (void)keyUp:(NSEvent*)event { - if (self.sdlContentView) { - [self.sdlContentView keyUp:event]; } } @@ -86,11 +52,11 @@ - (void)paste:(id)sender { } } -- (void)flagsChanged:(NSEvent*)event { - if (self.sdlContentView) { - [self.sdlContentView flagsChanged:event]; - } -} +// Prevent NSTextView from handling editing commands - SDL handles these +- (void)deleteBackward:(id)sender {} +- (void)deleteForward:(id)sender {} +- (void)deleteWordBackward:(id)sender {} +- (void)deleteWordForward:(id)sender {} // Pass mouse events through to SDL's view underneath - (NSView*)hitTest:(NSPoint)point { @@ -103,19 +69,8 @@ - (BOOL)acceptsMouseMovedEvents { -// Provide our own input context since NSTextView's default one is null in this configuration -- (NSTextInputContext*)inputContext { - return self.customInputContext; -} -// Override both insertText variants - some apps use the older one without replacementRange -- (void)insertText:(id)string { - [self insertText:string replacementRange:NSMakeRange(NSNotFound, 0)]; -} - -// Override insertText to capture all text input (keyboard and external) -// We forward everything through our callback since SDL can't receive insertText -// when we're the first responder +// Override insertText to capture text input from external sources - (void)insertText:(id)string replacementRange:(NSRange)replacementRange { NSString* text = nil; if ([string isKindOfClass:[NSAttributedString class]]) { @@ -128,7 +83,6 @@ - (void)insertText:(id)string replacementRange:(NSRange)replacementRange { g_callback([text UTF8String], g_userdata); } - // Clear the text view after processing to keep it empty [self setString:@""]; } @@ -213,22 +167,21 @@ void macos_text_input_init(void* nswindow, TextInputCallback callback, void* use // This ensures it's the target for accessibility-based text input NSRect frame = contentView.bounds; g_textView = [[AccessibleTextInputView alloc] initWithFrame:frame]; - g_textView.sdlContentView = contentView; // Configure the text view [g_textView setEditable:YES]; - [g_textView setSelectable:NO]; + [g_textView setSelectable:YES]; [g_textView setRichText:NO]; [g_textView setImportsGraphics:NO]; [g_textView setAllowsUndo:NO]; + [g_textView setInsertionPointColor:[NSColor clearColor]]; // Hide cursor // Make it nearly transparent but still functional - // Note: alpha=0 causes inputContext to be null, breaking text input - [g_textView setAlphaValue:0.01]; // Nearly invisible but functional + // Note: alpha=0 causes issues with text input + [g_textView setAlphaValue:0.01]; [g_textView setBackgroundColor:[NSColor clearColor]]; [g_textView setDrawsBackground:NO]; - // Auto-resize with the window [g_textView setAutoresizingMask:NSViewWidthSizable | NSViewHeightSizable]; @@ -238,6 +191,17 @@ void macos_text_input_init(void* nswindow, TextInputCallback callback, void* use // Make it the first responder so external input sources can find it [window makeFirstResponder:g_textView]; + // Reclaim first responder when window becomes key again + g_windowDidBecomeKeyObserver = [[NSNotificationCenter defaultCenter] + addObserverForName:NSWindowDidBecomeKeyNotification + object:window + queue:[NSOperationQueue mainQueue] + usingBlock:^(NSNotification* note) { + if (g_textView && g_window) { + [g_window makeFirstResponder:g_textView]; + } + }]; + // Observe text changes as a fallback g_textDidChangeObserver = [[NSNotificationCenter defaultCenter] addObserverForName:NSTextDidChangeNotification @@ -251,25 +215,6 @@ void macos_text_input_init(void* nswindow, TextInputCallback callback, void* use } }]; - // Reclaim first responder when window becomes key again - // This is critical for receiving text from emoji picker, dictation, etc. - g_windowDidBecomeKeyObserver = [[NSNotificationCenter defaultCenter] - addObserverForName:NSWindowDidBecomeKeyNotification - object:window - queue:[NSOperationQueue mainQueue] - usingBlock:^(NSNotification* note) { - if (g_textView && g_window) { - id firstResponder = [g_window firstResponder]; - if (firstResponder != g_textView) { - [g_window makeFirstResponder:g_textView]; - } - // Activate the input context to signal we're ready for input - NSTextInputContext* ctx = [g_textView inputContext]; - if (ctx) { - [ctx activate]; - } - } - }]; } } From a2558a64d64b3d0da18c3255c0b9239f1858e76c Mon Sep 17 00:00:00 2001 From: Forketyfork Date: Fri, 16 Jan 2026 17:05:00 +0100 Subject: [PATCH 4/5] fix: avoid double backspace Issue: Backspace deletes two characters after adding accessibility paste/input support. Solution: Route SDL and accessibility text input through a shared handler. Filter backspace control bytes (0x08/0x7f) out of text payloads to avoid duplicate deletes. Document macOS accessibility input and backspace filtering behavior. --- README.md | 1 + docs/architecture.md | 1 + src/main.zig | 36 +++++++++++++++++++++++++++--------- 3 files changed, 29 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 8d7cf7e..dd547dd 100644 --- a/README.md +++ b/README.md @@ -624,6 +624,7 @@ The application uses cubic ease-in-out interpolation to smoothly transition betw - Claude Code integration via Unix domain sockets - Scrolling back through terminal history (mouse wheel) with a grid indicator when a pane is scrolled - Text selection in full view with clipboard copy/paste (drag, ⌘C / ⌘V) +- macOS accessibility text input for external sources (emoji picker, speech-to-text) - Cmd+Click to open hyperlinks (OSC 8) in your default browser - Kitty keyboard protocol negotiation (query response and conditional CSI-u encoding for Shift+Enter/Tab/Backspace) diff --git a/docs/architecture.md b/docs/architecture.md index c8c209f..00f3113 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -241,6 +241,7 @@ struct { 4. `ui.handleEvent()` dispatches to components (topmost-first by z-index) 5. If consumed, skip app handlers; otherwise continue to main event switch 6. `ui.hitTest()` used for cursor changes in full view +7. Text input filters out backspace control bytes (0x08/0x7f) so backspace comes from key events only Components that consume events: - `HelpOverlayComponent`: ⌘? pill click or Cmd+/ to toggle overlay diff --git a/src/main.zig b/src/main.zig index 3d8efd2..ee101a1 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1073,11 +1073,9 @@ pub fn main() !void { if (accessible_text_input.pollText()) |text| { defer allocator.free(text); const focused = &sessions[anim_state.focused_session]; - if (focused.spawned and !focused.dead) { - focused.sendInput(text) catch |err| { - log.err("Failed to send accessible text input: {}", .{err}); - }; - } + handleTextSlice(focused, text) catch |err| { + log.err("Failed to send accessible text input: {}", .{err}); + }; } try loop.run(.no_wait); @@ -2370,14 +2368,34 @@ fn pasteText(session: *SessionState, allocator: std.mem.Allocator, text: []const } fn handleTextInput(session: *SessionState, text_ptr: [*c]const u8) !void { - if (!session.spawned or session.dead) return; if (text_ptr == null) return; - const text = std.mem.sliceTo(text_ptr, 0); + try handleTextSlice(session, text); +} + +fn handleTextSlice(session: *SessionState, text: []const u8) !void { + if (!session.spawned or session.dead) return; if (text.len == 0) return; - resetScrollIfNeeded(session); - try session.sendInput(text); + var start: usize = 0; + var idx: usize = 0; + var sent_any = false; + while (idx < text.len) : (idx += 1) { + const ch = text[idx]; + if (ch == 8 or ch == 0x7f) { + if (idx > start) { + if (!sent_any) resetScrollIfNeeded(session); + try session.sendInput(text[start..idx]); + sent_any = true; + } + start = idx + 1; + } + } + + if (start < text.len) { + if (!sent_any) resetScrollIfNeeded(session); + try session.sendInput(text[start..]); + } } fn clearTerminal(session: *SessionState) void { From 823de3772f51bd2d03889bfdc6fd1ca4130a57a4 Mon Sep 17 00:00:00 2001 From: Forketyfork Date: Fri, 16 Jan 2026 21:41:56 +0100 Subject: [PATCH 5/5] refactor: address PR review feedback - Queue multiple text events using ArrayList instead of replacing - Add named constants for control characters (CTRL_BACKSPACE, CTRL_DELETE) - Add named constants for macOS text input (kTextViewAlpha, kKeyCodeV) --- src/main.zig | 6 +++++- src/platform/macos_text_input.m | 10 ++++++---- src/platform/macos_text_input.zig | 30 +++++++++++++----------------- 3 files changed, 24 insertions(+), 22 deletions(-) diff --git a/src/main.zig b/src/main.zig index ee101a1..224094c 100644 --- a/src/main.zig +++ b/src/main.zig @@ -2373,6 +2373,10 @@ fn handleTextInput(session: *SessionState, text_ptr: [*c]const u8) !void { try handleTextSlice(session, text); } +// Control characters for backspace filtering in text input +const CTRL_BACKSPACE: u8 = 0x08; // ASCII backspace +const CTRL_DELETE: u8 = 0x7f; // ASCII delete + fn handleTextSlice(session: *SessionState, text: []const u8) !void { if (!session.spawned or session.dead) return; if (text.len == 0) return; @@ -2382,7 +2386,7 @@ fn handleTextSlice(session: *SessionState, text: []const u8) !void { var sent_any = false; while (idx < text.len) : (idx += 1) { const ch = text[idx]; - if (ch == 8 or ch == 0x7f) { + if (ch == CTRL_BACKSPACE or ch == CTRL_DELETE) { if (idx > start) { if (!sent_any) resetScrollIfNeeded(session); try session.sendInput(text[start..idx]); diff --git a/src/platform/macos_text_input.m b/src/platform/macos_text_input.m index 3ec267c..23ec457 100644 --- a/src/platform/macos_text_input.m +++ b/src/platform/macos_text_input.m @@ -12,6 +12,10 @@ // Callback function type for delivering text to Zig code typedef void (*TextInputCallback)(const char* text, void* userdata); +// Constants +static const CGFloat kTextViewAlpha = 0.01; // Nearly transparent but still functional for text input +static const unsigned short kKeyCodeV = 9; // macOS virtual key code for 'V' key + // Forward declaration @class AccessibleTextInputView; @@ -38,7 +42,7 @@ - (BOOL)acceptsFirstResponder { // after putting text on the pasteboard. SDL receives other keys through its own mechanism. - (void)keyDown:(NSEvent*)event { NSEventModifierFlags cmdOnly = event.modifierFlags & NSEventModifierFlagDeviceIndependentFlagsMask; - if (cmdOnly == NSEventModifierFlagCommand && event.keyCode == 9) { // Cmd+V + if (cmdOnly == NSEventModifierFlagCommand && event.keyCode == kKeyCodeV) { [self paste:nil]; } } @@ -176,9 +180,7 @@ void macos_text_input_init(void* nswindow, TextInputCallback callback, void* use [g_textView setAllowsUndo:NO]; [g_textView setInsertionPointColor:[NSColor clearColor]]; // Hide cursor - // Make it nearly transparent but still functional - // Note: alpha=0 causes issues with text input - [g_textView setAlphaValue:0.01]; + [g_textView setAlphaValue:kTextViewAlpha]; [g_textView setBackgroundColor:[NSColor clearColor]]; [g_textView setDrawsBackground:NO]; diff --git a/src/platform/macos_text_input.zig b/src/platform/macos_text_input.zig index 4f00fff..f09e669 100644 --- a/src/platform/macos_text_input.zig +++ b/src/platform/macos_text_input.zig @@ -20,7 +20,7 @@ const c = if (is_macos) struct { const MAX_TEXT_SIZE = 4096; pub const AccessibleTextInput = if (is_macos) struct { - pending_text: ?[]u8 = null, + pending_text: std.ArrayList(u8), allocator: std.mem.Allocator, nswindow: ?*anyopaque, @@ -28,11 +28,11 @@ pub const AccessibleTextInput = if (is_macos) struct { var global_instance: ?*AccessibleTextInput = null; pub fn init(allocator: std.mem.Allocator, nswindow: ?*anyopaque) AccessibleTextInput { - const self = AccessibleTextInput{ + return .{ + .pending_text = .empty, .allocator = allocator, .nswindow = nswindow, }; - return self; } /// Must be called after init, once the struct is at its final memory location. @@ -44,10 +44,7 @@ pub const AccessibleTextInput = if (is_macos) struct { pub fn deinit(self: *AccessibleTextInput) void { c.macos_text_input_deinit(); - if (self.pending_text) |text| { - self.allocator.free(text); - self.pending_text = null; - } + self.pending_text.deinit(self.allocator); global_instance = null; } @@ -69,8 +66,12 @@ pub const AccessibleTextInput = if (is_macos) struct { /// Poll for pending text input. Returns the text and clears the pending state. /// Caller must free the returned slice. pub fn pollText(self: *AccessibleTextInput) ?[]u8 { - const text = self.pending_text; - self.pending_text = null; + if (self.pending_text.items.len == 0) return null; + const text = self.pending_text.toOwnedSlice(self.allocator) catch { + log.err("Failed to convert pending text to owned slice", .{}); + self.pending_text.clearRetainingCapacity(); + return null; + }; return text; } @@ -82,14 +83,9 @@ pub const AccessibleTextInput = if (is_macos) struct { const text = std.mem.sliceTo(text_ptr, 0); if (text.len == 0) return; - // Free any existing pending text - if (instance.pending_text) |old| { - instance.allocator.free(old); - } - - // Store the new text - instance.pending_text = instance.allocator.dupe(u8, text) catch { - log.err("Failed to allocate text buffer", .{}); + // Append to pending text buffer (preserves earlier chunks) + instance.pending_text.appendSlice(instance.allocator, text) catch { + log.err("Failed to append to text buffer", .{}); return; }; }