Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,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 });
Expand Down
1 change: 1 addition & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,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
Expand Down
4 changes: 4 additions & 0 deletions src/c.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
98 changes: 81 additions & 17 deletions src/main.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
Expand All @@ -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 => {
Expand All @@ -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| {
Expand Down Expand Up @@ -1034,6 +1068,16 @@ 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];
handleTextSlice(focused, text) catch |err| {
log.err("Failed to send accessible text input: {}", .{err});
};
}

try loop.run(.no_wait);

var any_session_dirty = false;
Expand Down Expand Up @@ -2324,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) {
Copy link

Copilot AI Jan 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The magic numbers 8 and 0x7f for backspace control bytes should be defined as named constants (e.g., BACKSPACE_CTRL and DELETE_CTRL) to improve code clarity and maintainability.

Copilot uses AI. Check for mistakes.
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Added CTRL_BACKSPACE and CTRL_DELETE named constants.

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 {
Expand Down
Loading