summaryrefslogtreecommitdiff
path: root/src/tty
diff options
context:
space:
mode:
Diffstat (limited to 'src/tty')
-rw-r--r--src/tty/panel_compositor.zig730
-rw-r--r--src/tty/tty.zig238
2 files changed, 857 insertions, 111 deletions
diff --git a/src/tty/panel_compositor.zig b/src/tty/panel_compositor.zig
new file mode 100644
index 00000000..647c36ff
--- /dev/null
+++ b/src/tty/panel_compositor.zig
@@ -0,0 +1,730 @@
+//! TTY implementation of panel transitions.
+//!
+//! The core publishes final cells plus POD tracks. This backend copies that
+//! grid and performs the transition in cell space; no terminal escape trick
+//! or pixel assumption leaks into the core.
+
+const std = @import("std");
+const pardes = @import("../pardes.zig");
+const panel_animation = @import("../panel_animation.zig");
+
+const Box = panel_animation.Box;
+const Track = panel_animation.Track;
+
+/// Kitty placements cannot be resampled through the character-grid transform.
+/// Keep their transmitted pixels cached, but omit the placement while its pane
+/// geometry moves. Data effects animate changed grid cells in place and have
+/// no honest old Kitty bitmap, so leaving an unchanged attachment canonical is
+/// better than making it disappear for the whole reveal and pop back.
+pub fn hidesAttachment(tracks: []const Track, serial: u32) bool {
+ for (tracks) |track| {
+ if (track.serial != serial or !track.active()) continue;
+ if (track.effect != .slide and track.effect != .zoom and track.effect != .vertical) continue;
+ // The last active sample is already the exact canonical frame. Place
+ // the image there too, rather than making it pop in one tick later.
+ if (track.frame + 1 < track.effect.frames()) return true;
+ }
+ return false;
+}
+
+/// Return `source` unchanged when idle, otherwise an arena-owned Surface whose
+/// cells contain this frame's TTY presentation. Geometry exposed by a moving
+/// panel uses the theme's page background when it has one; null deliberately
+/// leaves the host terminal's default background showing.
+pub fn compose(
+ arena: std.mem.Allocator,
+ source: *pardes.Surface,
+ tracks: []const Track,
+ theme_bg: ?[3]u8,
+) !*pardes.Surface {
+ var active = false;
+ var noncanonical = false;
+ for (tracks) |track| {
+ if (!drawable(source, track)) continue;
+ active = true;
+ noncanonical = noncanonical or track.amount() < 1.0;
+ }
+ if (!active) return source;
+ // Every effect's last active sample is its exact canonical endpoint. Keep
+ // the real cursor and attachments in that sample instead of suppressing
+ // them for one extra frame that is otherwise pixel-identical.
+ if (!noncanonical) return source;
+
+ const out = try arena.create(pardes.Surface);
+ out.* = source.*;
+ out.cells = try arena.dupe(pardes.Cell, source.cells);
+ // A cursor has no pane/phase record of its own. All native backends hide
+ // it for the short transition rather than disagreeing about whether it
+ // should move, stay static, or cover an overlapping opening pane.
+ out.cursor = null;
+
+ // Clear every final geometry before drawing any presented geometry. If
+ // clear and draw are interleaved, a new panel can erase the old full-width
+ // panel that was deliberately drawn underneath it at frame zero.
+ for (tracks) |track|
+ if (drawable(source, track) and (track.effect == .slide or track.effect == .zoom))
+ clearBox(out, track.to, theme_bg);
+
+ // A vertical opener starts over the exact old pixels in its fixed target
+ // clip. Its new content then rises into that clip from below. Restore all
+ // bases before painting anything so an opener cannot erase an earlier
+ // moving panel merely because its pane slot sorts later.
+ for (tracks) |track|
+ if (drawable(source, track) and track.effect == .vertical and track.phase == .opening)
+ restorePrevious(out, source, track.contentBox());
+
+ // Stable layout motion first, newly opening panels above it, and closing
+ // tombstones last. A closing pane no longer owns input or canonical cells,
+ // but its frozen old content remains the top visual until it slides out.
+ for ([_]panel_animation.Phase{ .moving, .opening, .closing }) |phase| {
+ for (tracks) |track| {
+ if (!drawable(source, track) or track.phase != phase) continue;
+ switch (track.effect) {
+ .off => {},
+ .slide, .zoom => transform(out, source, track),
+ .dissolve => dissolve(out, source, track),
+ .ascii => ascii(out, source, track),
+ .vertical => vertical(out, source, track),
+ }
+ }
+ }
+ return out;
+}
+
+/// Content effects have no honest frame without the frozen old grid. In
+/// production the core drops such tracks before publishing Surface; retain
+/// this guard at the backend boundary so a partial/custom caller also snaps
+/// to canonical content instead of manufacturing blanks.
+fn drawable(source: *const pardes.Surface, track: Track) bool {
+ return track.active() and
+ (!track.effect.needsPreviousGrid() or source.hasPanelDiff());
+}
+
+fn transform(out: *pardes.Surface, source: *const pardes.Surface, track: Track) void {
+ const shown = track.presented();
+ if (shown.w <= 0 or shown.h <= 0) return;
+ const dst = clipped(shown, out.cols, out.rows) orelse return;
+ var y = dst.y0;
+ while (y < dst.y1) : (y += 1) {
+ var x = dst.x0;
+ while (x < dst.x1) : (x += 1) {
+ // The core hit-tests cell CENTRES against this same half-open box.
+ // floor/ceil clipping includes boundary cells for iteration, but
+ // they are not visible unless their centre is actually inside.
+ // Test that containment before division: at large u16 grids f32
+ // can round an inside right-edge ratio to exactly 1.0.
+ const cx = @as(f32, @floatFromInt(x)) + 0.5;
+ const cy = @as(f32, @floatFromInt(y)) + 0.5;
+ if (cx < shown.x or cx >= shown.x + shown.w or
+ cy < shown.y or cy >= shown.y + shown.h) continue;
+ const u = std.math.clamp((cx - shown.x) / shown.w, 0, 0.999_999);
+ const v = std.math.clamp((cy - shown.y) / shown.h, 0, 0.999_999);
+ const sx: i32 = @intFromFloat(@floor(track.to.x + u * track.to.w));
+ const sy: i32 = @intFromFloat(@floor(track.to.y + v * track.to.h));
+ if (sx < 0 or sy < 0 or sx >= source.cols or sy >= source.rows) continue;
+ out.at(x, y).* = source.cells[@as(usize, @intCast(sy)) * source.cols + @as(usize, @intCast(sx))];
+ }
+ }
+}
+
+fn dissolve(out: *pardes.Surface, source: *const pardes.Surface, track: Track) void {
+ const area = clipped(track.to, out.cols, out.rows) orelse return;
+ const amount = track.amount();
+ var y = area.y0;
+ while (y < area.y1) : (y += 1) {
+ var x = area.x0;
+ while (x < area.x1) : (x += 1) {
+ if (!source.panelCellChanged(x, y)) continue;
+ if (panel_animation.dissolveRevealed(
+ track.serial,
+ x - area.x0,
+ y - area.y0,
+ amount,
+ )) continue;
+ out.at(x, y).* = previousCell(source, x, y).*;
+ }
+ }
+}
+
+fn ascii(out: *pardes.Surface, source: *const pardes.Surface, track: Track) void {
+ const area = clipped(track.to, out.cols, out.rows) orelse return;
+ const amount = track.amount();
+ var y = area.y0;
+ while (y < area.y1) : (y += 1) {
+ var x = area.x0;
+ while (x < area.x1) : (x += 1) {
+ if (!source.panelCellChanged(x, y)) continue;
+ const material = panel_animation.asciiCell(track.serial, x - area.x0, y - area.y0, amount);
+ if (material.reveal) continue;
+ const cell = out.at(x, y);
+ if (material.glyph == ' ') {
+ cell.* = previousCell(source, x, y).*;
+ continue;
+ }
+ // The wave itself wears the new cell's colors, then yields to its
+ // real glyph. Before the wave arrives the complete old cell—not a
+ // synthetic blank—remains visible above.
+ cell.* = source.cells[@as(usize, y) * source.cols + x];
+ cell.text[0] = material.glyph;
+ cell.len = 1;
+ cell.default = false;
+ cell.style.dim = true;
+ }
+ }
+}
+
+fn previousCell(source: *const pardes.Surface, x: u16, y: u16) *const pardes.Cell {
+ std.debug.assert(source.hasPanelDiff());
+ return &source.previous_cells[@as(usize, y) * source.cols + x];
+}
+
+fn restorePrevious(out: *pardes.Surface, source: *const pardes.Surface, box: Box) void {
+ const area = clipped(box, out.cols, out.rows) orelse return;
+ var y = area.y0;
+ while (y < area.y1) : (y += 1) {
+ var x = area.x0;
+ while (x < area.x1) : (x += 1)
+ out.at(x, y).* = previousCell(source, x, y).*;
+ }
+}
+
+fn vertical(out: *pardes.Surface, source: *const pardes.Surface, track: Track) void {
+ const cells = if (track.phase == .closing) source.previous_cells else source.cells;
+ transformClipped(out, source.cols, source.rows, cells, track, track.contentBox());
+}
+
+/// Translate one old/new canonical panel through its fixed lifecycle clip.
+/// Sampling is always relative to contentBox: an opener reads its new target,
+/// while a closer reads the old box retained after that pane was destroyed.
+fn transformClipped(
+ out: *pardes.Surface,
+ source_cols: u16,
+ source_rows: u16,
+ cells: []const pardes.Cell,
+ track: Track,
+ clip: Box,
+) void {
+ const shown = track.presented();
+ if (shown.w <= 0 or shown.h <= 0) return;
+ const dst = clipped(shown, out.cols, out.rows) orelse return;
+ const content = track.contentBox();
+ var y = dst.y0;
+ while (y < dst.y1) : (y += 1) {
+ var x = dst.x0;
+ while (x < dst.x1) : (x += 1) {
+ const cx = @as(f32, @floatFromInt(x)) + 0.5;
+ const cy = @as(f32, @floatFromInt(y)) + 0.5;
+ if (cx < shown.x or cx >= shown.x + shown.w or
+ cy < shown.y or cy >= shown.y + shown.h or
+ cx < clip.x or cx >= clip.x + clip.w or
+ cy < clip.y or cy >= clip.y + clip.h) continue;
+ const u = std.math.clamp((cx - shown.x) / shown.w, 0, 0.999_999);
+ const v = std.math.clamp((cy - shown.y) / shown.h, 0, 0.999_999);
+ const sx: i32 = @intFromFloat(@floor(content.x + u * content.w));
+ const sy: i32 = @intFromFloat(@floor(content.y + v * content.h));
+ if (sx < 0 or sy < 0 or sx >= source_cols or sy >= source_rows) continue;
+ out.at(x, y).* = cells[@as(usize, @intCast(sy)) * source_cols + @as(usize, @intCast(sx))];
+ }
+ }
+}
+
+fn clearBox(surface: *pardes.Surface, box: Box, theme_bg: ?[3]u8) void {
+ const area = clipped(box, surface.cols, surface.rows) orelse return;
+ var blank: pardes.Cell = .{};
+ if (theme_bg) |rgb| {
+ blank.default = false;
+ blank.style.bg = .{ .rgb = rgb };
+ }
+ var y = area.y0;
+ while (y < area.y1) : (y += 1) {
+ var x = area.x0;
+ while (x < area.x1) : (x += 1) surface.at(x, y).* = blank;
+ }
+}
+
+const GridBox = struct { x0: u16, y0: u16, x1: u16, y1: u16 };
+
+fn clipped(box: Box, cols: u16, rows: u16) ?GridBox {
+ const x0: i32 = @intFromFloat(@floor(box.x));
+ const y0: i32 = @intFromFloat(@floor(box.y));
+ const x1: i32 = @intFromFloat(@ceil(box.x + box.w));
+ const y1: i32 = @intFromFloat(@ceil(box.y + box.h));
+ const area: GridBox = .{
+ .x0 = @intCast(std.math.clamp(x0, 0, @as(i32, cols))),
+ .y0 = @intCast(std.math.clamp(y0, 0, @as(i32, rows))),
+ .x1 = @intCast(std.math.clamp(x1, 0, @as(i32, cols))),
+ .y1 = @intCast(std.math.clamp(y1, 0, @as(i32, rows))),
+ };
+ return if (area.x0 < area.x1 and area.y0 < area.y1) area else null;
+}
+
+test "ASCII materialization happens in copied TTY cells" {
+ var cells: [8]pardes.Cell = @splat(.{});
+ var previous: [8]pardes.Cell = @splat(.{});
+ const changed: [8]u8 = @splat(1);
+ for (&cells) |*cell| {
+ cell.text[0] = 'X';
+ cell.len = 1;
+ cell.default = false;
+ }
+ for (&previous) |*cell| {
+ cell.text[0] = 'O';
+ cell.len = 1;
+ cell.default = false;
+ }
+ var surface: pardes.Surface = .{
+ .cols = 4,
+ .rows = 2,
+ .cells = &cells,
+ .previous_cells = &previous,
+ .changed_cells = &changed,
+ };
+ var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
+ defer arena.deinit();
+ const track: Track = .{
+ .serial = 9,
+ .effect = .ascii,
+ .frame = 0,
+ .from = .{ .w = 4, .h = 2 },
+ .to = .{ .w = 4, .h = 2 },
+ };
+ const animated = try compose(arena.allocator(), &surface, &.{track}, null);
+ try std.testing.expect(animated != &surface);
+ try std.testing.expectEqualStrings("X", surface.at(0, 0).grapheme());
+ var differs = false;
+ for (animated.cells) |cell| differs = differs or !std.mem.eql(u8, cell.grapheme(), "X");
+ try std.testing.expect(differs);
+}
+
+test "dissolve changes only diff cells from old to new" {
+ var cells: [2]pardes.Cell = @splat(.{});
+ var previous: [2]pardes.Cell = @splat(.{});
+ for (&cells, "NU") |*cell, glyph| {
+ cell.text[0] = glyph;
+ cell.len = 1;
+ cell.default = false;
+ }
+ for (&previous, "OU") |*cell, glyph| {
+ cell.text[0] = glyph;
+ cell.len = 1;
+ cell.default = false;
+ }
+ const changed = [_]u8{ 1, 0 };
+ var surface: pardes.Surface = .{
+ .cols = 2,
+ .rows = 1,
+ .cells = &cells,
+ .previous_cells = &previous,
+ .changed_cells = &changed,
+ };
+ var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
+ defer arena.deinit();
+ var track: Track = .{
+ .serial = 1,
+ .effect = .dissolve,
+ .from = .{ .w = 2, .h = 1 },
+ .to = .{ .w = 2, .h = 1 },
+ };
+
+ const first = try compose(arena.allocator(), &surface, &.{track}, null);
+ try std.testing.expectEqualStrings("O", first.at(0, 0).grapheme());
+ try std.testing.expectEqual(cells[1], first.at(1, 0).*);
+
+ _ = arena.reset(.retain_capacity);
+ track.frame = track.effect.frames() - 1;
+ const final = try compose(arena.allocator(), &surface, &.{track}, null);
+ try std.testing.expectEqual(cells[0], final.at(0, 0).*);
+ try std.testing.expectEqual(cells[1], final.at(1, 0).*);
+}
+
+test "ASCII diff travels old through punctuation to new" {
+ var cells = [_]pardes.Cell{.{}};
+ var previous = [_]pardes.Cell{.{}};
+ cells[0].text[0] = 'N';
+ cells[0].default = false;
+ previous[0].text[0] = 'O';
+ previous[0].default = false;
+ const changed = [_]u8{1};
+ var surface: pardes.Surface = .{
+ .cols = 1,
+ .rows = 1,
+ .cells = &cells,
+ .previous_cells = &previous,
+ .changed_cells = &changed,
+ };
+ var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
+ defer arena.deinit();
+ var track: Track = .{
+ .serial = 9,
+ .effect = .ascii,
+ .from = .{ .w = 1, .h = 1 },
+ .to = .{ .w = 1, .h = 1 },
+ };
+
+ const old_frame = try compose(arena.allocator(), &surface, &.{track}, null);
+ try std.testing.expectEqualStrings("O", old_frame.at(0, 0).grapheme());
+
+ _ = arena.reset(.retain_capacity);
+ track.frame = 7;
+ const wave = panel_animation.asciiCell(track.serial, 0, 0, track.amount());
+ try std.testing.expect(!wave.reveal and wave.glyph != ' ');
+ const punctuation = try compose(arena.allocator(), &surface, &.{track}, null);
+ try std.testing.expectEqualSlices(u8, &.{wave.glyph}, punctuation.at(0, 0).grapheme());
+ try std.testing.expect(punctuation.at(0, 0).style.dim);
+
+ _ = arena.reset(.retain_capacity);
+ track.frame = track.effect.frames() - 1;
+ const new_frame = try compose(arena.allocator(), &surface, &.{track}, null);
+ try std.testing.expectEqual(cells[0], new_frame.at(0, 0).*);
+}
+
+test "content transition without a diff snaps to canonical surface" {
+ var cells = [_]pardes.Cell{.{}};
+ cells[0].text[0] = 'N';
+ cells[0].default = false;
+ var surface: pardes.Surface = .{
+ .cols = 1,
+ .rows = 1,
+ .cells = &cells,
+ .cursor = .{ .x = 0, .y = 0 },
+ };
+ var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
+ defer arena.deinit();
+
+ for ([_]panel_animation.Transition{ .dissolve, .ascii, .vertical }) |effect| {
+ const track: Track = .{
+ .effect = effect,
+ .phase = .opening,
+ .from = .{ .y = 1, .w = 1, .h = 1 },
+ .to = .{ .w = 1, .h = 1 },
+ };
+ try std.testing.expectEqual(&surface, try compose(arena.allocator(), &surface, &.{track}, null));
+ }
+}
+
+test "exact transition endpoint preserves the canonical cursor" {
+ var cells = [_]pardes.Cell{.{}};
+ var previous = cells;
+ const changed = [_]u8{1};
+ var surface: pardes.Surface = .{
+ .cols = 1,
+ .rows = 1,
+ .cells = &cells,
+ .cursor = .{ .x = 0, .y = 0 },
+ .previous_cells = &previous,
+ .changed_cells = &changed,
+ };
+ const track: Track = .{
+ .effect = .ascii,
+ .frame = panel_animation.Transition.ascii.frames() - 1,
+ .from = .{ .w = 1, .h = 1 },
+ .to = .{ .w = 1, .h = 1 },
+ };
+ var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
+ defer arena.deinit();
+
+ const result = try compose(arena.allocator(), &surface, &.{track}, null);
+ try std.testing.expectEqual(&surface, result);
+ try std.testing.expect(result.cursor != null);
+}
+
+test "vertical opening rises through a fixed old-grid clip" {
+ var cells: [12]pardes.Cell = @splat(.{});
+ var previous: [12]pardes.Cell = @splat(.{});
+ for (&cells, "aaabbbcccddd") |*cell, glyph| {
+ cell.text[0] = glyph;
+ cell.len = 1;
+ cell.default = false;
+ }
+ for (&previous, "000111222333") |*cell, glyph| {
+ cell.text[0] = glyph;
+ cell.len = 1;
+ cell.default = false;
+ }
+ const changed: [12]u8 = @splat(1);
+ var surface: pardes.Surface = .{
+ .cols = 3,
+ .rows = 4,
+ .cells = &cells,
+ .previous_cells = &previous,
+ .changed_cells = &changed,
+ };
+ var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
+ defer arena.deinit();
+ const target: Box = .{ .y = 1, .w = 3, .h = 2 };
+ var track: Track = .{
+ .serial = 5,
+ .phase = .opening,
+ .effect = .vertical,
+ .from = panel_animation.openingBox(.vertical, target, 3),
+ .to = target,
+ };
+
+ const first = try compose(arena.allocator(), &surface, &.{track}, null);
+ try std.testing.expectEqualStrings("1", first.at(0, 1).grapheme());
+ try std.testing.expectEqualStrings("2", first.at(0, 2).grapheme());
+ try std.testing.expectEqualStrings("a", first.at(0, 0).grapheme());
+ try std.testing.expectEqualStrings("d", first.at(0, 3).grapheme());
+
+ _ = arena.reset(.retain_capacity);
+ track.frame = 2;
+ const entering = try compose(arena.allocator(), &surface, &.{track}, null);
+ try std.testing.expectEqualStrings("1", entering.at(0, 1).grapheme());
+ try std.testing.expectEqualStrings("b", entering.at(0, 2).grapheme());
+ try std.testing.expectEqualStrings("a", entering.at(0, 0).grapheme());
+ try std.testing.expectEqualStrings("d", entering.at(0, 3).grapheme());
+
+ _ = arena.reset(.retain_capacity);
+ track.frame = track.effect.frames() - 1;
+ const final = try compose(arena.allocator(), &surface, &.{track}, null);
+ try std.testing.expectEqualStrings("b", final.at(0, 1).grapheme());
+ try std.testing.expectEqualStrings("c", final.at(0, 2).grapheme());
+}
+
+test "vertical closing slides frozen content upward over canonical cells" {
+ var cells: [12]pardes.Cell = @splat(.{});
+ var previous: [12]pardes.Cell = @splat(.{});
+ for (&cells, "aaabbbcccddd") |*cell, glyph| {
+ cell.text[0] = glyph;
+ cell.len = 1;
+ cell.default = false;
+ }
+ for (&previous, "000111222333") |*cell, glyph| {
+ cell.text[0] = glyph;
+ cell.len = 1;
+ cell.default = false;
+ }
+ const changed: [12]u8 = @splat(1);
+ var surface: pardes.Surface = .{
+ .cols = 3,
+ .rows = 4,
+ .cells = &cells,
+ .previous_cells = &previous,
+ .changed_cells = &changed,
+ };
+ var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
+ defer arena.deinit();
+ const old_box: Box = .{ .y = 1, .w = 3, .h = 2 };
+ var track: Track = .{
+ .serial = 5,
+ .phase = .closing,
+ .effect = .vertical,
+ .from = old_box,
+ .to = panel_animation.closingBox(.vertical, old_box),
+ };
+
+ const first = try compose(arena.allocator(), &surface, &.{track}, null);
+ try std.testing.expectEqualStrings("1", first.at(0, 1).grapheme());
+ try std.testing.expectEqualStrings("2", first.at(0, 2).grapheme());
+
+ _ = arena.reset(.retain_capacity);
+ track.frame = 2;
+ const leaving = try compose(arena.allocator(), &surface, &.{track}, null);
+ try std.testing.expectEqualStrings("2", leaving.at(0, 1).grapheme());
+ try std.testing.expectEqualStrings("c", leaving.at(0, 2).grapheme());
+ try std.testing.expectEqualStrings("a", leaving.at(0, 0).grapheme());
+ try std.testing.expectEqualStrings("d", leaving.at(0, 3).grapheme());
+
+ _ = arena.reset(.retain_capacity);
+ track.frame = track.effect.frames() - 1;
+ const final = try compose(arena.allocator(), &surface, &.{track}, null);
+ try std.testing.expectEqual(cells[3], final.at(0, 1).*);
+ try std.testing.expectEqual(cells[6], final.at(0, 2).*);
+}
+
+test "closing content paints after opening content regardless of track order" {
+ var cells = [_]pardes.Cell{.{}};
+ var previous = [_]pardes.Cell{.{}};
+ cells[0].text[0] = 'N';
+ cells[0].default = false;
+ previous[0].text[0] = 'O';
+ previous[0].default = false;
+ const changed = [_]u8{1};
+ var surface: pardes.Surface = .{
+ .cols = 1,
+ .rows = 1,
+ .cells = &cells,
+ .previous_cells = &previous,
+ .changed_cells = &changed,
+ };
+ var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
+ defer arena.deinit();
+ const box: Box = .{ .w = 1, .h = 1 };
+ const closing: Track = .{
+ .serial = 1,
+ .phase = .closing,
+ .effect = .vertical,
+ .from = box,
+ .to = panel_animation.closingBox(.vertical, box),
+ };
+ const opening: Track = .{
+ .serial = 2,
+ .phase = .opening,
+ .effect = .ascii,
+ .frame = panel_animation.Transition.ascii.frames() - 1,
+ .from = box,
+ .to = box,
+ };
+
+ const animated = try compose(arena.allocator(), &surface, &.{ closing, opening }, null);
+ try std.testing.expectEqualStrings("O", animated.at(0, 0).grapheme());
+}
+
+test "opening panel cannot erase an existing panel drawn underneath" {
+ var cells: [8]pardes.Cell = @splat(.{});
+ for (&cells, 0..) |*cell, x| {
+ cell.text[0] = if (x < 4) 'A' else 'B';
+ cell.len = 1;
+ cell.default = false;
+ }
+ var surface: pardes.Surface = .{ .cols = 8, .rows = 1, .cells = &cells };
+ var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
+ defer arena.deinit();
+ const tracks = [_]Track{
+ .{
+ .serial = 1,
+ .phase = .moving,
+ .effect = .slide,
+ .from = .{ .w = 8, .h = 1 },
+ .to = .{ .w = 4, .h = 1 },
+ },
+ .{
+ .serial = 2,
+ .phase = .opening,
+ .effect = .slide,
+ .from = .{ .x = 8, .w = 4, .h = 1 },
+ .to = .{ .x = 4, .w = 4, .h = 1 },
+ },
+ };
+ const animated = try compose(arena.allocator(), &surface, &tracks, null);
+ try std.testing.expectEqualStrings("A", animated.at(7, 0).grapheme());
+}
+
+test "geometry gap is a blank painted in an explicit theme background" {
+ var cells: [4]pardes.Cell = @splat(.{});
+ for (&cells, "ABCD") |*cell, glyph| {
+ cell.text[0] = glyph;
+ cell.len = 1;
+ cell.default = false;
+ cell.style = .{
+ .fg = .{ .rgb = .{ 220, 210, 200 } },
+ .bg = .{ .rgb = .{ 30, 20, 10 } },
+ .bold = true,
+ .font_role = .tagline,
+ };
+ }
+ var surface: pardes.Surface = .{ .cols = 4, .rows = 1, .cells = &cells };
+ var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
+ defer arena.deinit();
+ const track: Track = .{
+ .serial = 1,
+ .effect = .slide,
+ .from = .{ .x = 2, .w = 2, .h = 1 },
+ .to = .{ .w = 4, .h = 1 },
+ };
+ const page_bg = [3]u8{ 248, 244, 232 };
+ const animated = try compose(arena.allocator(), &surface, &.{track}, page_bg);
+
+ const gap = animated.at(0, 0);
+ try std.testing.expect(!gap.default);
+ try std.testing.expectEqualStrings(" ", gap.grapheme());
+ try std.testing.expectEqual(pardes.Color{ .rgb = page_bg }, gap.style.bg);
+ try std.testing.expectEqual(pardes.Color.default, gap.style.fg);
+ try std.testing.expect(!gap.style.bold);
+ try std.testing.expectEqual(pardes.FontRole.body, gap.style.font_role);
+ // The clear supplies only exposed geometry. Cells sampled from the moving
+ // panel retain all canonical content and style fields byte-for-byte.
+ try std.testing.expectEqual(cells[1], animated.at(2, 0).*);
+}
+
+test "geometry gap stays terminal-default for a transparent theme" {
+ var cells: [2]pardes.Cell = @splat(.{});
+ for (&cells, "AB") |*cell, glyph| {
+ cell.text[0] = glyph;
+ cell.len = 1;
+ cell.default = false;
+ }
+ var surface: pardes.Surface = .{ .cols = 2, .rows = 1, .cells = &cells };
+ var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
+ defer arena.deinit();
+ const track: Track = .{
+ .serial = 1,
+ .effect = .slide,
+ .from = .{ .x = 1, .w = 1, .h = 1 },
+ .to = .{ .w = 2, .h = 1 },
+ };
+ const animated = try compose(arena.allocator(), &surface, &.{track}, null);
+
+ const gap = animated.at(0, 0);
+ try std.testing.expect(gap.default);
+ try std.testing.expectEqualStrings(" ", gap.grapheme());
+ try std.testing.expectEqual(pardes.Color.default, gap.style.bg);
+}
+
+test "fractional geometry draws exactly the cell centres core can hit" {
+ var cells: [3]pardes.Cell = @splat(.{});
+ for (&cells, "ABC") |*cell, glyph| {
+ cell.text[0] = glyph;
+ cell.len = 1;
+ cell.default = false;
+ }
+ var surface: pardes.Surface = .{ .cols = 3, .rows = 1, .cells = &cells };
+ var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
+ defer arena.deinit();
+ const track: Track = .{
+ .serial = 1,
+ .effect = .slide,
+ .from = .{ .x = 0.9, .w = 1, .h = 1 },
+ .to = .{ .x = 0, .w = 1, .h = 1 },
+ };
+ const animated = try compose(arena.allocator(), &surface, &.{track}, null);
+ // Cell 0 lies in floor/ceil iteration bounds, but its centre (0.5) is
+ // outside [0.9, 1.9); cell 1 is the only presented/hit-testable cell.
+ try std.testing.expectEqualStrings(" ", animated.at(0, 0).grapheme());
+ try std.testing.expectEqualStrings("A", animated.at(1, 0).grapheme());
+}
+
+test "large-grid right edge uses containment before rounded ratio" {
+ const cols = std.math.maxInt(u16);
+ const source_cells = try std.testing.allocator.alloc(pardes.Cell, cols);
+ defer std.testing.allocator.free(source_cells);
+ const output_cells = try std.testing.allocator.alloc(pardes.Cell, cols);
+ defer std.testing.allocator.free(output_cells);
+ @memset(source_cells, .{});
+ @memset(output_cells, .{});
+ for (source_cells) |*cell| {
+ cell.text[0] = 'X';
+ cell.len = 1;
+ cell.default = false;
+ }
+ var source: pardes.Surface = .{ .cols = cols, .rows = 1, .cells = source_cells };
+ var output: pardes.Surface = .{ .cols = cols, .rows = 1, .cells = output_cells };
+ const track: Track = .{
+ .serial = 1,
+ .effect = .zoom,
+ .frame = 8,
+ .from = .{ .x = 56_796, .w = 8_299, .h = 1 },
+ .to = .{ .x = 556, .w = 63_483, .h = 1 },
+ };
+ const shown = track.presented();
+ try std.testing.expect(@as(f32, 63_935.5) < shown.x + shown.w);
+ transform(&output, &source, track);
+ try std.testing.expectEqualStrings("X", output.at(63_935, 0).grapheme());
+}
+
+test "active panel transition hides only its own native attachment" {
+ const tracks = [_]Track{
+ .{ .serial = 41, .effect = .slide, .frame = 0 },
+ .{ .serial = 42, .effect = .ascii, .frame = 0 },
+ .{ .serial = 43, .effect = .zoom, .frame = panel_animation.Transition.zoom.frames() - 1 },
+ .{ .serial = 44, .effect = .zoom, .frame = panel_animation.Transition.zoom.frames() },
+ .{ .serial = 45, .phase = .opening, .effect = .vertical, .frame = 0 },
+ };
+
+ try std.testing.expect(hidesAttachment(&tracks, 41));
+ try std.testing.expect(!hidesAttachment(&tracks, 42));
+ try std.testing.expect(!hidesAttachment(&tracks, 43));
+ try std.testing.expect(!hidesAttachment(&tracks, 44));
+ try std.testing.expect(hidesAttachment(&tracks, 45));
+ try std.testing.expect(!hidesAttachment(&tracks, 46));
+}
diff --git a/src/tty/tty.zig b/src/tty/tty.zig
index f6d095e5..05585f46 100644
--- a/src/tty/tty.zig
+++ b/src/tty/tty.zig
@@ -14,8 +14,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 file_watch = @import("../file_watch.zig");
const selection_pipe = @import("../selection_pipe.zig");
const nested = @import("../nested.zig");
+const panel_compositor = @import("panel_compositor.zig");
extern "c" fn forkpty(amaster: *c_int, name: ?[*:0]u8, termp: ?*const anyopaque, winp: ?*const posix.winsize) c_int;
extern "c" fn execv(path: [*:0]const u8, argv: [*:null]const ?[*:0]const u8) c_int;
@@ -36,6 +38,11 @@ pub const Command = struct {
pty_eof: struct { id: usize, gen: u32 },
winsize: vaxis.Winsize,
mouse: vaxis.Mouse,
+ /// Focus reporting is part of vaxis's mouse mode (DEC 1004). A TTY
+ /// cannot report a literal pointer crossing its character grid, so
+ /// losing terminal focus is its only reliable pointer-leave signal.
+ focus_in,
+ focus_out,
paste: []const u8,
/// The bracketed-paste brackets. vaxis posts them ONLY because this
/// union declares fields with these exact names — its Loop gates every
@@ -57,26 +64,6 @@ pub const Command = struct {
};
const Loop = vaxis.Loop(@TypeOf(Command.value));
-/// The files on open panes, watched through ONE inotify instance.
-///
-/// `wd` is the descriptor for the file's CONTAINING DIRECTORY, not for the
-/// file: almost nothing rewrites a file in place. An editor, `git checkout`, a
-/// formatter — they write a temp file and rename it over the target, which
-/// swaps the inode, and a watch on the file itself keeps reporting on the old
-/// unlinked one forever. The directory sees the new inode arrive (MOVED_TO /
-/// CREATE). std.Build.Watch marks directories for exactly this reason; it uses
-/// fanotify because it watches thousands of them across mount points and needs
-/// FID-keyed dedup, whereas sixteen panes' worth of inotify is a third of the
-/// code, hands us no privilege question, and has no kernel version floor.
-///
-/// `hash` is what we last saw ON DISK. A reload only fires when it moves, so
-/// pardes's own Save — which restamps the hash from the bytes it just wrote —
-/// never looks like an external change and never touches the undo stack.
-/// A hash rather than mtime+size because we have to read the file to hand the
-/// core its bytes anyway, so the exact answer costs nothing extra and there is
-/// no clock granularity to reason about.
-const Watch = struct { wd: c_int, hash: u64 };
-
/// One language query, owned by the worker that runs it. Everything the
/// backend may read is copied in here before the worker starts: the core goes
/// on editing the moment the effect is drained, so a borrowed slice would be a
@@ -333,6 +320,81 @@ test "Kitty PDF fragments never declare pixels beyond their clipped bottom" {
try std.testing.expect(kittyPlacement(height, 80, 16, 640, 256) == null);
}
+test "Linux host watch closes initial race and reloads rename-over PDF while idle" {
+ if (comptime builtin.os.tag != .linux or !pardes.pdf_enabled) return;
+ const io = std.testing.io;
+ const gpa = std.testing.allocator;
+ var tmp = std.testing.tmpDir(.{});
+ defer tmp.cleanup();
+ const original = try pardes.pdf.makeOutlineTestPdf(gpa);
+ defer gpa.free(original);
+ const replacement = try pardes.pdf.makeNoOutlineTestPdf(gpa);
+ defer gpa.free(replacement);
+ try tmp.dir.writeFile(io, .{ .sub_path = "live.pdf", .data = original });
+ // Prepare both editor-style temporary inodes before marking the directory
+ // so the only post-arm wake below is the second rename.
+ try tmp.dir.writeFile(io, .{ .sub_path = "initial.pdf", .data = replacement });
+ try tmp.dir.writeFile(io, .{ .sub_path = "live-replacement.pdf", .data = original });
+ var path_buf: [256]u8 = undefined;
+ const path = try std.fmt.bufPrint(&path_buf, ".zig-cache/tmp/{s}/live.pdf", .{tmp.sub_path});
+
+ const fd = libc.inotify_init1(linux.IN.CLOEXEC | linux.IN.NONBLOCK);
+ if (fd < 0) return error.InotifyInitFailed;
+ defer _ = libc.close(fd);
+ var watches: file_watch.Table = @splat(null);
+ defer for (0..watches.len) |id| file_watch.watchPane(
+ fd,
+ &watches,
+ @intCast(id),
+ null,
+ 0,
+ .{ .text = 0 },
+ );
+
+ const core = try pardes.Pardes.init(gpa, .{ .file = path, .cols = 80, .rows = 28 });
+ defer core.deinit();
+ try std.testing.expectEqual(@as(usize, 3), core.panes[0].?.pdf.?.page_count);
+
+ // The core opened the three-page inode, but the host has not drained its
+ // watch effect yet. Replace it now: install-then-reconcile must discover
+ // the one-page document even though no source existed for this first edge.
+ try tmp.dir.rename("initial.pdf", tmp.dir, "live.pdf", io);
+ var armed = false;
+ while (core.nextEffect()) |effect| switch (effect) {
+ .watch => |watch| if (watch.pane == 0 and watch.on) {
+ armed = true;
+ try std.testing.expect(!file_watch.applyEffect(core, io, gpa, fd, &watches, 0, true));
+ },
+ else => {},
+ };
+ try std.testing.expect(armed and watches[0] != null);
+ try std.testing.expectEqual(@as(usize, 1), core.panes[0].?.pdf.?.page_count);
+
+ try tmp.dir.rename("live-replacement.pdf", tmp.dir, "live.pdf", io);
+ var events: [4096]u8 = undefined;
+ const event_bytes = libc.read(fd, &events, events.len);
+ try std.testing.expect(event_bytes > 0);
+
+ // This is the same pass the watcher thread schedules; no key, mouse, or
+ // synthetic core file_changed event participates in the transaction.
+ try std.testing.expect(!file_watch.reloadChanged(core, io, gpa, &watches));
+ const pane = core.panes[0].?;
+ try std.testing.expectEqual(@as(usize, 3), pane.pdf.?.page_count);
+ const disk_identity = try file_watch.identify(io, path);
+ switch (watches[0].?.generation) {
+ .pdf => |accepted| try std.testing.expect(accepted != null and accepted.?.eql(disk_identity)),
+ .text => return error.PdfWatchStoredTextGeneration,
+ }
+ try std.testing.expect(std.mem.indexOf(u8, pane.msg[0..pane.msg_len], "reloaded") != null);
+
+ core.native_images = true;
+ var frame = std.heap.ArenaAllocator.init(gpa);
+ defer frame.deinit();
+ const surface = try core.render(frame.allocator());
+ try std.testing.expect(surface.nimages > 0);
+ try std.testing.expectEqual(@as(u32, 0), surface.images[0].?.native.page);
+}
+
fn kittyImageRepresentable(place: pardes.ImagePlace) bool {
return place.iw > 0 and place.ih > 0 and
place.iw <= std.math.maxInt(u16) and place.ih <= std.math.maxInt(u16);
@@ -439,9 +501,10 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void {
var frame_arena: std.heap.ArenaAllocator = .init(allocs.frame);
defer frame_arena.deinit();
- // shells emit OSC 133 prompt marks via this rc (prompt hiding, click-move)
- writeFile(shell_bin.bash_rc_path, shell_bin.bash_rc);
- writeFile(shell_bin.fish_rc_path, shell_bin.fish_rc);
+ // Private, complete before any fork and retained until the last possible
+ // spawn; children borrow only these stable in-struct path buffers.
+ var prompt_rcs = shell_bin.PromptRcs.init();
+ defer prompt_rcs.deinit();
// macos: apple's bash 3.2 prints the zsh-deprecation banner into every
// pane unless this is in the env BEFORE bash starts (the rc is too late)
if (comptime builtin.os.tag.isDarwin()) _ = setenv("BASH_SILENCE_DEPRECATION_WARNING", "1", 1);
@@ -471,7 +534,7 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void {
// file a positional path argument opened. -1 off linux: watchPane goes
// quiet and the core simply never gets a file_changed event.
var inotify_fd: c_int = if (builtin.os.tag == .linux) libc.inotify_init1(linux.IN.CLOEXEC) else -1;
- var watches: [pardes.MAX_PANES]?Watch = @splat(null);
+ var watches: file_watch.Table = @splat(null);
var watch_task: ?std.Io.Future(anyerror!void) = null;
var loop: Loop = .init(io, &tty, &vx);
defer {
@@ -525,7 +588,7 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void {
// Perform the initial spawns BEFORE any worker thread exists: forkpty from
// a multithreaded process can wedge the child before exec.
- drainEffects(core, &ptys, &gens, io, gpa, allocs.lsp, &loop, &vx, &tty, &lsp_task, &pipe_tasks, inotify_fd, &watches, false);
+ drainEffects(core, &prompt_rcs, &ptys, &gens, io, gpa, allocs.lsp, &loop, &vx, &tty, &lsp_task, &pipe_tasks, inotify_fd, &watches, false);
try loop.start();
defer loop.stop();
@@ -621,6 +684,8 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void {
.nop => {},
.tick => core.update(.tick),
.quit => stop = true,
+ .focus_in => {},
+ .focus_out => core.update(.pointer_leave),
.winsize => |ws| {
try vx.resize(gpa, tty.writer(), ws);
if (comptime pardes.pdf_enabled)
@@ -761,35 +826,13 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void {
tz_event.end();
if (stop) break;
- // A watched directory moved. The wake says only THAT something
- // happened, so re-read every watched pane and hand the core the ones
- // whose bytes actually changed — that comparison is what keeps our own
- // Save, and any write that lands on identical content, out of the undo
- // stack. Reading here rather than on the watcher thread because the
- // core is the only thing that knows which pane a path belongs to, and
- // opening a file pane already reads synchronously on this thread.
if (check_files) {
check_files = false;
- for (&watches, 0..) |*slot, id| {
- if (slot.* == null) continue;
- const pane = core.panes[id] orelse continue;
- const f = pane.file orelse continue;
- const bytes = look.readFile(gpa, f.path) catch continue;
- defer gpa.free(bytes);
- const h = std.hash.Wyhash.hash(0, bytes);
- if (h == slot.*.?.hash) continue;
- slot.*.?.hash = h;
- core.update(.{ .file_changed = .{ .pane = @intCast(id), .bytes = bytes } });
- // the hash moving IS "someone else wrote this file" (our own
- // Save restamped it above), so the pane says who did what to it
- // — and it says it after the update, which cannot clear a
- // message because only a key or a mouse event does
- var mbuf: [256]u8 = undefined;
- core.setMessage(id, message.stamp(&mbuf, "reloaded", f.path));
- }
+ if (file_watch.reloadChanged(core, io, gpa, &watches))
+ loop.postEvent(.files_changed) catch {};
}
- drainEffects(core, &ptys, &gens, io, gpa, allocs.lsp, &loop, &vx, &tty, &lsp_task, &pipe_tasks, inotify_fd, &watches, true);
+ drainEffects(core, &prompt_rcs, &ptys, &gens, io, gpa, allocs.lsp, &loop, &vx, &tty, &lsp_task, &pipe_tasks, inotify_fd, &watches, true);
if (core.quit) break;
// Restore builtin: swap in a core rebuilt from the dump; the live
@@ -811,7 +854,14 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void {
// the replay core's pane ids mean new things, and the dying core's
// `watch off` effects go into a queue nobody drains — drop the lot
// here. The new core emits its own `on`s as it builds its panes.
- for (0..watches.len) |wid| watchPane(inotify_fd, &watches, @intCast(wid), null, 0);
+ for (0..watches.len) |wid| file_watch.watchPane(
+ inotify_fd,
+ &watches,
+ @intCast(wid),
+ null,
+ 0,
+ .{ .text = 0 },
+ );
var image_iterator = kitty_handles.valueIterator();
while (image_iterator.next()) |handle| vx.freeImage(tty.writer(), handle.id);
kitty_handles.clearRetainingCapacity();
@@ -858,8 +908,14 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void {
// ---- render: surface -> vaxis, cell for cell ----
_ = frame_arena.reset(.retain_capacity);
const tz_core = tracy.zone(@src(), "core.render");
- const surface = try core.render(frame_arena.allocator());
+ const canonical = try core.render(frame_arena.allocator());
tz_core.end();
+ const surface = try panel_compositor.compose(
+ frame_arena.allocator(),
+ canonical,
+ canonical.panelTracks(),
+ core.theme().bg,
+ );
const tz_cells = tracy.zone(@src(), "surface->vaxis");
const win = vx.window();
win.clear();
@@ -883,6 +939,10 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void {
// aliasing a fixed terminal cache slot.
for (surface.images[0..surface.nimages]) |maybe| {
const place = maybe orelse continue;
+ // Keep the placement in Surface so the terminal-side cache stays
+ // live, but do not pin native pixels over a panel whose cells are
+ // currently moving through the TTY grid.
+ if (panel_compositor.hidesAttachment(surface.panelTracks(), place.serial)) continue;
if (comptime pardes.pdf_enabled) {
if (!kittyImageRepresentable(place)) continue;
}
@@ -947,14 +1007,15 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void {
const tz_render = tracy.zone(@src(), "vx.render");
try vx.render(tty.writer());
tz_render.end();
+ core.acknowledgePanelPresentation(canonical.panelTracks());
tracy.frameMark();
- // A theme transition is the only reason this otherwise event-driven
+ // Active core animation is the only reason this otherwise event-driven
// frontend redraws on a clock. Sleep on the loop thread, then make the
// next pass a tick; input and worker events can queue during the short
// sleep and are drained in that pass. No timer worker can outlive the
// loop, and at the exact endpoint we return to an indefinite wait.
- if (core.themeAnimationActive()) {
+ if (core.animationActive()) {
std.Io.sleep(io, .fromMilliseconds(pardes.animation.frame_ms), .awake) catch {};
pending = .tick;
}
@@ -963,6 +1024,7 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void {
fn drainEffects(
core: *pardes.Pardes,
+ prompt_rcs: *const shell_bin.PromptRcs,
ptys: *[pardes.MAX_PANES]?Pty,
gens: *[pardes.MAX_PANES]u32,
io: std.Io,
@@ -974,7 +1036,7 @@ fn drainEffects(
lsp_task: *?std.Io.Future(anyerror!void),
pipe_tasks: *PipeTasks,
inotify_fd: c_int,
- watches: *[pardes.MAX_PANES]?Watch,
+ watches: *file_watch.Table,
threads_ok: bool,
) void {
while (core.nextEffect()) |effect| switch (effect) {
@@ -996,7 +1058,7 @@ fn drainEffects(
cwd_buf[cwd.len] = 0;
cwd_z = @ptrCast(&cwd_buf);
}
- const child = forkShell(core.shellBin(), cwd_z, core.screen_h, core.screen_w);
+ const child = forkShell(core, sp.pane, prompt_rcs, core.shellBin(), cwd_z, core.screen_h, core.screen_w);
ptys[sp.pane] = .{ .file = child.file, .pid = child.pid, .reader = .{ .any_future = null, .result = {} } };
// report the pane's starting directory back to the core (tags). The
// slot needs no occupancy reset: nothing is remembered, and the next
@@ -1032,7 +1094,10 @@ fn drainEffects(
_ = libc.close(fd);
// our own write is about to come back as a watch event: restamp
// from the bytes we just put there so it reads as "no change"
- if (watches[sf.pane]) |*w| w.hash = std.hash.Wyhash.hash(0, f.content);
+ if (watches[sf.pane]) |*w| if (w.serial == pane.serial) switch (w.generation) {
+ .text => w.generation = .{ .text = std.hash.Wyhash.hash(0, f.content) },
+ .pdf => {},
+ };
// ...and say so on the pane's message row. AFTER the write, not
// beside it: every `continue` above is a save that did not happen
// and must not be reported as one.
@@ -1138,15 +1203,8 @@ fn drainEffects(
pipe_tasks.add(.{ .id = request.id, .future = future });
},
.watch => |w| {
- // starting, the path and the on-disk bytes are read off the core
- // (same split as save_file); stopping, the pane is already gone
- var path: ?[]const u8 = null;
- var hash: u64 = 0;
- if (w.on) if (core.panes[w.pane]) |pane| if (pane.file) |f| {
- path = f.path;
- hash = std.hash.Wyhash.hash(0, f.content);
- };
- watchPane(inotify_fd, watches, w.pane, path, hash);
+ if (file_watch.applyEffect(core, io, gpa, inotify_fd, watches, w.pane, w.on))
+ loop.postEvent(.files_changed) catch {};
},
.quit => {},
};
@@ -1187,42 +1245,6 @@ fn pipeWorker(
loop.postEvent(.{ .pipe_done = response }) catch response.deinit(gpa);
}
-/// Mark or unmark one pane's file (`path` null = unmark). Linux only: on any
-/// other OS this returns silently, the core never receives a file_changed
-/// event, and the feature is simply off — a state the core has to tolerate
-/// regardless, since the browser shell has no filesystem at all.
-/// ponytail: darwin wants the FSEvents half of std.Build.Watch here.
-fn watchPane(fd: c_int, watches: *[pardes.MAX_PANES]?Watch, id: u8, path: ?[]const u8, hash: u64) void {
- if (comptime builtin.os.tag != .linux) return;
- if (fd < 0) return;
- if (watches[id]) |old| {
- // inotify hands out ONE descriptor per directory, so two panes on
- // files in the same directory share it: drop the mark only when the
- // last of them lets go, or closing one blinds the other.
- var shared = false;
- for (watches, 0..) |other, i| {
- const o = other orelse continue;
- if (i != id and o.wd == old.wd) shared = true;
- }
- if (!shared) _ = libc.inotify_rm_watch(fd, old.wd);
- watches[id] = null;
- }
- const p = path orelse return;
- const dir = std.fs.path.dirname(p) orelse ".";
- var dbuf: [4096:0]u8 = undefined;
- if (dir.len >= dbuf.len) return;
- @memcpy(dbuf[0..dir.len], dir);
- dbuf[dir.len] = 0;
- // CLOSE_WRITE, not MODIFY: one event when a writer is DONE rather than one
- // per write(2), which is most of the debounce for free. MOVED_TO and
- // CREATE catch the rename-over and the delete-then-recreate that are how
- // files are actually replaced (see Watch).
- const mask = linux.IN.CLOSE_WRITE | linux.IN.MOVED_TO | linux.IN.CREATE | linux.IN.ONLYDIR;
- const wd = libc.inotify_add_watch(fd, dbuf[0..dir.len :0], mask);
- if (wd < 0) return;
- watches[id] = .{ .wd = wd, .hash = hash };
-}
-
/// Block on the inotify fd and wake the loop. Deliberately does NOT parse the
/// events: the loop re-reads every watched pane anyway, so the only thing an
/// event carries that we need is THAT something happened, and parsing would
@@ -1297,12 +1319,12 @@ fn ttyTakenAt(ctx: ?*anyopaque, pane: usize) bool {
return look.ttyTaken(pt.pid, pt.file.handle);
}
-fn forkShell(bin: []const u8, cwd: ?[*:0]const u8, rows: u16, cols: u16) struct { file: std.Io.File, pid: posix.pid_t } {
+fn forkShell(core: *pardes.Pardes, pane: usize, prompt_rcs: *const shell_bin.PromptRcs, bin: []const u8, cwd: ?[*:0]const u8, rows: u16, cols: u16) struct { file: std.Io.File, pid: posix.pid_t } {
var master: c_int = undefined;
// resolved BEFORE the fork, into this frame, which the child inherits:
// nothing between fork and exec may allocate, and a PATH search would
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
- const spawn = shell_bin.resolve(bin, &path_buf);
+ const spawn = shell_bin.resolve(bin, &path_buf, prompt_rcs);
const ws = posix.winsize{ .row = rows, .col = cols, .xpixel = 0, .ypixel = 0 };
const pid = forkpty(&master, null, null, &ws);
if (pid == 0) {
@@ -1316,6 +1338,7 @@ fn forkShell(bin: []const u8, cwd: ?[*:0]const u8, rows: u16, cols: u16) struct
_ = execv(spawn.path, &spawn.argv);
_exit(127);
}
+ if (pid > 0) core.acknowledgeShell(pane, std.mem.span(spawn.path), spawn.argv[1] != null);
return .{ .file = .{ .handle = master, .flags = .{ .nonblocking = false } }, .pid = pid };
}
@@ -1398,13 +1421,6 @@ fn vaxisColor(c: pardes.Color) vaxis.Color {
};
}
-fn writeFile(path: [*:0]const u8, data: []const u8) void {
- const fd = libc.open(path, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644));
- if (fd < 0) return;
- defer _ = libc.close(fd);
- writeFd(fd, data);
-}
-
fn writeFd(fd: c_int, data: []const u8) void {
var off: usize = 0;
while (off < data.len) {