diff options
Diffstat (limited to 'src/tty')
| -rw-r--r-- | src/tty/panel_compositor.zig | 730 | ||||
| -rw-r--r-- | src/tty/tty.zig | 238 |
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) { |
