Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
102 changes: 85 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,38 @@ 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);
}

// 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;

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 == CTRL_BACKSPACE or ch == CTRL_DELETE) {
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