Skip to content
Merged
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
5 changes: 5 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,11 @@ struct {
5. If consumed, skip app handlers; otherwise continue to main event switch
6. `ui.hitTest()` used for cursor changes in full view

Text input notes:
- `SDL_EVENT_TEXT_INPUT` is treated as committed text for the focused session.
- `SDL_EVENT_TEXT_EDITING` updates are treated as preedit; the prior composition is removed with delete (0x7f) before inserting the latest composition, so macOS IME/dictation updates replace in place.
- When focus switches between sessions, any in-flight preedit text is cleared from the previously focused session.

Components that consume events:
- `HelpOverlayComponent`: ⌘? pill click or Cmd+/ to toggle overlay
- `WorktreeOverlayComponent`: ⌘T pill, Cmd+T, Cmd+1–9 to cd the focused shell into a worktree; Cmd+0 opens a creation modal that builds `.architect/<name>` via `git worktree add -b <name>` and cds into it; pill is hidden when a foreground process is running; refreshes its list on every open, reads worktrees from git metadata (commondir and linked worktree dirs only), highlights rows on hover with a gradient, supports click selection, limits the list to 9 entries, and displays paths relative to the primary worktree; includes delete (×) button to remove non-root worktrees
Expand Down
99 changes: 91 additions & 8 deletions src/main.zig
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,14 @@ const FontSizeDirection = input.FontSizeDirection;
const GridNavDirection = input.GridNavDirection;
const CursorKind = enum { arrow, ibeam, pointer };

const ImeComposition = struct {
codepoints: usize = 0,

fn reset(self: *ImeComposition) void {
self.codepoints = 0;
}
};

fn countForegroundProcesses(sessions: []const SessionState) usize {
var total: usize = 0;
for (sessions) |*session| {
Expand Down Expand Up @@ -332,6 +340,8 @@ pub fn main() !void {
.start_rect = Rect{ .x = 0, .y = 0, .w = 0, .h = 0 },
.target_rect = Rect{ .x = 0, .y = 0, .w = 0, .h = 0 },
};
var ime_composition = ImeComposition{};
var last_focused_session: usize = anim_state.focused_session;

const worktree_comp_ptr = try allocator.create(ui_mod.worktree_overlay.WorktreeOverlayComponent);
worktree_comp_ptr.* = .{ .allocator = allocator };
Expand Down Expand Up @@ -389,6 +399,14 @@ pub fn main() !void {
var event: c.SDL_Event = undefined;
var processed_event = false;
while (c.SDL_PollEvent(&event)) {
if (anim_state.focused_session != last_focused_session) {
const previous_session = last_focused_session;
clearImeComposition(&sessions[previous_session], &ime_composition) catch |err| {
std.debug.print("Failed to clear IME composition: {}\n", .{err});
};
ime_composition.reset();
last_focused_session = anim_state.focused_session;
}
processed_event = true;
var scaled_event = scaleEventToRender(&event, scale_x, scale_y);
const session_ui_info = try allocator.alloc(ui_mod.SessionUiInfo, grid_count);
Expand Down Expand Up @@ -467,6 +485,7 @@ pub fn main() !void {
text_input_active = false;
}
}
ime_composition.reset();
},
c.SDL_EVENT_WINDOW_FOCUS_GAINED => {
if (builtin.os.tag == .macos) {
Expand All @@ -490,18 +509,21 @@ pub fn main() !void {
},
c.SDL_EVENT_TEXT_INPUT => {
const focused = &sessions[anim_state.focused_session];
handleTextInput(focused, scaled_event.text.text) catch |err| {
handleTextInput(focused, &ime_composition, 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];
// 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| {
std.debug.print("Edit input failed: {}\n", .{err});
};
}
handleTextEditing(
focused,
&ime_composition,
scaled_event.edit.text,
scaled_event.edit.start,
scaled_event.edit.length,
) catch |err| {
std.debug.print("Edit input failed: {}\n", .{err});
};
},
c.SDL_EVENT_DROP_FILE => {
const drop_path_ptr = scaled_event.drop.data;
Expand Down Expand Up @@ -2323,14 +2345,75 @@ fn pasteText(session: *SessionState, allocator: std.mem.Allocator, text: []const
}
}

fn handleTextInput(session: *SessionState, text_ptr: [*c]const u8) !void {
fn countImeCodepoints(text: []const u8) usize {
return std.unicode.utf8CountCodepoints(text) catch text.len;
}

fn sendDeleteInput(session: *SessionState, count: usize) !void {
if (count == 0) return;

var buf: [16]u8 = undefined;
@memset(buf[0..], 0x7f);

var remaining: usize = count;
while (remaining > 0) {
const chunk: usize = @min(remaining, buf.len);
try session.sendInput(buf[0..chunk]);
remaining -= chunk;
}
}

fn clearImeComposition(session: *SessionState, ime: *ImeComposition) !void {
if (ime.codepoints == 0) return;
if (!session.spawned or session.dead) {
ime.codepoints = 0;
return;
}

try sendDeleteInput(session, ime.codepoints);
ime.codepoints = 0;
}

fn handleTextEditing(
session: *SessionState,
ime: *ImeComposition,
text_ptr: [*c]const u8,
start: c_int,
length: c_int,
) !void {
if (!session.spawned or session.dead) return;
if (text_ptr == null) return;

const text = std.mem.sliceTo(text_ptr, 0);
if (text.len == 0) {
if (ime.codepoints == 0) return;
resetScrollIfNeeded(session);
try clearImeComposition(session, ime);
return;
}

resetScrollIfNeeded(session);
const is_committed_text = length == 0 and start == 0;
if (is_committed_text) {
try clearImeComposition(session, ime);
try session.sendInput(text);
return;
}

try clearImeComposition(session, ime);
try session.sendInput(text);
ime.codepoints = countImeCodepoints(text);
}

fn handleTextInput(session: *SessionState, ime: *ImeComposition, 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);
if (text.len == 0) return;

resetScrollIfNeeded(session);
try clearImeComposition(session, ime);
try session.sendInput(text);
}

Expand Down