summaryrefslogtreecommitdiff
path: root/src/macos.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/macos.zig')
-rw-r--r--src/macos.zig267
1 files changed, 249 insertions, 18 deletions
diff --git a/src/macos.zig b/src/macos.zig
index d463b842..d3f9577c 100644
--- a/src/macos.zig
+++ b/src/macos.zig
@@ -28,6 +28,10 @@ const look = @import("look.zig");
const temp_file = @import("temp_file.zig");
const shell_bin = @import("shell_bin.zig");
const message = @import("message.zig");
+const nested = @import("nested.zig");
+const fonts = if (pardes.font_picker) @import("fonts.zig") else struct {
+ pub const want: ?[]const u8 = null;
+};
const user_config = @import("user_config.zig");
extern "c" fn forkpty(amaster: *c_int, name: ?[*:0]u8, termp: ?*const anyopaque, winp: ?*const posix.winsize) c_int;
@@ -108,11 +112,15 @@ const Pty = struct {
const Msg = union(enum) {
output: struct { pane: u8, gen: u32, bytes: []u8 },
eof: struct { pane: u8, gen: u32 },
+ /// One `Look <path>` line from a pardes launched inside this one. Arrives
+ /// on the listener thread; runs, like everything else, on the main one.
+ command: []u8,
fn free(m: Msg, gpa: std.mem.Allocator) void {
switch (m) {
.output => |o| gpa.free(o.bytes),
.eof => {},
+ .command => |c| gpa.free(c),
}
}
};
@@ -156,8 +164,11 @@ const Inbox = struct {
return removed;
}
- /// Pty output is lossy under sustained backpressure. EOF is structural:
- /// admit it by evicting queued output so dead readers are always reaped.
+ /// Pty output is lossy under sustained backpressure. EOF is structural, and
+ /// so is a nested `Look`: one is a reader that must be reaped, the other is
+ /// a launch that already exited believing it was delivered. Admit both by
+ /// evicting queued output. Every switch below is exhaustive on purpose — a
+ /// new message kind has to say which of the two it is.
fn push(q: *Inbox, gpa: std.mem.Allocator, m: Msg) void {
q.lock();
defer q.mutex.unlock();
@@ -166,11 +177,11 @@ const Inbox = struct {
return;
}
if (q.len == q.items.len) {
- const incoming_eof = switch (m) {
- .eof => true,
- .output => false,
+ const lossy = switch (m) {
+ .output => true,
+ .eof, .command => false,
};
- if (!incoming_eof) {
+ if (lossy) {
m.free(gpa);
return;
}
@@ -178,7 +189,7 @@ const Inbox = struct {
while (offset < q.len) : (offset += 1)
if (switch (q.items[(q.head + offset) % q.items.len]) {
.output => true,
- .eof => false,
+ .eof, .command => false,
}) break;
if (offset == q.len) return;
q.removeAt(offset).free(gpa);
@@ -234,10 +245,30 @@ const State = struct {
/// copy in every message it posts; anything that no longer matches belongs
/// to a shell this slot has already replaced.
gens: [pardes.MAX_PANES]u32 = @splat(0),
- /// Sub-row wheel distance the core has not been told about yet. The core
- /// moves a whole row at a time, so fractional trackpad travel accumulates
- /// here and is spent as wheel presses — see pardes_scroll.
+ /// Sub-cell wheel distance the core has not been told about yet, one
+ /// accumulator per axis. The core moves a whole row or column at a time,
+ /// so fractional trackpad travel banks here and is spent as wheel presses
+ /// — see pardes_scroll. Separate axes because a diagonal drift must not
+ /// let one direction's residue push the other over a notch.
scroll_lag: f32 = 0,
+ scroll_lag_x: f32 = 0,
+ /// Degrees of trackpad rotation not yet spent as a search step — the same
+ /// accumulate-and-keep-the-remainder shape as scroll_lag, see pardes_rotate.
+ rotate_lag: f32 = 0,
+ /// Panes whose shell has produced output since we last read its cwd.
+ ///
+ /// The cwd is wanted for pane tags and for resolving a relative Look, and
+ /// asking libproc costs a syscall per pane. Polling it on a clock spends
+ /// that forever to notice something that only ever changes when the shell
+ /// runs a command — and a shell that ran a command always writes at least
+ /// its next prompt. So the read is owed to output, not to time: mark here
+ /// on the way past and settle it once at the end of the drain, however
+ /// many chunks that burst arrived in.
+ cwd_stale: [pardes.MAX_PANES]bool = @splat(false),
+ /// The socket a pardes launched inside this app connects to (nested.zig),
+ /// or -1 when it could not be bound and nested launches open their own
+ /// window as they always did.
+ sock_fd: c_int = -1,
/// Owns the bytes of the user config, which Options only borrows.
config_arena: std.heap.ArenaAllocator,
};
@@ -335,10 +366,52 @@ fn initCore(runtime: ?*const Runtime, cols_arg: u16, rows_arg: u16) !void {
// the two backends readable side by side.
_ = drainEffects(st, false);
for (&st.ptys, 0..) |*slot, id| if (slot.*) |*pt| startReader(st, pt, @intCast(id));
+
+ // Last, because it is the one thing here that publishes this process to
+ // the outside: nothing may connect before the core can answer. The shells
+ // above are already forked, which is why the listener's fd is CLOEXEC —
+ // an orphaned bash holding it would keep the socket bound after we quit.
+ st.sock_fd = nested.listen();
+ if (st.sock_fd >= 0) {
+ const thread = std.Thread.spawn(.{}, lookServer, .{st}) catch |err| {
+ // Bound but unattended would be worse than never bound: every
+ // nested launch would connect, be believed, and vanish.
+ log.warn("nested Look server did not start ({t})", .{err});
+ nested.unlisten(st.sock_fd);
+ st.sock_fd = -1;
+ return;
+ };
+ thread.detach();
+ }
+}
+
+/// Accept `Look <path>` lines from pardes instances launched inside this app
+/// and post them where the main thread will run them.
+///
+/// A detached thread around a call that never returns, exactly like the tty
+/// backend's: close(2) does not release a thread parked in accept(2), so this
+/// dies with the process rather than with the socket. The window that leaves
+/// is one connection accepted between the last tick and process exit posting
+/// into an inbox nobody drains — the same bound the pty readers have, and a
+/// self-pipe to close it would be more machinery than the window is worth.
+fn lookServer(st: *State) void {
+ var buf: [nested.max_line]u8 = undefined;
+ while (nested.acceptLine(st.sock_fd, &buf)) |line| {
+ const owned = st.gpa.dupe(u8, line) catch continue;
+ st.inbox.push(st.gpa, .{ .command = owned });
+ wake(st);
+ }
}
export fn pardes_deinit() void {
const st = &(state orelse return);
+ // Before anything else: it is the only fd another process can reach us
+ // through, and unlinking the file is what stops the next launch from
+ // connecting to a session that is halfway through tearing itself down.
+ // The thread parked in accept(2) is not released by this and dies with
+ // the process, which is what its detach() already said.
+ nested.unlisten(st.sock_fd);
+ st.sock_fd = -1;
// Every reader is joined here, before anything it touches is freed. The
// runtime joins its tasks on exit, so a reader left parked in read(2) would
// hang the process instead of the app quitting.
@@ -369,9 +442,33 @@ export fn pardes_animating() bool {
return st.core.themeAnimationActive();
}
+/// Re-read the cwd of every shell that just spoke, and only those.
+///
+/// A pane's tag shows this and a relative `Look` resolves against it, so it has
+/// to follow the shell around rather than stay at the directory the pane was
+/// spawned in. The tty and SDL hosts poll all of them every frame; here the
+/// drain has just said exactly which shells produced bytes, and nothing else
+/// can have changed one — a `cd` is a command, and a shell that ran a command
+/// writes at least its next prompt. So an idle session costs nothing at all,
+/// and a busy one costs one libproc call per pane per burst.
+fn refreshCwds(st: *State) void {
+ for (&st.cwd_stale, 0..) |*stale, id| {
+ if (!stale.*) continue;
+ stale.* = false;
+ const pt = st.ptys[id] orelse continue;
+ var buf: [1024]u8 = undefined;
+ if (look.shellCwd(pt.pid, &buf)) |wd| st.core.setCwd(id, wd);
+ }
+}
+
/// Drain what the reader tasks collected into the core, then perform whatever
-/// the core queued in response. Returns whether anything moved, so an idle
-/// wakeup does not cost the host a repaint.
+/// the core queued in response. Returns whether this tick did any IO.
+///
+/// NOT a repaint signal, however tempting: the core changes the grid on its own
+/// for a cursor move, a selection, a mode change and a scroll, none of which
+/// queue an effect or read a pty, so all four return false here. The macOS host
+/// learned that the expensive way — see the comment on pump() in
+/// src/macos/Sources/AppDelegate.swift.
export fn pardes_tick() bool {
const st = &(state orelse return false);
// Cleared before the drain: a reader that pushes during this tick must be
@@ -384,6 +481,7 @@ export fn pardes_tick() bool {
switch (msg) {
.output => |o| {
if (st.gens[o.pane] != o.gen) continue;
+ st.cwd_stale[o.pane] = true;
st.core.update(.{ .output = .{ .pane = o.pane, .bytes = o.bytes } });
},
.eof => |e| {
@@ -394,8 +492,12 @@ export fn pardes_tick() bool {
reap(st, e.pane);
st.core.update(.{ .eof = .{ .pane = e.pane } });
},
+ // Already filtered down to `Look ` by the accept side — this
+ // socket may open things and that is all it may do.
+ .command => |c| st.core.update(.{ .command = c }),
}
}
+ refreshCwds(st);
if (drainEffects(st, true)) changed = true;
// A live theme transition repaints on its own clock; say so, or the host
// stops ticking and the fade freezes half-applied.
@@ -456,13 +558,12 @@ export fn pardes_mouse(button_arg: c_int, kind_arg: c_int, col: u16, row: u16, m
} });
}
-export fn pardes_scroll(delta_rows: f32, col: u16, row: u16) void {
+export fn pardes_scroll(delta_rows: f32, delta_cols: f32, col: u16, row: u16) void {
const st = &(state orelse return);
- const ticks = takeScrollTicks(&st.scroll_lag, delta_rows);
- var left = ticks;
- while (left != 0) {
- const down = left > 0;
- left += if (down) -1 else 1;
+ var down_left = takeScrollTicks(&st.scroll_lag, delta_rows);
+ while (down_left != 0) {
+ const down = down_left > 0;
+ down_left += if (down) -1 else 1;
st.core.update(.{ .mouse = .{
.button = if (down) .wheel_down else .wheel_up,
.kind = .press,
@@ -470,6 +571,48 @@ export fn pardes_scroll(delta_rows: f32, col: u16, row: u16) void {
.row = row,
} });
}
+ // Horizontal after vertical, and through the same quantizer: the core's
+ // own drift guard (config.wheelTick) is what decides whether a sideways
+ // wobble during a vertical flick counts, so the shell must not second-guess
+ // it by filtering here.
+ var right_left = takeScrollTicks(&st.scroll_lag_x, delta_cols);
+ while (right_left != 0) {
+ const right = right_left > 0;
+ right_left += if (right) -1 else 1;
+ st.core.update(.{ .mouse = .{
+ .button = if (right) .wheel_right else .wheel_left,
+ .kind = .press,
+ .col = col,
+ .row = row,
+ } });
+ }
+}
+
+/// Spend a trackpad rotation as search steps. AppKit reports degrees since the
+/// last event, counterclockwise positive; the core has no rotation, so the
+/// dial is quantized into the keys a hand would otherwise press — clockwise is
+/// `n` (forward through the matches), counterclockwise `N`.
+export fn pardes_rotate(degrees: f32) void {
+ const st = &(state orelse return);
+ // A gesture beginning re-zeros the dial: leftover travel from the last
+ // twist must not make the first degree of this one jump a match.
+ if (degrees == 0) {
+ st.rotate_lag = 0;
+ return;
+ }
+ var left = takeRotationNotches(&st.rotate_lag, degrees);
+ while (left != 0) {
+ const back = left > 0; // counterclockwise
+ left += if (back) -1 else 1;
+ st.core.update(.{ .key = .{ .cp = if (back) 'N' else 'n' } });
+ }
+}
+
+export fn pardes_command(text_ptr: ?[*]const u8, len: usize) void {
+ const st = &(state orelse return);
+ const text: []const u8 = if (text_ptr) |p| p[0..len] else "";
+ if (text.len == 0) return;
+ st.core.update(.{ .command = text });
}
export fn pardes_resize(cols_arg: u16, rows_arg: u16, cell_w: u16, cell_h: u16) void {
@@ -561,6 +704,38 @@ export fn pardes_cursor_bar() bool {
return if (st.core.surface.cursor) |c| c.bar else false;
}
+/// The acme verb the core last performed, and clears it. Ordinals, not the
+/// enum: the host is not part of this build, so the boundary speaks integers
+/// and the ABI guard asserts they are the ones the header names.
+export fn pardes_take_haptic() c_int {
+ const st = &(state orelse return 0);
+ return switch (st.core.takeHaptic()) {
+ .none => 0,
+ .exec => 1,
+ .look => 2,
+ };
+}
+
+/// The file the `Font` builtin asked for, and clears it — the same take-once
+/// shape as the haptic above, and the same one the SDL shell uses on this
+/// exact variable.
+///
+/// A copy rather than the borrowed slice: `fonts.want` is a length and no
+/// terminator, and C wants a string. One static buffer because there is one
+/// core and the header promises the value only until the next call.
+var font_path_z: [4096:0]u8 = undefined;
+
+export fn pardes_font_take() ?[*:0]const u8 {
+ _ = state orelse return null;
+ if (comptime !pardes.font_picker) return null;
+ const want = fonts.want orelse return null;
+ fonts.want = null;
+ if (want.len >= font_path_z.len) return null;
+ @memcpy(font_path_z[0..want.len], want);
+ font_path_z[want.len] = 0;
+ return &font_path_z;
+}
+
// ---------------------------------------------------------------- effects
/// Perform the IO the core queued. `threads_ok` is false for the one drain
@@ -826,6 +1001,25 @@ fn takeScrollTicks(lag: *f32, delta_rows: f32) i32 {
return whole;
}
+/// One search step per this many degrees of twist. A trackpad rotation runs
+/// tens of degrees before it feels deliberate, and every notch here is a jump
+/// to another match — coarse on purpose, so a thumb resettling cannot walk the
+/// cursor across the file.
+const rotation_notch_degrees: f32 = 20;
+
+/// Spend accumulated rotation as whole search steps, keeping the remainder.
+/// Same contract as takeScrollTicks, including the clamp: an absurd delta
+/// spends a bounded number of notches instead of spinning the emit loop.
+fn takeRotationNotches(lag: *f32, degrees: f32) i32 {
+ if (!std.math.isFinite(degrees)) return 0;
+ const limit = rotation_notch_degrees * 64;
+ const next = std.math.clamp(lag.* + degrees, -limit, limit);
+ if (!std.math.isFinite(next)) return 0;
+ const whole: i32 = @intFromFloat(@trunc(next / rotation_notch_degrees));
+ lag.* = next - @as(f32, @floatFromInt(whole)) * rotation_notch_degrees;
+ return whole;
+}
+
// ---------------------------------------------------------------- ABI guard
// The header is hand-written, so nothing but a test keeps it honest. build.zig
@@ -857,6 +1051,8 @@ test "pardes.h declares every export the way it is defined" {
try expectSameAbi(@TypeOf(c.pardes_paste), @TypeOf(pardes_paste));
try expectSameAbi(@TypeOf(c.pardes_mouse), @TypeOf(pardes_mouse));
try expectSameAbi(@TypeOf(c.pardes_scroll), @TypeOf(pardes_scroll));
+ try expectSameAbi(@TypeOf(c.pardes_rotate), @TypeOf(pardes_rotate));
+ try expectSameAbi(@TypeOf(c.pardes_command), @TypeOf(pardes_command));
try expectSameAbi(@TypeOf(c.pardes_resize), @TypeOf(pardes_resize));
try expectSameAbi(@TypeOf(c.pardes_frame), @TypeOf(pardes_frame));
try expectSameAbi(@TypeOf(c.pardes_frame_cells), @TypeOf(pardes_frame_cells));
@@ -865,6 +1061,8 @@ test "pardes.h declares every export the way it is defined" {
try expectSameAbi(@TypeOf(c.pardes_cursor_x), @TypeOf(pardes_cursor_x));
try expectSameAbi(@TypeOf(c.pardes_cursor_y), @TypeOf(pardes_cursor_y));
try expectSameAbi(@TypeOf(c.pardes_cursor_bar), @TypeOf(pardes_cursor_bar));
+ try expectSameAbi(@TypeOf(c.pardes_take_haptic), @TypeOf(pardes_take_haptic));
+ try expectSameAbi(@TypeOf(c.pardes_font_take), @TypeOf(pardes_font_take));
}
test "pardes.h matches the Zig boundary" {
@@ -914,6 +1112,12 @@ test "pardes.h matches the Zig boundary" {
try expectEqual(c.PARDES_MOUSE_MOTION, @intFromEnum(pardes.Mouse.Kind.motion));
try expectEqual(c.PARDES_MOUSE_DRAG, @intFromEnum(pardes.Mouse.Kind.drag));
+ // The haptic ordinals pardes_take_haptic returns, against the header's
+ // names and the core's enum. Three places, checked as one.
+ try expectEqual(c.PARDES_HAPTIC_NONE, @intFromEnum(pardes.Haptic.none));
+ try expectEqual(c.PARDES_HAPTIC_EXEC, @intFromEnum(pardes.Haptic.exec));
+ try expectEqual(c.PARDES_HAPTIC_LOOK, @intFromEnum(pardes.Haptic.look));
+
// The attribute bits the host decodes, against the encoder that writes them.
try expectEqual(@as(u16, c.PARDES_ATTR_BOLD), encodeAttrs(.{ .bold = true }));
try expectEqual(@as(u16, c.PARDES_ATTR_DIM), encodeAttrs(.{ .dim = true }));
@@ -957,3 +1161,30 @@ test "sub-row scroll spends whole notches and keeps the remainder" {
try expectEqual(@as(f32, 0), lag);
try expectEqual(@as(i32, 256), takeScrollTicks(&lag, 1e9));
}
+
+test "trackpad rotation spends whole search steps and keeps the remainder" {
+ const expectEqual = std.testing.expectEqual;
+ var lag: f32 = 0;
+ // A twist under one notch moves nothing; crossing it moves exactly one,
+ // and the overshoot is credited to the next.
+ try expectEqual(@as(i32, 0), takeRotationNotches(&lag, 15));
+ try expectEqual(@as(i32, 1), takeRotationNotches(&lag, 10));
+ try expectEqual(@as(f32, 5), lag);
+
+ // Reversing spends the residue first, so a twist back is not amplified by
+ // travel the other direction already banked.
+ try expectEqual(@as(i32, -1), takeRotationNotches(&lag, -25));
+ try expectEqual(@as(f32, 0), lag);
+
+ // One deliberate half-turn is several matches, not a hundred.
+ lag = 0;
+ try expectEqual(@as(i32, 9), takeRotationNotches(&lag, 180));
+
+ // Garbage moves nothing and leaves the dial usable; an absurd delta is
+ // clamped rather than spinning the emit loop.
+ lag = 0;
+ try expectEqual(@as(i32, 0), takeRotationNotches(&lag, std.math.nan(f32)));
+ try expectEqual(@as(i32, 0), takeRotationNotches(&lag, -std.math.inf(f32)));
+ try expectEqual(@as(f32, 0), lag);
+ try expectEqual(@as(i32, 64), takeRotationNotches(&lag, 1e9));
+}