const std = @import("std"); pub const layout = @import("layout.zig"); const uucode = @import("uucode"); const vaxis = @import("vaxis"); const mvzr = @import("mvzr"); pub const modal = @import("modal.zig"); pub const look = @import("look.zig"); pub const filesystem = @import("fs.zig"); pub const syntax = @import("syntax.zig"); pub const locations_config = @import("locations_config.zig"); const tracy = @import("tracy.zig"); pub const panes = @import("panes.zig"); pub const builtins = @import("builtins.zig"); const limits = memory.limits; const selection_pipe = @import("selection_pipe.zig"); pub const config = @import("config.zig"); pub const pdf_enabled = panes.Pdf.enabled; pub const pdf = panes.Pdf.pdf; pub const memory = @import("memory.zig"); pub const image = @import("image.zig"); pub const dump = @import("dump.zig"); pub const lsp = @import("lsp/lsp.zig"); const host_io = @import("host_io.zig"); pub const Host = host_io.Host; pub const frameMark = tracy.frameMark; pub const Platform = enum { tty, gui, web, macos, esp32p4 }; pub const platform: Platform = @field(Platform, @tagName(@import("pardes_config").platform)); pub const theme_animation = @import("pardes_config").theme_animation; pub const version = @import("pardes_config").version; pub const commit: ?[]const u8 = @import("pardes_config").commit; pub const isolated = @import("pardes_isolation").isolated; pub const font_picker = platform == .gui or platform == .macos; pub const hosted = platform == .tty or platform == .gui or platform == .macos; pub const can_attach = platform == .tty or platform == .gui; pub const terminal_panes = platform != .esp32p4; pub const fonts = if (font_picker) @import("fonts.zig") else struct {}; pub const PdfRasterPolicy = panes.Pdf.RasterPolicy; pub const kitty_pdf_raster_policy: PdfRasterPolicy = .{ .dpi = 96, .max_dimension = 1200, .match_viewport = false, }; pub const sdl_pdf_raster_policy: PdfRasterPolicy = .{ .dpi = 192, .max_dimension = 4096, .match_viewport = true, }; pub const pdf_raster_policy: PdfRasterPolicy = switch (platform) { .tty => kitty_pdf_raster_policy, // Both pixel shells rasterize for a real display and can afford it; the // wire-bandwidth argument that shapes the Kitty policy does not apply. .gui, .macos => sdl_pdf_raster_policy, // Neither hostless platform rasterizes a PDF at all (mupdf is compiled // out), so the cheaper policy is the honest placeholder. .web, .esp32p4 => kitty_pdf_raster_policy, }; pub const Pane = panes.Pane; pub const MAX_PANES = 16; pub const BodyLayer = @import("body_layer.zig").Layer; pub const RowMetrics = @import("body_layer.zig").Metrics; pub const TagLayer = @import("tag_layer.zig").Layer; pub const TagHit = @import("tag_layer.zig").Hit; pub const TagKind = @import("tag_layer.zig").Kind; pub const MAX_TAG_LAYERS = MAX_PANES + MAX_COLS + 1; pub const PDF_PAGE_GAP_PX = panes.Pdf.page_gap_px; pub const MAX_COLS = 6; const column_weight_unit = layout.column_weight_unit; const max_column_weight = layout.max_column_weight; pub const MAX_JUMPS = 64; pub const TOPBAR_H: u16 = 1; pub const COLBAR_H: u16 = 1; pub const TagLine = @import("tag_line.zig").Line; pub const BOX_H: u16 = 1; pub fn taglineBandOffset(row: u16, canvas_h: f32, cell_h: u32, tagline_h: u32) u32 { const spare = cell_h -| tagline_h; const border = topbarPaneBorderPixels(cell_h, tagline_h); if (row == 0) return spare - border / 2; if (row == TOPBAR_H) return border - border / 2; const rows: u32 = @max(1, @as(u32, @intFromFloat(@floor(canvas_h / @as(f32, @floatFromInt(cell_h)))))); if (@as(u32, row) + 1 >= rows) return spare; return spare / 2; } pub fn topbarPaneBorderPixels(cell_h: u32, tagline_h: u32) u32 { const spare = cell_h -| tagline_h; return @min(@as(u32, config.gui_topbar_pane_border_px), spare * 2); } pub fn taglineOriginCol(p: *const Pardes, col: u16, row: u16, track: ?layout.Track) f32 { if (row < TOPBAR_H) return 0; if (row < TOPBAR_H + p.columnBarHeight()) { for (p.col_x[0..p.ncol], p.col_w[0..p.ncol]) |x, w| if (col >= x and col - x < w) return @floatFromInt(x); } if (track) |active| { const box = active.contentBox(); const tag_y = if (p.settings.tag_bottom) box.y + box.h - @as(f32, @floatFromInt(BOX_H)) else box.y; if (@as(f32, @floatFromInt(row)) >= tag_y and @as(f32, @floatFromInt(row)) < tag_y + @as(f32, @floatFromInt(BOX_H))) return box.x; } for (p.panes, 0..) |slot, id| { if (slot == null) continue; const r = p.rects[id]; const tag_y = if (p.settings.tag_bottom) r.y + r.h -| BOX_H else r.y; if (row == tag_y and col >= r.x and col < r.x + r.w) return @floatFromInt(r.x); } return @floatFromInt(col); } pub fn taglineOriginColForFrame(p: *const Pardes, col: u16, row: u16) f32 { for (p.surface.panelTracks()) |track| if (track.contentBox().contains(col, row)) return taglineOriginCol(p, col, row, track); return taglineOriginCol(p, col, row, null); } test "paint order is moving, then opening, then closing tombstones on top" { const Track = layout.Track; // Deliberately interleaved on the way in: the phases are what order the // output, not the slot they happened to occupy. const live = [_]?Track{ .{ .serial = 11, .pane = 3, .phase = .opening, .effect = .slide }, .{ .serial = 12, .pane = 1, .phase = .moving, .effect = .zoom }, null, .{ .serial = 14, .pane = 5, .phase = .opening, .effect = .ascii }, .{ .serial = 15, .pane = 2, .phase = .moving, .effect = .dissolve }, }; const closing = [_]Track{ .{ .serial = 16, .pane = 2, .phase = .closing, .effect = .vertical }, }; var out: [8]Track = undefined; const len = layout.paintOrder(&live, &closing, &out); var serials: [8]u32 = undefined; for (out[0..len], 0..) |track, i| serials[i] = track.serial; try std.testing.expectEqualSlices(u32, &.{ 12, 15, 11, 14, 16 }, serials[0..len]); // A host's array is fixed-size and the core's is not its business: writing // past it would be a buffer overrun in whichever shell had the smaller one. var tight: [2]Track = undefined; try std.testing.expectEqual(@as(usize, 2), layout.paintOrder(&live, &closing, &tight)); try std.testing.expectEqual(@as(u32, 12), tight[0].serial); try std.testing.expectEqual(@as(u32, 15), tight[1].serial); } pub fn gridColAt(p: ?*const Pardes, x: f32, row: u16, body_w: f32, tagline_w: f32) u16 { const body = @max(body_w, 1); const tag = @max(tagline_w, 1); if (row < TOPBAR_H) return colFromSpan(x, tag); if (p) |core| if (row < TOPBAR_H + core.columnBarHeight()) { for (core.col_x[0..core.ncol], core.col_w[0..core.ncol]) |origin, width| { if (width == 0) continue; const left = @as(f32, @floatFromInt(origin)) * body; const right = @as(f32, @floatFromInt(origin + width)) * body; if (x < left or x >= right) continue; return origin + @as(u16, @intFromFloat(@min( @floor(@max(0, x - left) / tag), @as(f32, @floatFromInt(width - 1)), ))); } }; if (p) |core| for (core.panes, 0..) |slot, id| { if (slot == null) continue; const r = core.rects[id]; const tag_y = if (core.settings.tag_bottom) r.y + r.h -| BOX_H else r.y; if (row != tag_y or r.w == 0) continue; const left = @as(f32, @floatFromInt(r.x)) * body; const right = @as(f32, @floatFromInt(r.x + r.w)) * body; if (x < left or x >= right) continue; const within: u16 = @intFromFloat(@min( @floor(@max(0, x - left) / tag), @as(f32, @floatFromInt(r.w - 1)), )); return r.x + within; }; return colFromSpan(x, body); } fn colFromSpan(x: f32, span: f32) u16 { return @intFromFloat(@min(@floor(@max(x, 0) / span), 10_000)); } test "a compact tagline anchors at its own pane, and both shells step from the same origin" { if (platform == .web) return; const p = try Pardes.init(std.testing.allocator, .{ .cols = 120, .rows = 24 }); defer p.deinit(); _ = try p.newShell(1, ""); try std.testing.expect(layout.splitColumn(p, 0, 1, false)); p.sync(); // Two panes side by side put two tags on ONE row, which is the case a // per-row origin gets wrong and the reason this is asked per cell. const left = p.rects[0]; const right = p.rects[1]; try std.testing.expect(right.x > left.x); try std.testing.expectEqual(@as(f32, @floatFromInt(left.x)), taglineOriginCol(p, left.x + 3, left.y, null)); try std.testing.expectEqual(@as(f32, @floatFromInt(right.x)), taglineOriginCol(p, right.x + 3, right.y, null)); // The topbar's origin is column zero always — it is not a pane rect, which // is why row zero is right even in a window with no core to ask. try std.testing.expectEqual(@as(f32, 0), taglineOriginCol(p, 40, 0, null)); const body_row = left.y + 2; try std.testing.expectEqual(@as(f32, 7), taglineOriginCol(p, 7, body_row, null)); const body_w: f32 = 10; const tag_w: f32 = 8; for (p.col_x[0..p.ncol], p.col_w[0..p.ncol]) |origin, width| { if (width == 0) continue; const col = origin; try std.testing.expectEqual(@as(f32, @floatFromInt(origin)), taglineOriginCol(p, col, TOPBAR_H, null)); const glyph_x = @as(f32, @floatFromInt(origin)) * body_w; try std.testing.expectEqual(col, gridColAt(p, glyph_x + tag_w / 2, TOPBAR_H, body_w, tag_w)); } for ([_]u16{ 0, 1, 5, 40, 119 }) |col| { const origin = taglineOriginCol(p, col, left.y, null); const sdl = origin * (body_w - tag_w) + @as(f32, @floatFromInt(col)) * tag_w; const appkit = origin * body_w + (@as(f32, @floatFromInt(col)) - origin) * tag_w; try std.testing.expectEqual(sdl, appkit); } for ([_]u16{ 0, 1, 4, 9 }) |offset| { const col = left.x + offset; if (offset >= left.w) break; const origin = taglineOriginCol(p, col, left.y, null); const glyph_x = origin * body_w + (@as(f32, @floatFromInt(col)) - origin) * tag_w; try std.testing.expectEqual(col, gridColAt(p, glyph_x + tag_w / 2, left.y, body_w, tag_w)); } // The topbar's pointer grid is compact from column zero, with no pane to // anchor to — the one tag row that is right with or without a core. try std.testing.expectEqual(@as(u16, 3), gridColAt(p, 3.5 * tag_w, 0, body_w, tag_w)); try std.testing.expectEqual(@as(u16, 3), gridColAt(null, 3.5 * tag_w, 0, body_w, tag_w)); // A body row is untouched: still the body grid, still divided by the body // cell. Only tag rows compact. try std.testing.expectEqual(@as(u16, 3), gridColAt(p, 3.5 * body_w, body_row, body_w, tag_w)); } pub const Loc = struct { pane: u16, serial: u32, line: u32, col: u32, }; test { _ = @import("body_layer.zig"); _ = @import("tag_layer.zig"); } const pane_tail = " " ++ config.pane_builtins_str; const pdf_pane_tail = " " ++ config.pdf_pane_builtins_str; const file_pane_tail = " " ++ config.file_pane_builtins_str; const context_pane_tail = file_pane_tail ++ " TreeContext"; const locations_pane_tail = file_pane_tail ++ " LocationsConfig"; const terminal_pane_tail = " " ++ config.terminal_pane_builtins_str; const dirty_marker = " *"; const legacy_pane_tail = " Del"; const legacy_file_pane_tail = " Save Del"; // The defaults from the release before Newtty joined every tagline. Recognized // so a dump written then upgrades instead of keeping the old word as a tail. const prev_pane_tail = " New Del"; const prev_file_pane_tail = " Save New Del"; const legacy_terminal_pane_tail = " New Del Filter"; // ...and the terminal default from the release before Save reached it, when a // scrollback was not yet something you could write to a path. const prev_terminal_pane_tail = " New Newtty Del Filter"; const pre_tty_terminal_pane_tail = " Save New Newtty Del Filter"; const pre_column_pane_tail = " New Newtty Del"; const pre_column_file_pane_tail = " Save New Newtty Del"; const pre_column_pdf_pane_tail = " New Newtty Del PdfSections PdfTint"; const pre_column_terminal_pane_tail = " Save New Newtty Del Tty Filter"; const pre_collapse_pane_tail = " Tty Del"; const pre_collapse_file_pane_tail = " Save Tty Del"; const pre_collapse_pdf_pane_tail = " Tty Del PdfSections PdfTint"; const pre_collapse_terminal_pane_tail = " Save Tty Del Togglettymode Filter"; const pre_tty_first_terminal_pane_tail = " Save Tty Del Togglettymode Filter Collapse"; const Builtin = builtins.registry.Builtin(); pub const test_api = if (@import("builtin").is_test) struct { pub fn tagText(p: *Pardes, arena: std.mem.Allocator, pane: *Pane) ![]u8 { return p.tagText(arena, pane); } pub fn runBuiltin( p: *Pardes, name: []const u8, id: usize, txt: []const u8, arg: ?[]const u8, ) bool { const command = std.meta.stringToEnum(Builtin, name) orelse return false; p.runBuiltin(command, id, txt, arg); return true; } pub fn sync(p: *Pardes) void { p.sync(); } pub fn searchStep(p: *Pardes, id: usize, delta: i32) bool { return p.searchStep(id, delta); } pub fn heldSelection(p: *Pardes, first: usize) ?[]const u8 { return p.heldSelection(first); } } else struct {}; fn nextPipeEffect(p: *Pardes) ?u32 { while (p.nextEffect()) |effect| switch (effect) { .pipe => |request| return request.id, else => {}, }; return null; } fn drainForSavePath(p: *Pardes, buf: []u8) ?[]const u8 { var len: ?usize = null; while (p.nextEffect()) |effect| { switch (effect) { .save_text => |st| { const path = st.path.slice(); @memcpy(buf[0..path.len], path); len = path.len; }, else => {}, } p.perform(effect); } return if (len) |n| buf[0..n] else null; } fn drainForAttach(p: *Pardes) ?AttachRequest { while (p.nextEffect()) |effect| p.perform(effect); return p.takeAttach(); } test "Attach asks for a session and tears nothing down" { if (comptime !can_attach) return; const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true }); defer p.deinit(); while (p.nextEffect()) |_| {} // Bare means whichever session is there, and that is an EMPTY name rather // than an absent request: the frontend still has to go and look. try std.testing.expect(p.executeBuiltinLine(0, "Attach")); const bare = drainForAttach(p) orelse return error.NoAttachAsked; try std.testing.expectEqualStrings("", bare.name); try std.testing.expectEqual(@as(u8, 0), bare.pane); // ...and the named form carries its tail, the way `Theme ` does. try std.testing.expect(p.executeBuiltinLine(0, "Attach work")); const named = drainForAttach(p) orelse return error.NoAttachAsked; try std.testing.expectEqualStrings("work", named.name); try std.testing.expect(p.takeAttach() == null); // taken once, then gone // The word only ASKS. A core that tore itself down here could not be handed // back intact when the connect fails, and that is the entire guarantee. try std.testing.expect(!p.quit); try std.testing.expect(p.panes[0] != null); } test "Detach asks the frontend to leave, and says so when there is nothing to leave" { if (comptime !can_attach) return; // compiled out where nothing polls `takeAttach` — see `can_attach` const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true }); defer p.deinit(); while (p.nextEffect()) |_| {} try std.testing.expect(p.executeBuiltinLine(0, "Detach")); const asked = while (p.nextEffect()) |effect| switch (effect) { .detach => |d| break d, else => {}, } else return error.NoDetachAsked; try std.testing.expectEqual(@as(u8, 0), asked.pane); p.perform(.{ .detach = asked }); const pane = p.panes[0].?; try std.testing.expectEqualStrings("detach: NotAttached", pane.msg[0..pane.msg_len]); try std.testing.expect(!p.quit); } test "selection pipe prompt submits exact request and Escape cancels" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true }); defer p.deinit(); while (p.nextEffect()) |_| {} const pane = try p.setTestFile("one\ntwo\n"); pane.cur_row = 0; pane.cur_col = 2; pane.vsel = .{ .active = true, .row = 0, .col = 0, .explicit = true }; p.update(.{ .key = .{ .cp = '|' } }); try std.testing.expect(pane.tag_edit and pane.prompt == .pipe); try std.testing.expect(std.mem.endsWith(u8, pane.tagSlice(), config.pipe_marker)); try std.testing.expect(nextPipeEffect(p) == null); p.update(.{ .key = .{ .cp = 't', .text = "tr a-z A-Z" } }); p.update(.{ .key = .{ .cp = Key.enter } }); try std.testing.expect(!pane.tag_edit and pane.prompt != .pipe); const id = nextPipeEffect(p) orelse return error.MissingPipeEffect; const request = p.pipeRequest(id) orelse return error.MissingPipeRequest; try std.testing.expectEqualStrings("tr a-z A-Z", request.command); try std.testing.expectEqualStrings("/", request.cwd); try std.testing.expectEqual(@as(usize, 1), request.inputs.len); try std.testing.expectEqualSlices(u8, "one", request.inputs[0].bytes); const before = pane.file.?.content; p.update(.{ .key = .{ .cp = '|' } }); p.update(.{ .key = .{ .cp = 'c', .text = "cat" } }); p.update(.{ .key = .{ .cp = Key.escape } }); try std.testing.expect(!pane.tag_edit and pane.prompt != .pipe); try std.testing.expectEqualSlices(u8, before, pane.file.?.content); try std.testing.expect(nextPipeEffect(p) == null); } test "gj/gk step the wrapped rows a body draws while j/k keep the file's lines" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true }); defer p.deinit(); while (p.nextEffect()) |_| {} const long = "a" ** 400; const pane = try p.setTestFile(long ++ "\nsecond\n"); p.settings.wrap = true; const width = panes.File.wrapWidth(pane, true); try std.testing.expect(width > 4 and long.len > width * 3); // gj holds the column INSIDE the row and lands on the next break; the line // is one line the whole way down, which is the whole point. pane.cur_row = 0; pane.cur_col = 3; pane.sticky_col = -1; p.update(.{ .key = .{ .cp = 'g' } }); p.update(.{ .key = .{ .cp = 'j' } }); try std.testing.expectEqual(@as(i32, 0), pane.cur_row); try std.testing.expectEqual(@as(i32, @intCast(width + 3)), pane.cur_col); p.update(.{ .key = .{ .cp = 'g' } }); p.update(.{ .key = .{ .cp = 'j' } }); try std.testing.expectEqual(@as(i32, @intCast(width * 2 + 3)), pane.cur_col); p.update(.{ .key = .{ .cp = 'g' } }); p.update(.{ .key = .{ .cp = 'k' } }); try std.testing.expectEqual(@as(i32, @intCast(width + 3)), pane.cur_col); // A count is counted in rows, not lines. p.update(.{ .key = .{ .cp = '2' } }); p.update(.{ .key = .{ .cp = 'g' } }); p.update(.{ .key = .{ .cp = 'j' } }); try std.testing.expectEqual(@as(i32, 0), pane.cur_row); try std.testing.expectEqual(@as(i32, @intCast(width * 3 + 3)), pane.cur_col); // Plain j/k are untouched: one FILE line each, whatever the body drew. pane.cur_col = 3; pane.sticky_col = -1; p.update(.{ .key = .{ .cp = 'j' } }); try std.testing.expectEqual(@as(i32, 1), pane.cur_row); p.update(.{ .key = .{ .cp = 'k' } }); try std.testing.expectEqual(@as(i32, 0), pane.cur_row); // The last row of a line steps into the next line's first row, and the // first row steps back onto the previous line's LAST row. pane.cur_row = 0; pane.cur_col = @intCast(long.len - 1); pane.sticky_col = -1; p.update(.{ .key = .{ .cp = 'g' } }); p.update(.{ .key = .{ .cp = 'j' } }); try std.testing.expectEqual(@as(i32, 1), pane.cur_row); pane.cur_col = 0; pane.sticky_col = -1; p.update(.{ .key = .{ .cp = 'g' } }); p.update(.{ .key = .{ .cp = 'k' } }); try std.testing.expectEqual(@as(i32, 0), pane.cur_row); try std.testing.expect(pane.cur_col >= @as(i32, @intCast(long.len - long.len % width))); // Wrap OFF: a line is one visual row, so gj/gk ARE j/k again. p.settings.wrap = false; pane.cur_row = 0; pane.cur_col = 3; pane.sticky_col = -1; p.update(.{ .key = .{ .cp = 'g' } }); p.update(.{ .key = .{ .cp = 'j' } }); try std.testing.expectEqual(@as(i32, 1), pane.cur_row); } test "the message log keeps what the row forgets, and collapses repeats" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 100, .rows = 30 }); defer p.deinit(); p.setMessage(0, "14:32:07 saved /x.zig"); p.setMessage(0, "14:32:09 saved /x.zig"); // same event, later clock p.setMessage(0, "save: AccessDenied"); try std.testing.expectEqual(@as(usize, 2), p.messages_len); const first = p.messageLog(0).?; try std.testing.expectEqual(@as(u16, 2), first.repeats); // ...and the NEWEST wording is what survives, so the row carries the last // time it happened rather than the first. try std.testing.expectEqualStrings("14:32:09 saved /x.zig", first.slice()); try std.testing.expectEqualStrings("save: AccessDenied", p.messageLog(1).?.slice()); try std.testing.expect(p.messageLog(2) == null); // The same text from a DIFFERENT pane is a different event: one pane's // failure must not be recorded as another's. p.setMessage(1, "save: AccessDenied"); try std.testing.expectEqual(@as(usize, 3), p.messages_len); try std.testing.expectEqual(@as(u8, 1), p.messageLog(2).?.pane); // Progress is NOT logged: it arrives several times a second for a whole // index and would push everything else out (`setStatus`). p.setStatus(0, "14:32:10 lsp rust-analyzer: Indexing 47%"); try std.testing.expectEqual(@as(usize, 3), p.messages_len); // The ring wraps rather than grows, and still reads oldest-first. for (0..limits.message_log + 5) |i| { var buf: [32]u8 = undefined; p.setMessage(0, std.fmt.bufPrint(&buf, "line {d}", .{i}) catch unreachable); } try std.testing.expectEqual(@as(usize, limits.message_log), p.messages_len); try std.testing.expectEqualStrings("line 5", p.messageLog(0).?.slice()); var last_buf: [32]u8 = undefined; const want_last = std.fmt.bufPrint(&last_buf, "line {d}", .{limits.message_log + 4}) catch unreachable; try std.testing.expectEqualStrings(want_last, p.messageLog(limits.message_log - 1).?.slice()); } test "the acme chords act once per selection, not once on the primary" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 100, .rows = 30 }); defer p.deinit(); while (p.nextEffect()) |_| {} // Two selections, each naming a DIFFERENT builtin, so what ran is visible // in the layout rather than in a shell nobody can read from a test. const pane = try p.setTestFile("Newcol\nNewcol\n"); const ranges = [_]modal.Selection{ .{ .anchor = 0, .head = 6 }, .{ .anchor = 7, .head = 13 }, }; pane.setRanges(pane.file.?.content, &ranges, &.{}, 0, true); try std.testing.expectEqual(@as(u8, 1), pane.nsel); const before = p.ncol; p.update(.{ .key = .{ .cp = Key.tab } }); // config.exec_key: Exec // BOTH ran. Before this the chord read the primary range and dropped the // other cursor, so one keystroke over two cursors made one column. try std.testing.expectEqual(before + 2, p.ncol); // ...and the chord consumed the selection exactly as it does with one. try std.testing.expectEqual(@as(u8, 0), p.panes[0].?.nsel); } test "selection pipe replaces all ranges atomically and undo restores them" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true }); defer p.deinit(); while (p.nextEffect()) |_| {} const pane = try p.setTestFile("aa bb cc\n"); const ranges = [_]modal.Selection{ .{ .anchor = 0, .head = 2 }, .{ .anchor = 6, .head = 8 }, }; pane.setRanges(pane.file.?.content, &ranges, &.{}, 0, true); p.update(.{ .key = .{ .cp = '|' } }); p.update(.{ .key = .{ .cp = 'c', .text = "cat" } }); p.update(.{ .key = .{ .cp = Key.enter } }); const id = nextPipeEffect(p) orelse return error.MissingPipeEffect; const request = p.pipeRequest(id) orelse return error.MissingPipeRequest; try std.testing.expectEqual(@as(usize, 2), request.inputs.len); try std.testing.expectEqualSlices(u8, "aa", request.inputs[0].bytes); try std.testing.expectEqualSlices(u8, "cc", request.inputs[1].bytes); var denied = std.testing.FailingAllocator.init(gpa, .{ .fail_index = 0 }); p.scratch.deinit(); p.scratch = .init(denied.allocator()); const outputs: []const []const u8 = &.{ "AA\n", "" }; p.update(.{ .pipe_resp = .{ .id = id, .success = true, .outputs = outputs } }); try std.testing.expectEqualSlices(u8, "AA bb \n", pane.file.?.content); try std.testing.expectEqual(@as(usize, 1), pane.file.?.history.undo_len); try std.testing.expectEqual(@as(u8, 1), pane.nsel); try std.testing.expectEqual(@as(i32, 1), pane.cur_col); try std.testing.expectEqual(@as(i32, 6), pane.sels[0].col); try std.testing.expect(!denied.has_induced_failure); p.update(.{ .key = .{ .cp = 'u' } }); try std.testing.expectEqualSlices(u8, "aa bb cc\n", pane.file.?.content); } test "the four shell behaviours put their output where helix puts it" { const gpa = std.testing.allocator; const Case = struct { key: Key, outputs: []const []const u8, want: []const u8, want_effect: bool = true, }; // One selection, `bb`, in `aa bb cc\n` — bytes 3..5. for ([_]Case{ // `|` replaces the selection with what the command wrote. .{ .key = .{ .cp = '|' }, .outputs = &.{"XX"}, .want = "aa XX cc\n" }, // `!` inserts BEFORE it, and the selection survives to its right. .{ .key = .{ .cp = '!' }, .outputs = &.{"XX"}, .want = "aa XXbb cc\n" }, // `A-!` appends AFTER it. .{ .key = .{ .cp = '!', .alt = true }, .outputs = &.{"XX"}, .want = "aa bbXX cc\n" }, // `A-|` runs the command and leaves the text completely alone: the // point is the side effect, not the output. .{ .key = .{ .cp = '|', .alt = true }, .outputs = &.{"XX"}, .want = "aa bb cc\n" }, }) |case| { const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 100, .rows = 30 }); defer p.deinit(); while (p.nextEffect()) |_| {} const pane = try p.setTestFile("aa bb cc\n"); pane.setRanges(pane.file.?.content, &.{.{ .anchor = 3, .head = 5 }}, &.{}, 0, true); p.update(.{ .key = case.key }); for ("cmd") |c| p.update(.{ .key = .{ .cp = c, .text = &.{c} } }); p.update(.{ .key = .{ .cp = Key.enter } }); const id = nextPipeEffect(p) orelse return error.MissingPipeEffect; p.update(.{ .pipe_resp = .{ .id = id, .success = true, .outputs = case.outputs } }); try std.testing.expectEqualSlices(u8, case.want, pane.file.?.content); } } test "a command with no stdin runs once and every cursor gets that one answer" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 100, .rows = 30 }); defer p.deinit(); while (p.nextEffect()) |_| {} const pane = try p.setTestFile("aa bb cc\n"); const ranges = [_]modal.Selection{ .{ .anchor = 0, .head = 2 }, .{ .anchor = 6, .head = 8 }, }; pane.setRanges(pane.file.?.content, &ranges, &.{}, 0, true); p.update(.{ .key = .{ .cp = '!' } }); // insert-output: no stdin for ("date") |c| p.update(.{ .key = .{ .cp = c, .text = &.{c} } }); p.update(.{ .key = .{ .cp = Key.enter } }); const id = nextPipeEffect(p) orelse return error.MissingPipeEffect; const request = p.pipeRequest(id) orelse return error.MissingPipeRequest; try std.testing.expectEqual(@as(usize, 1), request.inputs.len); try std.testing.expectEqualSlices(u8, "", request.inputs[0].bytes); p.update(.{ .pipe_resp = .{ .id = id, .success = true, .outputs = &.{"T"} } }); try std.testing.expectEqualSlices(u8, "Taa bb Tcc\n", pane.file.?.content); try std.testing.expectEqual(@as(usize, 1), pane.file.?.history.undo_len); } test "selection pipe failure and stale completion never mutate the file" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true }); defer p.deinit(); while (p.nextEffect()) |_| {} const pane = try p.setTestFile("abc\n"); pane.cur_col = 2; pane.vsel = .{ .active = true, .row = 0, .col = 0, .explicit = true }; p.update(.{ .key = .{ .cp = '|' } }); p.update(.{ .key = .{ .cp = 'c', .text = "cat" } }); p.update(.{ .key = .{ .cp = Key.enter } }); const failed_id = nextPipeEffect(p) orelse return error.MissingPipeEffect; p.update(.{ .pipe_resp = .{ .id = failed_id, .success = false, .outputs = &.{} } }); try std.testing.expectEqualSlices(u8, "abc\n", pane.file.?.content); try std.testing.expectEqual(@as(usize, 0), pane.file.?.history.undo_len); p.update(.{ .key = .{ .cp = '|' } }); p.update(.{ .key = .{ .cp = 'c', .text = "cat" } }); p.update(.{ .key = .{ .cp = Key.enter } }); const stale_id = nextPipeEffect(p) orelse return error.MissingPipeEffect; panes.File.setContent(p, &pane.file.?, try gpa.dupe(u8, "changed\n")); p.update(.{ .pipe_resp = .{ .id = stale_id, .success = true, .outputs = &.{"ABC"} } }); try std.testing.expectEqualSlices(u8, "changed\n", pane.file.?.content); try std.testing.expectEqual(@as(usize, 0), pane.file.?.history.undo_len); } test "a failed filter opens an errors buffer carrying the command's own words" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 100, .rows = 30 }); defer p.deinit(); while (p.nextEffect()) |_| {} const pane = try p.setTestFile("abc\n"); pane.cur_col = 2; pane.vsel = .{ .active = true, .row = 0, .col = 0, .explicit = true }; p.update(.{ .key = .{ .cp = '|' } }); for ("trr") |c| p.update(.{ .key = .{ .cp = c, .text = &.{c} } }); p.update(.{ .key = .{ .cp = Key.enter } }); const id = nextPipeEffect(p) orelse return error.MissingPipeEffect; // The shape a real runner brings home for `| trr`: nonzero exit, and the // shell's own sentence about it. var stderr = "sh: line 1: trr: command not found\n".*; p.update(.{ .pipe_resp = .{ .id = id, .success = false, .outputs = &.{}, .failure = .{ .kind = .exit, .code = 127, .stderr = &stderr }, } }); // The text is untouched — a failed filter is not an edit... try std.testing.expectEqualSlices(u8, "abc\n", pane.file.?.content); try std.testing.expectEqual(@as(usize, 0), pane.file.?.history.undo_len); // ...and the cursor did not go anywhere, so `|` again edits the same file. try std.testing.expectEqual(@as(usize, 0), p.active); var found: ?[]const u8 = null; for (p.panes) |slot| { const q = slot orelse continue; const qf = q.file orelse continue; const o = qf.output orelse continue; if (std.meta.activeTag(o.from) == .errors) found = qf.content; } const report = found orelse return error.NoErrorsBuffer; try std.testing.expect(std.mem.indexOf(u8, report, "| trr") != null); try std.testing.expect(std.mem.indexOf(u8, report, "exit status 127") != null); try std.testing.expect(std.mem.indexOf(u8, report, "command not found") != null); } test "selection pipe rejects a reused pane slot and a superseded request" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true }); defer p.deinit(); while (p.nextEffect()) |_| {} var pane = try p.setTestFile("old\n"); pane.cur_col = 2; pane.vsel = .{ .active = true, .row = 0, .col = 0, .explicit = true }; p.update(.{ .key = .{ .cp = '|' } }); p.update(.{ .key = .{ .cp = 'c', .text = "cat" } }); p.update(.{ .key = .{ .cp = Key.enter } }); const replaced_id = nextPipeEffect(p) orelse return error.MissingPipeEffect; pane = try p.setTestFile("new\n"); // same slot, different serial p.update(.{ .pipe_resp = .{ .id = replaced_id, .success = true, .outputs = &.{"OLD"} } }); try std.testing.expectEqualSlices(u8, "new\n", pane.file.?.content); try std.testing.expectEqual(@as(usize, 0), pane.file.?.history.undo_len); pane.cur_col = 2; pane.vsel = .{ .active = true, .row = 0, .col = 0, .explicit = true }; p.update(.{ .key = .{ .cp = '|' } }); p.update(.{ .key = .{ .cp = 'c', .text = "cat" } }); p.update(.{ .key = .{ .cp = Key.enter } }); const first_id = nextPipeEffect(p) orelse return error.MissingPipeEffect; p.update(.{ .key = .{ .cp = '|' } }); p.update(.{ .key = .{ .cp = 'c', .text = "tr a-z A-Z" } }); p.update(.{ .key = .{ .cp = Key.enter } }); const latest_id = nextPipeEffect(p) orelse return error.MissingPipeEffect; try std.testing.expect(first_id != latest_id); p.update(.{ .pipe_resp = .{ .id = first_id, .success = true, .outputs = &.{"BAD"} } }); try std.testing.expectEqualSlices(u8, "new\n", pane.file.?.content); p.update(.{ .pipe_resp = .{ .id = latest_id, .success = true, .outputs = &.{"NEW"} } }); try std.testing.expectEqualSlices(u8, "NEW\n", pane.file.?.content); try std.testing.expectEqual(@as(usize, 1), pane.file.?.history.undo_len); } test "selection pipe binding is file-normal-only" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true }); defer p.deinit(); while (p.nextEffect()) |_| {} const pane = try p.setTestFile("x"); pane.file.?.output = .{ .from = .search }; p.update(.{ .key = .{ .cp = '|' } }); try std.testing.expect(pane.prompt != .pipe); pane.file.?.output = null; pane.mode = .insert; p.update(.{ .key = .{ .cp = '|', .text = "|" } }); try std.testing.expect(pane.prompt != .pipe); try std.testing.expectEqualSlices(u8, "|x", pane.file.?.content); } test "insert newline adds one indent level after a closing call" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true }); defer p.deinit(); while (p.nextEffect()) |_| {} const pane = try p.setTestFile(" callback({})\n"); pane.mode = .insert; pane.cur_col = 16; p.update(.{ .key = .{ .cp = Key.enter } }); try std.testing.expectEqualSlices(u8, " callback({})\n \n", pane.file.?.content); try std.testing.expectEqual(@as(i32, 1), pane.cur_row); try std.testing.expectEqual(@as(i32, 8), pane.cur_col); } test "startup config runs builtin lines in order and isolates bad lines" { const p = try Pardes.init(std.testing.allocator, .{ .startup_config = \\Theme dark \\DefinitelyNotABuiltin \\Kill trailing-garbage \\Theme no-such-theme \\Theme acme , }); defer p.deinit(); try std.testing.expectEqualStrings("acme", p.theme().name); try std.testing.expect(!p.animationActive()); try std.testing.expectEqual(ChromeTheme.fromTheme(p.theme()), p.chromeTheme().*); try std.testing.expect(!p.quit); for (0..p.effects_len) |i| switch (p.effects[(p.effects_head + i) % p.effects.len]) { .write => |w| { try std.testing.expect(std.mem.indexOf(u8, w.bytes.slice(), "DefinitelyNotABuiltin") == null); try std.testing.expect(std.mem.indexOf(u8, w.bytes.slice(), "Kill") == null); }, else => {}, }; } test "ThemeFile parses ZON atomically and Theme returns to the compiled ring" { if (platform == .web) return; const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .config_dir = "/tmp/pardes-config", }); defer p.deinit(); while (p.nextEffect()) |_| {} p.requestThemeFile(0, "themes/mine.zon"); const request_effect = while (p.nextEffect()) |effect| switch (effect) { .theme_file => |request| break request, else => {}, } else return error.MissingThemeFileEffect; try std.testing.expect(request_effect.on); const request = p.themeFileRequest(request_effect.generation) orelse return error.MissingThemeFileRequest; try std.testing.expectEqualStrings("/tmp/pardes-config/themes/mine.zon", request.path); var custom = themes[0]; custom.name = "mine"; custom.bg = .{ 1, 2, 3 }; custom.tag_bg = .{ 4, 5, 6 }; var encoded: std.Io.Writer.Allocating = .init(std.testing.allocator); defer encoded.deinit(); try std.zon.stringify.serialize(custom, .{ .whitespace = true }, &encoded.writer); try std.testing.expect(p.loadThemeFile(request_effect.generation, encoded.written(), false)); try std.testing.expectEqualStrings("mine", p.theme().name); try std.testing.expectEqual(@as(?[3]u8, .{ 1, 2, 3 }), p.theme().bg); try std.testing.expectEqual(ChromeTheme.fromTheme(p.theme()), p.chromeTheme().*); // A half-written save is rejected without disturbing the last good value. try std.testing.expect(!p.loadThemeFile(request_effect.generation, ".{ .name = ", true)); try std.testing.expectEqualStrings("mine", p.theme().name); try std.testing.expectEqual(@as(?[3]u8, .{ 1, 2, 3 }), p.theme().bg); p.setThemeIndex(0); try std.testing.expectEqualStrings(themes[0].name, p.theme().name); try std.testing.expect(p.themeFileRequest(request_effect.generation) == null); const stop = while (p.nextEffect()) |effect| switch (effect) { .theme_file => |theme_file| break theme_file, else => {}, } else return error.MissingThemeFileStop; try std.testing.expect(!stop.on); } test "scene effects keep the idle frontend animation clock active" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer p.deinit(); try std.testing.expect(!p.animationActive()); p.settings.scene_effects.crt = true; try std.testing.expect(p.animationActive()); p.settings.scene_effects = .{ .ripple = true }; try std.testing.expect(p.animationActive()); p.settings.scene_effects = .{ .glitch = true }; try std.testing.expect(p.animationActive()); p.settings.scene_effects = .{}; try std.testing.expect(!p.animationActive()); } test "runtime theme changes animate chrome and retarget without a jump" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer p.deinit(); const first = p.chromeTheme().*; try std.testing.expect(!p.animationActive()); const dark_index = for (themes, 0..) |th, i| { if (std.mem.eql(u8, th.name, "dark")) break i; } else return error.MissingDarkTheme; const acme_index = for (themes, 0..) |th, i| { if (std.mem.eql(u8, th.name, "acme")) break i; } else return error.MissingAcmeTheme; p.setThemeIndex(dark_index); // Target semantics are immediate, while the displayed chrome begins at // the exact previous frame. try std.testing.expectEqualStrings("dark", p.theme().name); try std.testing.expectEqual(first, p.chromeTheme().*); try std.testing.expect(p.animationActive()); p.update(.tick); p.update(.tick); p.update(.tick); const midflight = p.chromeTheme().*; try std.testing.expect(!std.meta.eql(first, midflight)); p.setThemeIndex(acme_index); try std.testing.expectEqualStrings("acme", p.theme().name); try std.testing.expectEqual(midflight, p.chromeTheme().*); try std.testing.expect(p.animationActive()); for (0..layout.Animation.transition_steps) |_| p.update(.tick); try std.testing.expect(!p.animationActive()); try std.testing.expectEqual(ChromeTheme.fromTheme(p.theme()), p.chromeTheme().*); // Extra ticks are inert at the exact endpoint. p.update(.tick); try std.testing.expectEqual(ChromeTheme.fromTheme(p.theme()), p.chromeTheme().*); } test "render uses target content colors while chrome is in flight" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 40, .rows = 12 }); defer p.deinit(); const source_chrome = p.chromeTheme().*; const acme_index = for (themes, 0..) |th, i| { if (std.mem.eql(u8, th.name, "acme")) break i; } else return error.MissingAcmeTheme; p.setThemeIndex(acme_index); var frame = std.heap.ArenaAllocator.init(std.testing.allocator); defer frame.deinit(); const surface = try p.render(frame.allocator()); // The anchored topbar has not advanced from its source frame. try std.testing.expectEqual( Color{ .rgb = source_chrome.tag_bg }, surface.at(0, 0).style.bg, ); const topbar_cell = surface.at(0, 0); try std.testing.expect(!std.mem.eql(u8, topbar_cell.grapheme(), " ")); try std.testing.expectEqual(FontRole.tagline, topbar_cell.style.font_role); const r = p.rects[0]; const tag_cell = find_tag_cell: { var x = r.x + config.GUTTER; while (x < r.x + r.w) : (x += 1) { const cell = surface.at(x, r.y); if (!std.mem.eql(u8, cell.grapheme(), " ")) break :find_tag_cell cell; } return error.MissingTagText; }; try std.testing.expectEqual(FontRole.tagline, tag_cell.style.font_role); try std.testing.expectEqual(FontRole.tagline, surface.at(r.x, r.y).style.font_role); try std.testing.expectEqual(FontRole.tagline, surface.at(r.x + r.w - 1, r.y).style.font_role); // The document/terminal page does not ride the chrome animation: acme's // target background is already present in the same render. const body = surface.at(r.x + r.w - 1, r.y + r.h - 1); try std.testing.expectEqual(FontRole.body, body.style.font_role); try std.testing.expectEqual( Color{ .rgb = p.theme().bg.? }, body.style.bg, ); try std.testing.expect(p.animationActive()); } test "topbar hover highlights exactly the executable word" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 40, .rows = 12 }); defer p.deinit(); var frame = std.heap.ArenaAllocator.init(std.testing.allocator); defer frame.deinit(); _ = try p.render(frame.allocator()); p.update(.{ .mouse = .{ .button = .none, .kind = .motion, .col = 1, .row = 0 } }); _ = frame.reset(.retain_capacity); const hovered = try p.render(frame.allocator()); for (0.."Newcol".len) |col| { const cell = hovered.at(@intCast(col), 0); try std.testing.expectEqual(Color{ .rgb = p.theme().sel_bg }, cell.style.bg); try std.testing.expectEqual(Color{ .rgb = p.theme().sel_fg }, cell.style.fg); } try std.testing.expectEqual(Color{ .rgb = p.chromeTheme().tag_bg }, hovered.at(6, 0).style.bg); p.update(.{ .mouse = .{ .button = .none, .kind = .motion, .col = 6, .row = 0 } }); _ = frame.reset(.retain_capacity); const whitespace = try p.render(frame.allocator()); try std.testing.expectEqual(Color{ .rgb = p.chromeTheme().tag_bg }, whitespace.at(1, 0).style.bg); p.update(.pointer_leave); _ = frame.reset(.retain_capacity); const left = try p.render(frame.allocator()); try std.testing.expectEqual(Color{ .rgb = p.chromeTheme().tag_bg }, left.at(1, 0).style.bg); } test "pane-tag Exec prefers Tty and argument builtins before shell fallback" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .cols = 100, .rows = 30 }); defer p.deinit(); while (p.nextEffect()) |_| {} // initial shell spawn/geometry p.acknowledgeShell(0, "/bin/sh", false); const pane = p.panes[0].?; try std.testing.expectEqualStrings(" Save Tty Del Togglettymode Filter Collapse", Pardes.curTail(pane)); const tag_x = p.rects[0].x + config.GUTTER; const tag_y = p.rects[0].y; const rendered = try p.tagText(p.scratch.allocator(), pane); const new_x = tag_x + @as(u16, @intCast(std.mem.indexOf(u8, rendered, "Tty").?)) + 1; var panes_before: usize = 0; for (p.panes) |s| if (s != null) { panes_before += 1; }; p.update(.{ .mouse = .{ .button = config.exec_button, .kind = .press, .col = new_x, .row = tag_y } }); p.update(.{ .mouse = .{ .button = config.exec_button, .kind = .release, .col = new_x, .row = tag_y } }); while (p.nextEffect()) |effect| switch (effect) { .write => return error.NewFellThroughToPty, else => {}, }; var panes_after: usize = 0; for (p.panes) |s| if (s != null) { panes_after += 1; }; try std.testing.expectEqual(panes_before + 1, panes_after); try std.testing.expect(p.panes[p.active].?.isTerminal()); // Argument-taking builtins use the same gesture path: a tag sweep is the // complete command line, and Theme consumes its tail without a PTY write. pane.tag_tail_len = 0; try std.testing.expect(pane.appendTag(" Theme dark")); pane.tag_init = true; p.update(.{ .mouse = .{ .button = config.exec_button, .kind = .press, .col = tag_x + 1, .row = tag_y } }); p.update(.{ .mouse = .{ .button = config.exec_button, .kind = .drag, .col = tag_x + 10, .row = tag_y } }); p.update(.{ .mouse = .{ .button = config.exec_button, .kind = .release, .col = tag_x + 10, .row = tag_y } }); try std.testing.expectEqualStrings("dark", p.theme().name); while (p.nextEffect()) |effect| switch (effect) { .write => return error.ArgumentBuiltinFellThroughToPty, else => {}, }; // Fixed-name builtins remain exact: a tail does not turn New or Kill into // a match (especially important for the destructive one). try std.testing.expect(!p.executeBuiltinLine(0, "New argument")); try std.testing.expect(!p.executeBuiltinLine(0, "Kill trailing-garbage")); try std.testing.expect(!p.quit); // A word absent from the builtin vocabulary takes the fallback, including // the terminating carriage return expected by the shell prompt. pane.tag_tail_len = 0; try std.testing.expect(pane.appendTag(" DefinitelyNotABuiltin")); p.update(.{ .mouse = .{ .button = config.exec_button, .kind = .press, .col = tag_x + 1, .row = tag_y } }); p.update(.{ .mouse = .{ .button = config.exec_button, .kind = .release, .col = tag_x + 1, .row = tag_y } }); var sent: [256]u8 = undefined; var sent_len: usize = 0; while (p.nextEffect()) |effect| switch (effect) { .write => |w| { try std.testing.expectEqual(@as(u8, 0), w.pane); try std.testing.expect(w.bytes.slice().len <= sent.len - sent_len); @memcpy(sent[sent_len..][0..w.bytes.slice().len], w.bytes.slice()); sent_len += w.bytes.slice().len; }, else => {}, }; try std.testing.expectEqualStrings("DefinitelyNotABuiltin\r", sent[0..sent_len]); } test "Collapse builtin keeps content and restores the pane from its default tag" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 24, .tty_only = true }); defer p.deinit(); const pane = try p.setTestFile("keep this text\n"); p.sync(); const height = p.rects[0].h; const revision = pane.file.?.revision; for ([_][]const u8{ pane_tail, file_pane_tail, pdf_pane_tail, terminal_pane_tail }) |tail| try std.testing.expect(std.mem.endsWith(u8, tail, " Collapse")); try std.testing.expect(p.executeBuiltinLine(0, "Collapse")); p.sync(); try std.testing.expect(pane.collapsed); try std.testing.expectEqual(@as(u16, BOX_H), p.rects[0].h); try std.testing.expectEqual(@as(f32, @floatFromInt(height)), pane.vweight); try std.testing.expectEqual(revision, pane.file.?.revision); try std.testing.expectEqualStrings("keep this text\n", pane.file.?.content); try std.testing.expect((try p.render(p.scratch.allocator())).cursor == null); p.enterTagEdit(pane, -1); const tag_cursor = (try p.render(p.scratch.allocator())).cursor orelse return error.MissingTagCursor; try std.testing.expectEqual(p.rects[0].y, tag_cursor.y); Pardes.exitTagEdit(pane); try std.testing.expect(p.executeBuiltinLine(0, "Collapse")); p.sync(); try std.testing.expect(!pane.collapsed); try std.testing.expectEqual(height, p.rects[0].h); // Unedited defaults from the preceding version gain the new command. pane.tag_init = false; pane.tag_tail_len = 0; p.restoreTailAt(pane, pre_collapse_file_pane_tail, "", .file); try std.testing.expect(!pane.tag_init); try std.testing.expectEqualStrings(file_pane_tail, Pardes.curTail(pane)); } test "LocationsConfig command reports partial updates and survives restore" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer p.deinit(); _ = try p.setTestFile("source\n"); try std.testing.expect(p.executeBuiltinLine(0, "LocationsConfig context:5 tscontext:on")); try std.testing.expect(p.executeBuiltinLine(0, "LocationsConfig tslocations:off")); const expected: locations_config.Config = .{ .context = 5, .tscontext = true, .tslocations = false }; try std.testing.expectEqual(expected, p.locations_config); try std.testing.expect(p.executeBuiltinLine(0, "LocationsConfig context:2 unknown:on")); try std.testing.expectEqual(expected, p.locations_config); var reports: usize = 0; for (p.panes) |slot| { const pane = slot orelse continue; const file = pane.file orelse continue; const out = file.output orelse continue; if (!std.meta.eql(out.from, panes.Output.Origin{ .cmd = .LocationsConfig })) continue; reports += 1; try std.testing.expectEqualStrings("LocationsConfig context:5 tscontext:on tslocations:off\n", file.content); } try std.testing.expectEqual(@as(usize, 1), reports); try p.dumpState(); const restored = try Pardes.initFromDump(p.gpa, .{ .tty_only = true }, p.dump_out.?); defer restored.deinit(); try std.testing.expectEqual(expected, restored.locations_config); } test "TreeContext tag style preserves message rows and its opt-out survives restore" { if (comptime !syntax.enabled) return error.SkipZigTest; const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 60, .rows = 16 }); defer p.deinit(); const pane = try p.setTestFile("pub const Box = struct {\n pub fn run() void {\n" ++ (" // body\n" ** 30) ++ " }\n};\n"); p.gpa.free(pane.file.?.path); pane.file.?.path = try p.gpa.dupe(u8, "/test.zig"); if (!panes.File.supportsContext(pane)) return error.SkipZigTest; p.update(.{ .resize = .{ .cols = 60, .rows = 16, .row_metrics = .{ .body_w = 10, .body_h = 20, .tagline_w = 6, .tagline_h = 12 } } }); pane.file.?.tree_context = true; pane.file.?.scroll = 8; pane.msg_len = 0; _ = try p.render(p.scratch.allocator()); try std.testing.expect(p.settings.tree_context_tag_style); const full_height = p.surface.body_layers[0].viewport.h; try std.testing.expect(p.surface.body_layers[0].rows > full_height); try std.testing.expectEqual(FontRole.tagline, p.surface.body_layers[0].cells[0].style.font_role); pane.msg[0] = '!'; pane.msg_len = 1; _ = try p.render(p.scratch.allocator()); try std.testing.expectEqual(full_height - 1, p.surface.body_layers[0].viewport.h); const r = p.rects[0]; try std.testing.expectEqualStrings("!", p.surface.at(r.x + config.GUTTER, r.y + r.h - 1).grapheme()); pane.msg_len = 0; p.leader_on = true; _ = try p.render(p.scratch.allocator()); try std.testing.expectEqual(full_height - 1, p.surface.body_layers[0].viewport.h); p.leader_on = false; try std.testing.expect(p.executeBuiltinLine(0, "TreeContextTagStyle")); _ = try p.render(p.scratch.allocator()); try std.testing.expectEqual(@as(u16, 0), p.surface.body_layers[0].rows); try std.testing.expectEqual(@as(u16, 0), pane.body_rows); try std.testing.expectEqual(FontRole.body, p.surface.at(r.x + config.GUTTER, r.y + BOX_H).style.font_role); try p.dumpState(); const restored = try Pardes.initFromDump(p.gpa, .{ .tty_only = true }, p.dump_out.?); defer restored.deinit(); try std.testing.expect(!restored.settings.tree_context_tag_style); } test "TreeContext command and custom tag survive a workspace round trip" { if (comptime !syntax.enabled) return error.SkipZigTest; const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer p.deinit(); const pane = try p.setTestFile("pub fn main() void {}\n"); try std.testing.expect(!panes.File.supportsContext(pane)); try std.testing.expectEqualStrings(file_pane_tail, Pardes.curTail(pane)); const path = try p.gpa.dupe(u8, "/test.zig"); p.gpa.free(pane.file.?.path); pane.file.?.path = path; if (!panes.File.supportsContext(pane)) return error.SkipZigTest; try std.testing.expectEqualStrings(context_pane_tail, Pardes.curTail(pane)); try std.testing.expect(!pane.file.?.tree_context); try std.testing.expect(p.executeBuiltinLine(0, "TreeContext on")); try std.testing.expect(pane.file.?.tree_context); try std.testing.expect(p.executeBuiltinLine(0, "TreeContext off")); try std.testing.expect(!pane.file.?.tree_context); try std.testing.expect(p.executeBuiltinLine(0, "TreeContext")); try std.testing.expect(pane.file.?.tree_context); try std.testing.expect(p.executeBuiltinLine(0, "TreeContext invalid")); try std.testing.expect(pane.file.?.tree_context); try std.testing.expect(pane.appendTag(" Save Keep Del")); pane.tag_init = true; try p.dumpState(); const restored = try Pardes.initFromDump(p.gpa, .{ .tty_only = true }, p.dump_out.?); defer restored.deinit(); const saved = restored.panes[0].?; try std.testing.expect(saved.file.?.tree_context); try std.testing.expectEqualStrings(" Save Keep Del", Pardes.curTail(saved)); } test "Togglettymode builtin and keyboard chord share mode transitions including edited tags" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 120, .rows = 20, .tty_only = true }); defer p.deinit(); const pane = p.panes[0].?; try std.testing.expect(std.mem.indexOf(u8, Pardes.curTail(pane), " Togglettymode ") != null); try std.testing.expectEqual(Pane.Mode.tty, pane.mode); try std.testing.expect(p.executeBuiltinLine(0, "Togglettymode")); try std.testing.expectEqual(Pane.Mode.normal, pane.mode); p.update(.{ .key = .{ .cp = p.opts.tty_toggle, .ctrl = true } }); try std.testing.expectEqual(Pane.Mode.tty, pane.mode); p.enterTagEdit(pane, -1); try std.testing.expectEqual(Pane.Mode.insert, pane.mode); try std.testing.expect(p.executeBuiltinLine(0, "Togglettymode")); try std.testing.expectEqual(Pane.Mode.normal, pane.mode); try std.testing.expect(!pane.tag_edit); p.enterTagEdit(pane, -1); p.update(.{ .key = .{ .cp = p.opts.tty_toggle, .ctrl = true } }); try std.testing.expectEqual(Pane.Mode.tty, pane.mode); try std.testing.expect(!pane.tag_edit); p.acknowledgePanelPresentation(&.{}); const tag = try p.tagText(p.scratch.allocator(), pane); const x = p.rects[0].x + config.GUTTER + @as(u16, @intCast(std.mem.indexOf(u8, tag, "Togglettymode").?)); const y = p.rects[0].y; p.update(.{ .mouse = .{ .button = .middle, .kind = .press, .col = x, .row = y } }); p.update(.{ .mouse = .{ .button = .middle, .kind = .release, .col = x, .row = y } }); try std.testing.expectEqual(Pane.Mode.normal, pane.mode); const file = try p.setTestFile("unchanged\n"); try std.testing.expect(p.executeBuiltinLine(0, "Togglettymode")); try std.testing.expectEqual(Pane.Mode.normal, file.mode); try std.testing.expectEqualStrings("unchanged\n", file.file.?.content); } test "Filter is ordered after Del and toggles only its terminal pane" { const testing = std.testing; const del = std.meta.stringToEnum(Builtin, "Del") orelse return error.MissingDelBuiltin; const filter = std.meta.stringToEnum(Builtin, "Filter") orelse return error.MissingFilterBuiltin; try testing.expectEqual(@intFromEnum(del) + 1, @intFromEnum(filter)); const p = try Pardes.init(testing.allocator, .{ .tty_only = true }); defer p.deinit(); while (p.nextEffect()) |_| {} const first = p.panes[0].?; const palette_before = first.terminal.?.vt.colorForXterm(.{ .palette = 1 }).?; try testing.expect(first.tty_filter); try testing.expect(p.executeBuiltinLine(0, "Filter")); try testing.expect(!first.tty_filter); try testing.expect(palette_before.eql(first.terminal.?.vt.colorForXterm(.{ .palette = 1 }).?)); const second_id = p.freeSlot().?; const second = try p.newShell(second_id, ""); try testing.expect(second.tty_filter); try testing.expect(p.executeBuiltinLine(second_id, "Filter")); try testing.expect(!second.tty_filter); try testing.expect(!first.tty_filter); const doc_id = p.freeSlot().?; const doc = try panes.Image.create(p, doc_id, "/tmp/filter-inert.ppm", &.{}); try testing.expect(!doc.tty_filter); try testing.expect(p.executeBuiltinLine(doc_id, "Filter")); try testing.expect(!doc.tty_filter); try testing.expect(!first.tty_filter); try testing.expect(p.executeBuiltinLine(0, "Filter")); try testing.expect(first.tty_filter); try testing.expect(!second.tty_filter); } test "compact tag commands stay beside their own prefix across layout changes" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .cols = 100, .rows = 30 }); defer p.deinit(); while (p.nextEffect()) |_| {} // initial shell spawn/geometry // A longer sibling never introduces invisible padding into this tag. const below_id = p.freeSlot().?; const below = try p.newShell(below_id, ""); const f = layout.findPane(p, p.active).?; layout.insert(p, f.col, f.idx + 1, below_id); layout.splitBelow(p, p.active, below); p.setCwd(below_id, "/a/deep/dir/whose/name/eats/the/right/pad/the/end/of/its/own/tagline"); p.sync(); while (p.nextEffect()) |_| {} const above = p.panes[0].?; try std.testing.expectEqual(p.rects[0].x, p.rects[below_id].x); const above_tag = try p.tagText(gpa, above); defer gpa.free(above_tag); const below_tag = try p.tagText(p.scratch.allocator(), below); try std.testing.expect(below_tag.len > above_tag.len); const tail = " Save Tty Del Togglettymode Filter Collapse"; try std.testing.expectEqualStrings(tail, above_tag[above_tag.len - tail.len ..]); try std.testing.expectEqual(above.cwdSlice().len + tail.len, above_tag.len); p.update(.{ .resize = .{ .cols = 88, .rows = 30 } }); while (p.nextEffect()) |_| {} try std.testing.expectEqualStrings(above_tag, try p.tagText(p.scratch.allocator(), above)); p.update(.{ .resize = .{ .cols = 87, .rows = 30 } }); while (p.nextEffect()) |_| {} try std.testing.expectEqualStrings(above_tag, try p.tagText(p.scratch.allocator(), above)); p.update(.{ .resize = .{ .cols = 100, .rows = 30 } }); while (p.nextEffect()) |_| {} p.seedTail(below); try std.testing.expectEqualStrings(tail, below.tagSlice()); try std.testing.expect(below.appendTag(" lots and lots of typing out here")); try std.testing.expectEqual(above_tag.len, (try p.tagText(p.scratch.allocator(), above)).len); p.update(.{ .resize = .{ .cols = 100, .rows = 2 } }); while (p.nextEffect()) |_| {} try std.testing.expectEqual(@as(u16, 0), p.rects[below_id].h); try std.testing.expectEqualStrings(above_tag, try p.tagText(p.scratch.allocator(), above)); } test "legacy default tag tails upgrade while custom tails remain owned" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{}); defer p.deinit(); const terminal = p.panes[0].?; const terminal_old = try std.fmt.allocPrint(p.scratch.allocator(), "{s}{s}", .{ try p.tagPrefix(terminal), legacy_pane_tail, }); p.restoreTail(terminal, terminal_old); try std.testing.expect(!terminal.tag_init); try std.testing.expectEqualStrings(" Tty Save Del Togglettymode Filter Collapse", Pardes.curTail(terminal)); const terminal_previous = try std.fmt.allocPrint(p.scratch.allocator(), "{s} New Del", .{ try p.tagPrefix(terminal), }); p.restoreTail(terminal, terminal_previous); try std.testing.expect(!terminal.tag_init); try std.testing.expectEqualStrings(" Tty Save Del Togglettymode Filter Collapse", Pardes.curTail(terminal)); const terminal_prefilter = try std.fmt.allocPrint(p.scratch.allocator(), "{s}{s}", .{ try p.tagPrefix(terminal), prev_terminal_pane_tail, }); p.restoreTail(terminal, terminal_prefilter); try std.testing.expect(!terminal.tag_init); try std.testing.expectEqualStrings(" Tty Save Del Togglettymode Filter Collapse", Pardes.curTail(terminal)); const terminal_custom = try std.fmt.allocPrint(p.scratch.allocator(), "{s} Keep Del", .{ try p.tagPrefix(terminal), }); p.restoreTail(terminal, terminal_custom); try std.testing.expect(terminal.tag_init); try std.testing.expectEqualStrings(" Keep Del", Pardes.curTail(terminal)); terminal.tag_tail_len = 0; terminal.tag_init = false; p.restoreDumpTail(terminal, .{ .kind = .terminal, .tag = "TTY /historical/cwd Keep Del", .body = "", .terminal = .{ .cwd = "/historical/cwd" }, }); try std.testing.expect(terminal.tag_init); try std.testing.expectEqualStrings(" Keep Del", Pardes.curTail(terminal)); terminal.tag_tail_len = 0; terminal.tag_init = false; p.restoreDumpTail(terminal, .{ .kind = .terminal, .tag = "TTY /historical/cwd New Del", .body = "", .terminal = .{ .cwd = "/historical/cwd" }, }); try std.testing.expect(!terminal.tag_init); try std.testing.expectEqualStrings(" Tty Save Del Togglettymode Filter Collapse", Pardes.curTail(terminal)); // This happens to be the historical FILE default, but on a terminal it is // user-owned text and must not be swallowed by migration. terminal.tag_tail_len = 0; terminal.tag_init = false; const terminal_fileish = try std.fmt.allocPrint(p.scratch.allocator(), "{s} Save Del", .{ try p.tagPrefix(terminal), }); p.restoreTail(terminal, terminal_fileish); try std.testing.expect(terminal.tag_init); try std.testing.expectEqualStrings(" Save Del", Pardes.curTail(terminal)); // A savable file has a distinct old default. Save remains first after the // migration so the tag's established `:w` route is unchanged. const file = try p.setTestFile(""); const file_old = try std.fmt.allocPrint(p.scratch.allocator(), "{s}{s}", .{ try p.tagPrefix(file), legacy_file_pane_tail, }); p.restoreTail(file, file_old); try std.testing.expect(!file.tag_init); try std.testing.expectEqualStrings(" Save Tty Del Collapse", Pardes.curTail(file)); const file_custom = try std.fmt.allocPrint(p.scratch.allocator(), "{s} Save Mine Del", .{ try p.tagPrefix(file), }); p.restoreTail(file, file_custom); try std.testing.expect(file.tag_init); try std.testing.expectEqualStrings(" Save Mine Del", Pardes.curTail(file)); // Conversely the generic pane's current default is a custom edit on a // savable file. Legacy recognition is scoped to the source pane class. file.tag_tail_len = 0; file.tag_init = false; const file_generic = try std.fmt.allocPrint(p.scratch.allocator(), "{s} New Del", .{ try p.tagPrefix(file), }); p.restoreTail(file, file_generic); try std.testing.expect(file.tag_init); try std.testing.expectEqualStrings(" New Del", Pardes.curTail(file)); const image_doc = try panes.Image.create(p, 1, "/tmp/legacy image.ppm", &.{}); try std.testing.expectEqualStrings(" Tty Del Collapse", Pardes.curTail(image_doc)); p.restoreDumpTail(image_doc, .{ .kind = .image, .tag = "img /tmp/legacy image.ppm Keep Del", .body = "", .image = .{ .path = "/tmp/legacy image.ppm" }, }); try std.testing.expect(image_doc.tag_init); try std.testing.expectEqualStrings(" Keep Del", image_doc.tagSlice()); } test "Joincol folds the active column into its right neighbor, keeping its panes" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 100, .rows = 30 }); defer p.deinit(); while (p.nextEffect()) |_| {} const right = p.freeSlot().?; _ = try p.newShell(right, ""); try std.testing.expect(layout.splitColumn(p, 0, right, false)); while (p.nextEffect()) |_| {} try std.testing.expectEqual(@as(usize, 2), p.ncol); p.active = 0; // the left column is current layout.joinCol(p); try std.testing.expectEqual(@as(usize, 1), p.ncol); const lf = layout.findPane(p, 0) orelse return error.LostLeftPane; const rf = layout.findPane(p, right) orelse return error.LostRightPane; try std.testing.expectEqual(lf.col, rf.col); layout.joinCol(p); // no right neighbor left: inert try std.testing.expectEqual(@as(usize, 1), p.ncol); } test "Tty spawns a raw shell in the caller's directory" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 100, .rows = 30 }); defer p.deinit(); while (p.nextEffect()) |_| {} p.setCwd(0, "/tmp/newtty-dir"); try std.testing.expect(p.executeBuiltinLine(0, "Tty")); const free = p.active; try std.testing.expect(free != 0); const nt = p.panes[free] orelse return error.NoNewPane; try std.testing.expect(nt.isTerminal()); try std.testing.expect(nt.mode == .tty); var found = false; while (p.nextEffect()) |effect| switch (effect) { .spawn => |s| if (@as(usize, s.pane) == free) { try std.testing.expectEqualStrings("/tmp/newtty-dir", s.cwd.slice()); found = true; }, else => {}, }; try std.testing.expect(found); } test "Tty9p marks only the new Linux terminal for a mounted shell" { if (comptime !hosted or @import("builtin").os.tag != .linux) return error.SkipZigTest; const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer p.deinit(); p.fs.socket_path = "/tmp/pardes-test.sock"; try std.testing.expect(p.executeBuiltinLine(0, "Tty9p")); try std.testing.expect(p.active != 0); try std.testing.expect(!p.panes[0].?.v9fs_on_spawn); const pane = p.panes[p.active].?; try std.testing.expect(pane.isTerminal() and pane.mode == .tty); try std.testing.expect(pane.v9fs_on_spawn); try std.testing.expect(!pane.greet); try std.testing.expect(!p.takesCommandLine(p.active)); panes.Terminal.feedOutput(p, pane, "\x1b]133;A\x07$ \x1b]133;B\x07"); try std.testing.expect(p.takesCommandLine(p.active)); panes.Terminal.feedOutput(p, pane, "\x1b]133;C\x07"); try std.testing.expect(!p.takesCommandLine(p.active)); } test "first document fallback splits like Tty from the originating pane" { for ([_]u16{ 100, 198 }) |width| for (0..3) |from| { for ([_]bool{ false, true }) |collapsed| { const p = try Pardes.init(std.testing.allocator, .{ .cols = width, .rows = 62, .shells = 3 }); defer p.deinit(); const tty = try Pardes.init(std.testing.allocator, .{ .cols = width, .rows = 62, .shells = 3 }); defer tty.deinit(); if (collapsed) { layout.toggleCollapse(p, from); layout.toggleCollapse(tty, from); } p.sync(); tty.sync(); const before = p.rects; const parent = layout.splitParent(p, from); const at = layout.findPane(p, parent).?; // The Look origin need not be the currently active pane. p.active = (from + 1) % 3; const free = p.freeSlot().?; const doc = try p.openTutorView(free); p.placeDoc(from, free, doc); tty.spawnTty(from); p.sync(); tty.sync(); try std.testing.expectEqual(@as(usize, 2), p.ncol); try std.testing.expectEqual(free, p.active); try std.testing.expectEqual(free, p.col_panes[at.col][at.idx + 1]); for (0..3) |id| { try std.testing.expect(p.panes[id] != null); if (id != parent) try std.testing.expectEqualDeep(before[id], p.rects[id]); } for (0..4) |id| try std.testing.expectEqualDeep(tty.rects[id], p.rects[id]); for (0..p.ncol) |col| { try std.testing.expectEqualSlices(usize, tty.col_panes[col][0..tty.col_n[col]], p.col_panes[col][0..p.col_n[col]]); } } }; } test "first document keeps its originating shell and only opens a column when it fits" { for ([_]u16{ 199, 200 }) |width| { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = width, .rows = 32 }); defer p.deinit(); const shell = p.panes[0].?; const doc = try p.openTutorView(1); p.placeDoc(0, 1, doc); p.sync(); try std.testing.expectEqual(shell, p.panes[0].?); try std.testing.expectEqual(@as(usize, 1), p.active); if (width < 200) { try std.testing.expectEqual(@as(usize, 1), p.ncol); try std.testing.expectEqualSlices(usize, &.{ 0, 1 }, p.col_panes[0][0..p.col_n[0]]); try std.testing.expectEqual(p.rects[0].y + p.rects[0].h, p.rects[1].y); } else { try std.testing.expectEqual(@as(usize, 2), p.ncol); try std.testing.expectEqual(@as(usize, 1), p.col_panes[0][0]); try std.testing.expectEqual(@as(usize, 0), p.col_panes[1][0]); } } } test "pane defaults keep creation of scratch panes in column tags" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer p.deinit(); for ([_][]const u8{ pane_tail, file_pane_tail, pdf_pane_tail, terminal_pane_tail, config.topbar_str }) |tag| { var words = std.mem.tokenizeScalar(u8, tag, ' '); while (words.next()) |word| try std.testing.expect(!std.mem.eql(u8, word, "New")); } try std.testing.expectEqualStrings("New Tty Find Grep Joincol", p.columnTag(0)); const terminal = p.panes[0].?; p.restoreTailAt(terminal, pre_column_terminal_pane_tail, "", .terminal); try std.testing.expect(!terminal.tag_init); try std.testing.expectEqualStrings(terminal_pane_tail, Pardes.curTail(terminal)); const file = try p.setTestFile("unchanged\n"); p.restoreTailAt(file, pre_column_file_pane_tail, "", .file); try std.testing.expect(!file.tag_init); try std.testing.expectEqualStrings(file_pane_tail, Pardes.curTail(file)); } test "an unsaved file marker sits between its path and builtins until Save" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 100, .rows = 30 }); defer p.deinit(); while (p.nextEffect()) |_| {} const pane = try p.setTestFile("before\n"); const clean = try p.tagText(p.scratch.allocator(), pane); try std.testing.expect(std.mem.indexOf(u8, clean, "/test.txt *") == null); panes.File.setContent(p, &pane.file.?, try gpa.dupe(u8, "after\n")); const dirty = try p.tagText(p.scratch.allocator(), pane); const marker_at = std.mem.indexOf(u8, dirty, "/test.txt *") orelse return error.MissingDirtyMarker; const save_at = std.mem.indexOf(u8, dirty, "Save") orelse return error.MissingSaveBuiltin; try std.testing.expect(marker_at < save_at); try std.testing.expect(p.executeBuiltinLine(0, "Save")); while (p.nextEffect()) |effect| p.perform(effect); const saved = try p.tagText(p.scratch.allocator(), pane); try std.testing.expect(std.mem.indexOf(u8, saved, "/test.txt *") == null); panes.File.changed(p, 0, "external\n"); const reloaded = try p.tagText(p.scratch.allocator(), pane); try std.testing.expect(std.mem.indexOf(u8, reloaded, "/test.txt *") == null); // A generated output is file-shaped and Save can write it to a path, but // there is no file of its own for it to be dirty against. pane.file.?.output = .{ .from = .search }; panes.File.setContent(p, &pane.file.?, try gpa.dupe(u8, "result\n")); const output = try p.tagText(p.scratch.allocator(), pane); try std.testing.expect(std.mem.indexOf(u8, output, "/test.txt *") == null); } test "unknown Exec from an image writes to a terminal in the image directory" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{}); defer p.deinit(); while (p.nextEffect()) |_| {} p.acknowledgeShell(0, "/bin/sh", false); p.setCwd(0, "/tmp/pardes-image-dir"); const image_doc = try p.newDocPane(1); image_doc.image = .{ .path = try gpa.dupe(u8, "/tmp/pardes-image-dir/pic.ppm") }; try std.testing.expectEqualStrings("/tmp/pardes-image-dir", Pardes.paneDir(image_doc)); try std.testing.expectEqual(@as(?usize, 0), p.execute(1, "echo image-fallback")); var sent: [256]u8 = undefined; var sent_len: usize = 0; while (p.nextEffect()) |effect| switch (effect) { .write => |w| { try std.testing.expectEqual(@as(u8, 0), w.pane); try std.testing.expect(w.bytes.slice().len <= sent.len - sent_len); @memcpy(sent[sent_len..][0..w.bytes.slice().len], w.bytes.slice()); sent_len += w.bytes.slice().len; }, .spawn => return error.ImageExecSpawnedDespiteMatchingTerminal, else => {}, }; try std.testing.expectEqualStrings("echo image-fallback\r", sent[0..sent_len]); } const FakeTtyQuery = struct { taken: [MAX_PANES]bool = @splat(false), asked: usize = 0, const vtable: Host.VTable = .{ .tty_taken = answer }; fn install(f: *FakeTtyQuery, p: *Pardes) void { p.host = .{ .ctx = f, .vtable = &vtable }; } fn answer(ctx: ?*anyopaque, pane: u8) bool { const f: *FakeTtyQuery = @ptrCast(@alignCast(ctx.?)); f.asked += 1; return f.taken[pane]; } }; test "Exec in a terminal whose tty is taken spawns a shell instead of typing at the program" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{}); defer p.deinit(); while (p.nextEffect()) |_| {} p.setCwd(0, "/tmp/pardes-taken"); // vim, a pager, an agent: the host answers that this pane's tty is no // longer the prompt pardes forked var host: FakeTtyQuery = .{}; host.install(p); host.taken[0] = true; const dst = p.execute(0, "echo taken-fallback") orelse return error.ExecFoundNowhereToRun; // somewhere ELSE — and still without moving focus, which is execute's // contract and the whole difference between it and a look try std.testing.expect(dst != 0); try std.testing.expectEqual(@as(usize, 0), p.active); var spawned: ?Effect = null; var sent: [256]u8 = undefined; var sent_len: usize = 0; while (p.nextEffect()) |effect| switch (effect) { .write => |w| { if (w.pane == 0) return error.WroteACommandLineIntoTheProgramOnTheTty; try std.testing.expectEqual(@as(u8, @intCast(dst)), w.pane); try std.testing.expect(w.bytes.slice().len <= sent.len - sent_len); @memcpy(sent[sent_len..][0..w.bytes.slice().len], w.bytes.slice()); sent_len += w.bytes.slice().len; }, .spawn => spawned = effect, else => {}, }; const sp = (spawned orelse return error.NoShellForTheOccupiedTerminal).spawn; try std.testing.expectEqual(@as(u8, @intCast(dst)), sp.pane); // ...in the directory the command was about, which is the taken pane's own try std.testing.expectEqualStrings("/tmp/pardes-taken", sp.cwd.slice()); try std.testing.expectEqual(@as(usize, 0), sent_len); p.acknowledgeShell(dst, "/bin/sh", false); while (p.nextEffect()) |effect| switch (effect) { .write => |w| { try std.testing.expectEqual(@as(u8, @intCast(dst)), w.pane); @memcpy(sent[sent_len..][0..w.bytes.slice().len], w.bytes.slice()); sent_len += w.bytes.slice().len; }, else => {}, }; try std.testing.expectEqualStrings("echo taken-fallback\r", sent[0..sent_len]); } test "image dump restores source bytes renderer choices and exact custom tail" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true }); defer p.deinit(); try p.deinitPane(p.panes[0].?); p.panes[0] = null; const source = "\x00embedded image bytes\xff"; const raw = try p.image_gpa.dupe(u8, source); const pane = panes.Image.create(p, 0, "/missing/restored-image.ppm", raw) catch |err| { p.image_gpa.free(raw); return err; }; pane.image.?.glyph_art = true; pane.image.?.pmode = .terminal; pane.image.?.ascii = false; try std.testing.expect(pane.appendTag(" Keep Del")); pane.tag_init = true; try p.dumpState(); var first_dump = try dump.readZon(gpa, p.dump_out.?, "image-first-dump"); defer first_dump.deinit(); const first = first_dump.value; try std.testing.expect(first.panes[0].image.?.petscii); try std.testing.expectEqual(dump.ImagePalette.terminal, first.panes[0].image.?.palette); try std.testing.expect(!first.panes[0].image.?.ascii); try std.testing.expectEqualStrings(" Keep Del", first.panes[0].tag_tail.?); const restored = try Pardes.initFromDump(gpa, .{ .tty_only = true }, p.dump_out.?); defer restored.deinit(); const restored_pane = restored.panes[0].?; try std.testing.expect(restored_pane.image.?.glyph_art); try std.testing.expectEqual(image.PaletteMode.terminal, restored_pane.image.?.pmode); try std.testing.expect(!restored_pane.image.?.ascii); try std.testing.expect(restored_pane.tag_init); try std.testing.expectEqualStrings(" Keep Del", restored_pane.tagSlice()); try std.testing.expectEqualSlices(u8, source, restored_pane.image.?.raw); try restored.dumpState(); var parsed = try dump.readZon(gpa, restored.dump_out.?, "image-redump"); defer parsed.deinit(); const redump = parsed.value; const encoded = redump.panes[0].image.?.bytes_b64; const decoded = try dump.decodeBytes(gpa, encoded); defer gpa.free(decoded); try std.testing.expectEqualSlices(u8, source, decoded); try std.testing.expect(redump.panes[0].image.?.petscii); try std.testing.expectEqual(dump.ImagePalette.terminal, redump.panes[0].image.?.palette); try std.testing.expect(!redump.panes[0].image.?.ascii); try std.testing.expectEqualStrings(" Keep Del", redump.panes[0].tag_tail.?); } test "Exec from a document pane skips an occupied terminal in its directory and spawns" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{}); defer p.deinit(); while (p.nextEffect()) |_| {} p.setCwd(0, "/tmp/pardes-image-dir"); const image_doc = try p.newDocPane(1); image_doc.image = .{ .path = try gpa.dupe(u8, "/tmp/pardes-image-dir/pic.ppm") }; var host: FakeTtyQuery = .{}; host.install(p); host.taken[0] = true; const dst = p.execute(1, "echo image-fallback") orelse return error.ExecFoundNowhereToRun; try std.testing.expect(dst != 0 and dst != 1); var spawned: ?Effect = null; var sent: [256]u8 = undefined; var sent_len: usize = 0; while (p.nextEffect()) |effect| switch (effect) { .write => |w| { if (w.pane == 0) return error.WroteACommandLineIntoTheProgramOnTheTty; try std.testing.expectEqual(@as(u8, @intCast(dst)), w.pane); try std.testing.expect(w.bytes.slice().len <= sent.len - sent_len); @memcpy(sent[sent_len..][0..w.bytes.slice().len], w.bytes.slice()); sent_len += w.bytes.slice().len; }, .spawn => spawned = effect, else => {}, }; const sp = (spawned orelse return error.NoShellForTheOccupiedTerminal).spawn; try std.testing.expectEqualStrings("/tmp/pardes-image-dir", sp.cwd.slice()); try std.testing.expectEqual(@as(usize, 0), sent_len); p.acknowledgeShell(dst, "/bin/sh", false); while (p.nextEffect()) |effect| switch (effect) { .write => |w| { try std.testing.expectEqual(@as(u8, @intCast(dst)), w.pane); @memcpy(sent[sent_len..][0..w.bytes.slice().len], w.bytes.slice()); sent_len += w.bytes.slice().len; }, else => {}, }; try std.testing.expectEqualStrings("echo image-fallback\r", sent[0..sent_len]); } test "a Look on a directory does not type ls into an occupied terminal" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .cols = 100, .rows = 30 }); defer p.deinit(); while (p.nextEffect()) |_| {} var realbuf: [4096]u8 = undefined; const tmp = look.resolve(null, "/tmp", "/", &realbuf).dir; p.setCwd(0, tmp); var host: FakeTtyQuery = .{}; host.install(p); host.taken[0] = true; p.lookAt(0, tmp); var spawned = false; while (p.nextEffect()) |effect| switch (effect) { .write => |w| if (w.pane == 0) return error.TypedLsIntoTheProgramOnTheTty, .spawn => spawned = true, else => {}, }; try std.testing.expect(spawned); // ...and the same look reuses that very pane once its program is gone: the // core keeps no state of its own about it, so recovery needs nothing reset host.taken[0] = false; p.lookAt(0, tmp); var sent: [64]u8 = undefined; var sent_len: usize = 0; while (p.nextEffect()) |effect| switch (effect) { .write => |w| { try std.testing.expectEqual(@as(u8, 0), w.pane); try std.testing.expect(w.bytes.slice().len <= sent.len - sent_len); @memcpy(sent[sent_len..][0..w.bytes.slice().len], w.bytes.slice()); sent_len += w.bytes.slice().len; }, .spawn => return error.SpawnedDespiteAFreeTerminalOnTheDirectory, else => {}, }; try std.testing.expectEqualStrings("ls\r", sent[0..sent_len]); try std.testing.expectEqual(@as(usize, 0), p.active); } test "a terminal its program gave back takes command lines again" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{}); defer p.deinit(); while (p.nextEffect()) |_| {} p.acknowledgeShell(0, "/bin/sh", false); p.setCwd(0, "/tmp/pardes-taken-flip"); var host: FakeTtyQuery = .{}; host.install(p); host.taken[0] = true; try std.testing.expect(p.execute(0, "echo while-taken") != 0); while (p.nextEffect()) |_| {} // the program exited, so the host's answer changes — and the core asks // again, because it never cached the old one host.taken[0] = false; try std.testing.expectEqual(@as(?usize, 0), p.execute(0, "echo after")); var sent: [64]u8 = undefined; var sent_len: usize = 0; while (p.nextEffect()) |effect| switch (effect) { .write => |w| { try std.testing.expectEqual(@as(u8, 0), w.pane); try std.testing.expect(w.bytes.slice().len <= sent.len - sent_len); @memcpy(sent[sent_len..][0..w.bytes.slice().len], w.bytes.slice()); sent_len += w.bytes.slice().len; }, .spawn => return error.SpawnedDespiteAPromptOfItsOwn, else => {}, }; try std.testing.expectEqualStrings("echo after\r", sent[0..sent_len]); } test "the host is asked about a tty only where a command line is about to go" { const p = try Pardes.init(std.testing.allocator, .{ .shells = 3, .cols = 100, .rows = 30 }); defer p.deinit(); while (p.nextEffect()) |_| {} var frame = std.heap.ArenaAllocator.init(std.testing.allocator); defer frame.deinit(); var host: FakeTtyQuery = .{}; host.install(p); p.setCwd(0, "/tmp/pardes-lazy-a"); p.setCwd(1, "/tmp/pardes-lazy-b"); p.setCwd(2, "/tmp/pardes-lazy-c"); _ = try p.render(frame.allocator()); p.update(.{ .key = .{ .cp = 'x' } }); p.update(.{ .mouse = .{ .button = .none, .kind = .motion, .col = 4, .row = 4 } }); p.update(.{ .resize = .{ .cols = 90, .rows = 28 } }); while (p.nextEffect()) |_| {} _ = try p.render(frame.allocator()); try std.testing.expectEqual(@as(usize, 0), host.asked); // The pane an Exec is typed into is one question, asked once... _ = p.execute(0, "echo lazy"); while (p.nextEffect()) |_| {} try std.testing.expectEqual(@as(usize, 1), host.asked); host.taken[0] = true; host.asked = 0; _ = p.execute(0, "echo lazy-again"); while (p.nextEffect()) |_| {} try std.testing.expectEqual(@as(usize, 2), host.asked); } test "New opens an empty scratch below the caller, inheriting its directory" { const p = try Pardes.init(std.testing.allocator, .{ .shells = 3, .cols = 100, .rows = 30 }); defer p.deinit(); while (p.nextEffect()) |_| {} const source: usize = 2; // the right column; active starts in the left p.setCwd(source, "/tmp/pardes-scratch-dir"); const source_col = layout.findPane(p, source).?.col; try std.testing.expect(p.executeBuiltinLine(source, "New")); // no shell IO: the scratch is created in-core, focused, below the caller while (p.nextEffect()) |_| {} const id = p.active; try std.testing.expect(id != source); try std.testing.expectEqual(source_col, layout.findPane(p, id).?.col); const np = p.panes[id].?; try std.testing.expect(np.file.?.output != null); // an output buffer, empty try std.testing.expectEqual(@as(usize, 0), np.file.?.content.len); // its directory is a LIVE link to the opener, not a snapshot taken at New try std.testing.expectEqualStrings("/tmp/pardes-scratch-dir", Pardes.paneDir(np)); p.setCwd(source, "/tmp/pardes-moved"); try std.testing.expectEqualStrings("/tmp/pardes-moved", Pardes.paneDir(np)); try p.removePane(source); p.sync(); try std.testing.expect(np.cwd == .owned); try std.testing.expectEqualStrings("/tmp/pardes-moved", Pardes.paneDir(np)); p.sync(); try std.testing.expectEqualStrings("/tmp/pardes-moved", Pardes.paneDir(np)); } test "owned cwd preserves long paths aliases and failed updates" { var allocator = std.testing.FailingAllocator.init(std.testing.allocator, .{}); const p = try Pardes.init(allocator.allocator(), .{ .tty_only = true }); defer p.deinit(); const pane = p.panes[0].?; var path: [3072]u8 = @splat('d'); path[0] = '/'; for (1..12) |i| path[i * 256] = '/'; p.setCwd(0, &path); try std.testing.expectEqualStrings(&path, pane.cwdSlice()); try std.testing.expect(pane.cwdSlice().ptr != &path); const original = pane.cwdSlice(); try pane.setOwnedCwd(original); try std.testing.expectEqualStrings(&path, pane.cwdSlice()); const effects = p.effects_len; const free = p.freeSlot().?; try std.testing.expectError(error.PathTooLong, p.newShell(free, path[0 .. effect_path_cap + 1])); try std.testing.expect(p.panes[free] == null); p.saveTo(0, "file.txt"); try std.testing.expectEqual(effects, p.effects_len); try std.testing.expect(std.mem.indexOf(u8, pane.msg[0..pane.msg_len], "PathTooLong") != null); const current = pane.cwdSlice(); allocator.fail_index = allocator.alloc_index; p.setCwd(0, "/replacement"); try std.testing.expectEqual(current.ptr, pane.cwdSlice().ptr); try std.testing.expectEqualStrings(&path, pane.cwdSlice()); try std.testing.expect(std.mem.indexOf(u8, pane.msg[0..pane.msg_len], "OutOfMemory") != null); allocator.fail_index = std.math.maxInt(usize); p.setCwd(0, "/replacement"); try std.testing.expectEqualStrings("/replacement", pane.cwdSlice()); const allocations = allocator.allocations; p.setCwd(0, "/replacement"); try std.testing.expectEqual(allocations, allocator.allocations); pane.clearCwd(); try std.testing.expect(pane.cwd == .none); try std.testing.expectEqualStrings("", pane.cwdSlice()); } test "owned cwd close copies are transactional across inherited chains" { for (0..2) |failed_copy| { var allocator = std.testing.FailingAllocator.init(std.testing.allocator, .{}); const p = try Pardes.init(allocator.allocator(), .{ .tty_only = true }); defer p.deinit(); p.presentation.enabled = false; const parent = try p.setTestFile("body\n"); p.gpa.free(parent.file.?.path); parent.file.?.path = try p.gpa.dupe(u8, "/work/project/source.zig"); p.newScratchBelow(0); const first_id = p.active; const first = p.panes[first_id].?; p.newScratchBelow(0); const second = p.panes[p.active].?; p.newScratchBelow(first_id); const grandchild = p.panes[p.active].?; p.sync(); while (p.nextEffect()) |_| {} p.parkPendingWrite(0, "queued"); const pending = p.pending_write[0].?.bytes; const columns = p.col_panes; const counts = p.col_n; const active = p.active; const live_bytes = allocator.allocated_bytes - allocator.freed_bytes; allocator.fail_index = allocator.alloc_index + failed_copy; try std.testing.expectError(error.OutOfMemory, p.removePane(0)); try std.testing.expectEqual(parent, p.panes[0].?); try std.testing.expectEqual(parent, first.cwd.inherited); try std.testing.expectEqual(parent, second.cwd.inherited); try std.testing.expectEqual(first, grandchild.cwd.inherited); try std.testing.expectEqualDeep(columns, p.col_panes); try std.testing.expectEqualDeep(counts, p.col_n); try std.testing.expectEqual(active, p.active); try std.testing.expectEqual(@as(usize, 0), p.effects_len); try std.testing.expectEqual(pending.ptr, p.pending_write[0].?.bytes.ptr); try std.testing.expectEqual(live_bytes, allocator.allocated_bytes - allocator.freed_bytes); allocator.fail_index = std.math.maxInt(usize); try p.removePane(0); try std.testing.expect(p.panes[0] == null); try std.testing.expect(first.cwd == .owned and second.cwd == .owned); try std.testing.expectEqual(first, grandchild.cwd.inherited); try std.testing.expect(first.cwdSlice().ptr != second.cwdSlice().ptr); try p.removePane(first_id); try std.testing.expect(grandchild.cwd == .owned); p.sync(); p.sync(); try std.testing.expectEqualStrings("/work/project", Pardes.paneDir(second)); try std.testing.expectEqualStrings("/work/project", Pardes.paneDir(grandchild)); } } test "owned cwd column close allocates every copy before removing any pane" { var allocator = std.testing.FailingAllocator.init(std.testing.allocator, .{}); const p = try Pardes.init(allocator.allocator(), .{ .shells = 3, .cols = 100, .rows = 30 }); defer p.deinit(); p.presentation.enabled = false; p.setCwd(0, "/first"); p.setCwd(1, "/second"); const first_child = try p.newDocPane(3); const second_child = try p.newDocPane(4); first_child.cwd = .{ .inherited = p.panes[0].? }; second_child.cwd = .{ .inherited = p.panes[1].? }; while (p.nextEffect()) |_| {} const before = p.panes; const columns = p.col_panes; const counts = p.col_n; allocator.fail_index = allocator.alloc_index + 1; try std.testing.expectError(error.OutOfMemory, p.removeColumn(0)); for (before, p.panes) |old, current| try std.testing.expectEqual(old, current); try std.testing.expectEqualDeep(columns, p.col_panes); try std.testing.expectEqualDeep(counts, p.col_n); try std.testing.expectEqual(@as(usize, 0), p.effects_len); try std.testing.expect(first_child.cwd == .inherited and second_child.cwd == .inherited); allocator.fail_index = std.math.maxInt(usize); try p.removeColumn(0); try std.testing.expect(p.panes[0] == null and p.panes[1] == null); p.sync(); p.sync(); try std.testing.expectEqualStrings("/first", first_child.cwdSlice()); try std.testing.expectEqualStrings("/second", second_child.cwdSlice()); } test "owned cwd EOF failure retains the pane and Del retries its close" { var allocator = std.testing.FailingAllocator.init(std.testing.allocator, .{}); const p = try Pardes.init(allocator.allocator(), .{ .tty_only = true }); defer p.deinit(); p.presentation.enabled = false; p.setCwd(0, "/exited"); const parent = p.panes[0].?; p.newScratchBelow(0); const child = p.panes[p.active].?; p.sync(); while (p.nextEffect()) |_| {} allocator.fail_index = allocator.alloc_index; p.update(.{ .eof = .{ .pane = 0 } }); try std.testing.expectEqual(parent, p.panes[0].?); try std.testing.expectEqual(parent, child.cwd.inherited); try std.testing.expectEqual(Pane.Mode.normal, parent.mode); try std.testing.expect(std.mem.indexOf(u8, parent.msg[0..parent.msg_len], "Del retries close") != null); try std.testing.expectEqual(@as(usize, 0), p.effects_len); allocator.fail_index = std.math.maxInt(usize); try std.testing.expect(p.executeBuiltinLine(0, "Del")); try std.testing.expect(p.panes[0] == null); p.sync(); p.sync(); try std.testing.expectEqualStrings("/exited", child.cwdSlice()); } test "owned cwd restore either copies the directory or preserves the old core" { var allocator = std.testing.FailingAllocator.init(std.testing.allocator, .{}); const p = try Pardes.init(allocator.allocator(), .{ .tty_only = true }); defer p.deinit(); p.setCwd(0, "/retained/terminal/directory"); try p.dumpState(); const original = p.panes[0].?; var completed = false; for (0..256) |failure| { allocator.has_induced_failure = false; allocator.fail_index = allocator.alloc_index + failure; const replacement = p.restore(p.dump_out.?) catch { try std.testing.expect(allocator.has_induced_failure); try std.testing.expectEqual(original, p.panes[0].?); try std.testing.expectEqualStrings("/retained/terminal/directory", original.cwdSlice()); continue; }; defer replacement.deinit(); try std.testing.expectEqualStrings("/retained/terminal/directory", replacement.panes[0].?.cwdSlice()); try std.testing.expectEqual(original, p.panes[0].?); if (!allocator.has_induced_failure) { completed = true; break; } } allocator.fail_index = std.math.maxInt(usize); try std.testing.expect(completed); } test "owned cwd Save promotion releases the former directory" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer p.deinit(); p.newScratchBelow(0); const id = p.active; const pane = p.panes[id].?; try pane.setOwnedCwd("/old/directory"); p.saveTo(id, "saved.txt"); try std.testing.expect(pane.cwd == .none); try std.testing.expect(pane.file.?.output == null); try std.testing.expectEqualStrings("/old/directory/saved.txt", pane.file.?.path); try std.testing.expectEqualStrings("/old/directory", Pardes.paneDir(pane)); } test "Save on a scratch asks for a path in its inherited dir and makes it a file" { const p = try Pardes.init(std.testing.allocator, .{ .shells = 3, .cols = 100, .rows = 30 }); defer p.deinit(); while (p.nextEffect()) |_| {} const source: usize = 2; p.setCwd(source, "/tmp/pardes-save-dir"); try std.testing.expect(p.executeBuiltinLine(source, "New")); const id = p.active; const np = p.panes[id].?; p.sync(); // the frame boundary that gives the new pane its geometry while (p.nextEffect()) |_| {} // Save on a scratch arms a PATH input, prefilled with the inherited dir try std.testing.expect(p.executeBuiltinLine(id, "Save")); try std.testing.expect(np.prompt == .save); try std.testing.expect(std.mem.endsWith(u8, np.tagSlice(), " Save /tmp/pardes-save-dir/")); // typing the filename and submitting converts it into an ordinary file try std.testing.expect(np.appendTag("note.txt")); p.submitSave(id); try std.testing.expect(np.file.?.output == null); try std.testing.expectEqualStrings("/tmp/pardes-save-dir/note.txt", np.file.?.path); var saved = false; while (p.nextEffect()) |effect| switch (effect) { .save_file => |sf| if (@as(usize, sf.pane) == id) { saved = true; }, else => {}, }; try std.testing.expect(saved); } test "Save on a terminal writes its plaintext scrollback and stays a terminal" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 40, .rows = 8 }); defer p.deinit(); while (p.nextEffect()) |_| {} const pane = p.panes[0].?; p.setCwd(0, "/tmp/pardes-tty-save"); p.update(.{ .output = .{ .pane = 0, .bytes = "hello scrollback\r\n" } }); while (p.nextEffect()) |_| {} try std.testing.expect(p.executeBuiltinLine(0, "Save")); try std.testing.expect(pane.prompt == .save); try std.testing.expect(pane.appendTag("log.txt")); p.submitSave(0); // the pane is untouched: still a terminal, no file behind it try std.testing.expect(pane.isTerminal()); try std.testing.expect(pane.file == null); // the host performs it, reading the scrollback off the pane as it goes var buf: [256]u8 = undefined; try std.testing.expectEqualStrings( "/tmp/pardes-tty-save/log.txt", drainForSavePath(p, &buf) orelse return error.NoSaveAsked, ); const written = p.fallback.get("/tmp/pardes-tty-save/log.txt") orelse return error.NothingWritten; try std.testing.expect(std.mem.indexOf(u8, written, "hello scrollback") != null); } test "Save on an output buffer writes its rows out and leaves the buffer alone" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .shells = 3, .cols = 100, .rows = 30 }); defer p.deinit(); while (p.nextEffect()) |_| {} const source: usize = 2; p.setCwd(source, "/tmp/pardes-output-save"); try std.testing.expect(p.executeBuiltinLine(source, "Help")); p.sync(); // the frame boundary that gives the new pane its geometry while (p.nextEffect()) |_| {} const help: panes.Output.Origin = .{ .cmd = .Help }; const id = blk: { for (p.panes, 0..) |slot, i| { const pane = slot orelse continue; const f = pane.file orelse continue; const o = f.output orelse continue; if (std.meta.eql(o.from, help)) break :blk i; } return error.MissingHelpBuffer; }; const out = p.panes[id].?; const path = try gpa.dupe(u8, out.file.?.path); defer gpa.free(path); const rows = try gpa.dupe(u8, out.file.?.content); defer gpa.free(rows); // Save leads its tagline now, and the path is REQUIRED: a bare Save asks try std.testing.expectEqualStrings(" Save Tty Del Collapse", Pardes.curTail(out)); try std.testing.expect(p.executeBuiltinLine(id, "Save")); try std.testing.expect(out.prompt == .save); try std.testing.expect(out.appendTag("help.txt")); p.submitSave(id); // the buffer itself is untouched: same origin, same name, still refillable // and still walked by n/N, because a saved copy of a list is only a copy try std.testing.expect(out.file.?.output != null); try std.testing.expectEqual(help, out.file.?.output.?.from); try std.testing.expectEqualStrings(path, out.file.?.path); var buf: [256]u8 = undefined; try std.testing.expectEqualStrings( "/tmp/pardes-output-save/help.txt", drainForSavePath(p, &buf) orelse return error.NoSaveAsked, ); try std.testing.expectEqualStrings( rows, p.fallback.get("/tmp/pardes-output-save/help.txt") orelse return error.NothingWritten, ); } test "Save takes the path as an argument, relative to the pane's own directory" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 40, .rows = 8 }); defer p.deinit(); while (p.nextEffect()) |_| {} const pane = p.panes[0].?; p.setCwd(0, "/tmp/pardes-arg-save"); p.update(.{ .output = .{ .pane = 0, .bytes = "typed and gone\r\n" } }); while (p.nextEffect()) |_| {} var buf: [256]u8 = undefined; try std.testing.expect(p.executeBuiltinLine(0, "Save session.txt")); try std.testing.expect(pane.prompt != .save); try std.testing.expectEqualStrings( "/tmp/pardes-arg-save/session.txt", drainForSavePath(p, &buf) orelse return error.NoSaveAsked, ); const written = p.fallback.get("/tmp/pardes-arg-save/session.txt") orelse return error.NothingWritten; try std.testing.expect(std.mem.indexOf(u8, written, "typed and gone") != null); // `.` and `..` normalize with it, so this is that same file and not a // second one spelled differently try std.testing.expect(p.executeBuiltinLine(0, "Save ./sub/../session.txt")); try std.testing.expectEqualStrings( "/tmp/pardes-arg-save/session.txt", drainForSavePath(p, &buf) orelse return error.NoSaveAsked, ); try std.testing.expect(p.executeBuiltinLine(0, "Save /tmp/pardes-elsewhere/abs.txt")); try std.testing.expectEqualStrings( "/tmp/pardes-elsewhere/abs.txt", drainForSavePath(p, &buf) orelse return error.NoSaveAsked, ); try std.testing.expect(pane.isTerminal()); p.panes[0].?.clearCwd(); try std.testing.expect(p.executeBuiltinLine(0, "Save nowhere.txt")); try std.testing.expect(drainForSavePath(p, &buf) == null); } test "a save the host could not do leaves the pane dirty" { const gpa = std.testing.allocator; const Refusing = struct { fn writeFile(ctx: ?*anyopaque, pane: u8, _: []const u8, _: []const u8) void { const core: *Pardes = @ptrCast(@alignCast(ctx.?)); core.saveFailed(pane, "save", error.PermissionDenied); } }; const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 100, .rows = 30 }); defer p.deinit(); while (p.nextEffect()) |_| {} const pane = try p.setTestFile("before\n"); panes.File.setContent(p, &pane.file.?, try gpa.dupe(u8, "after\n")); try std.testing.expect(pane.file.?.revision != pane.file.?.saved_revision); p.host = .{ .ctx = p, .vtable = &.{ .write_file = Refusing.writeFile } }; try std.testing.expect(p.executeBuiltinLine(0, "Save")); while (p.nextEffect()) |effect| p.perform(effect); try std.testing.expect(pane.file.?.revision != pane.file.?.saved_revision); // ...and the same save against a host that CAN write does come clean, so // the guard above is not simply "never clean". p.host = .{}; try std.testing.expect(p.executeBuiltinLine(0, "Save")); while (p.nextEffect()) |effect| p.perform(effect); try std.testing.expectEqual(pane.file.?.revision, pane.file.?.saved_revision); try std.testing.expectEqualStrings("after\n", p.fallback.get("/test.txt").?); } test "Save elsewhere copies a file's bytes and keeps the pane on its own file" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 100, .rows = 30 }); defer p.deinit(); while (p.nextEffect()) |_| {} const pane = try p.setTestFile("before\n"); panes.File.setContent(p, &pane.file.?, try gpa.dupe(u8, "after\n")); try std.testing.expect(pane.file.?.revision != pane.file.?.saved_revision); var buf: [256]u8 = undefined; try std.testing.expect(p.executeBuiltinLine(0, "Save /tmp/pardes-copy/other.txt")); // a copy, never a rename: the pane keeps its file, and that file is still // unsaved, so the marker stays where it was try std.testing.expectEqualStrings("/test.txt", pane.file.?.path); try std.testing.expect(pane.file.?.revision != pane.file.?.saved_revision); try std.testing.expectEqualStrings( "/tmp/pardes-copy/other.txt", drainForSavePath(p, &buf) orelse return error.NoSaveAsked, ); try std.testing.expectEqualStrings( "after\n", p.fallback.get("/tmp/pardes-copy/other.txt") orelse return error.NothingWritten, ); // its own path, spelled out, is the in-place write — nothing is asked of // the host but save_file, and the pane comes clean try std.testing.expect(p.executeBuiltinLine(0, "Save /test.txt")); try std.testing.expect(drainForSavePath(p, &buf) == null); try std.testing.expectEqual(pane.file.?.revision, pane.file.?.saved_revision); } test "saves armed in one batch stay with their own panes" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .shells = 3, .cols = 100, .rows = 30 }); defer p.deinit(); while (p.nextEffect()) |_| {} const tty_id = p.freeSlot() orelse return error.NoPaneSlots; const shell = try p.newShell(tty_id, "/tmp/pardes-batch"); p.setCwd(tty_id, "/tmp/pardes-batch"); p.update(.{ .output = .{ .pane = @intCast(tty_id), .bytes = "shell text\r\n" } }); const file = try p.setTestFile("file text\n"); const file_id = p.paneIdOf(file) orelse return error.MissingFilePane; try std.testing.expect(shell.isTerminal()); try std.testing.expect(tty_id != file_id); while (p.nextEffect()) |_| {} // both armed before the host drains anything — the path rides inside its // own effect, so neither can be written with the other pane's text try std.testing.expect(p.executeBuiltinLine(tty_id, "Save tty.txt")); try std.testing.expect(p.executeBuiltinLine(file_id, "Save /tmp/pardes-batch/file.txt")); while (p.nextEffect()) |effect| p.perform(effect); const tty_bytes = p.fallback.get("/tmp/pardes-batch/tty.txt") orelse return error.NothingWritten; try std.testing.expect(std.mem.indexOf(u8, tty_bytes, "shell text") != null); try std.testing.expectEqualStrings( "file text\n", p.fallback.get("/tmp/pardes-batch/file.txt") orelse return error.NothingWritten, ); } test "a save whose pane is gone before the drain writes nothing" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .shells = 3, .cols = 100, .rows = 30 }); defer p.deinit(); while (p.nextEffect()) |_| {} const doomed = p.freeSlot() orelse return error.NoPaneSlots; const shell = try p.newShell(doomed, "/tmp/pardes-gone"); p.update(.{ .output = .{ .pane = @intCast(doomed), .bytes = "doomed text\r\n" } }); while (p.nextEffect()) |_| {} try std.testing.expect(shell.isTerminal()); // Save, then close the pane, then let the host drain: the bytes are read // off the pane at drain time, so there are none to read... try std.testing.expect(p.executeBuiltinLine(doomed, "Save /tmp/pardes-gone/log.txt")); try std.testing.expect(p.executeBuiltinLine(doomed, "Del")); while (p.nextEffect()) |effect| p.perform(effect); try std.testing.expect(p.fallback.get("/tmp/pardes-gone/log.txt") == null); // ...and a pane that took the freed slot in the meantime is NOT the pane // that asked, which is what the serial in the effect is for try std.testing.expect(p.executeBuiltinLine(0, "Save /tmp/pardes-gone/recycled.txt")); try std.testing.expect(p.executeBuiltinLine(0, "Del")); const reused = p.freeSlot() orelse return error.NoPaneSlots; _ = try p.newShell(reused, "/tmp/pardes-gone"); while (p.nextEffect()) |effect| p.perform(effect); try std.testing.expect(p.fallback.get("/tmp/pardes-gone/recycled.txt") == null); } test "Save reaches every tagline with text behind it and no other" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 100, .rows = 30 }); defer p.deinit(); while (p.nextEffect()) |_| {} const out = try p.setTestFile("build.zig:1:1 pub fn main\n"); out.file.?.output = .{ .from = .search }; try std.testing.expectEqualStrings(" Save Tty Del Collapse", Pardes.curTail(out)); // A dump written before Save reached output buffers carries the generic // tail. That is a DEFAULT and not something typed, so it upgrades. const dumped = try std.fmt.allocPrint(p.scratch.allocator(), "{s}{s}", .{ try p.tagPrefix(out), pane_tail, }); p.restoreTail(out, dumped); try std.testing.expect(!out.tag_init); try std.testing.expectEqualStrings(" Save Tty Del Collapse", Pardes.curTail(out)); // A scratch is an output buffer that has worn the FILE tails all along, // so its own older default upgrades from that family too. out.file.?.output = .{ .from = .{ .cmd = std.meta.stringToEnum(Builtin, "New").? } }; out.tag_tail_len = 0; out.tag_init = false; const scratch_dumped = try std.fmt.allocPrint(p.scratch.allocator(), "{s}{s}", .{ try p.tagPrefix(out), prev_file_pane_tail, }); p.restoreTail(out, scratch_dumped); try std.testing.expect(!out.tag_init); try std.testing.expectEqualStrings(" Save Tty Del Collapse", Pardes.curTail(out)); // An image's bytes on disk already are what they are: nothing of the // pane's own is unwritten, so the word is absent and inert. const img_id = p.freeSlot().?; const img = try panes.Image.create(p, img_id, "/tmp/pardes-tag.ppm", &.{}); try std.testing.expectEqualStrings(" Tty Del Collapse", Pardes.curTail(img)); try std.testing.expect(p.executeBuiltinLine(img_id, "Save")); try std.testing.expect(img.prompt != .save); var buf: [256]u8 = undefined; try std.testing.expect(drainForSavePath(p, &buf) == null); } test "a save path that names no file is refused before anything is rewritten" { const p = try Pardes.init(std.testing.allocator, .{ .shells = 3, .cols = 100, .rows = 30 }); defer p.deinit(); while (p.nextEffect()) |_| {} const source: usize = 2; p.setCwd(source, "/tmp/pardes-refuse"); try std.testing.expect(p.executeBuiltinLine(source, "New")); const id = p.active; const scratch = p.panes[id].?; p.sync(); while (p.nextEffect()) |_| {} try std.testing.expect(p.executeBuiltinLine(id, "Save")); try std.testing.expect(scratch.prompt == .save); p.submitSave(id); var buf: [256]u8 = undefined; try std.testing.expect(drainForSavePath(p, &buf) == null); try std.testing.expect(scratch.file.?.output != null); try std.testing.expectEqualStrings("/tmp/pardes-refuse/+New", scratch.file.?.path); // ...and neither is a multi-line selection chorded onto the word try std.testing.expect(p.executeBuiltinLine(id, "Save one\ntwo")); try std.testing.expect(drainForSavePath(p, &buf) == null); try std.testing.expect(scratch.file.?.output != null); } test "a host with no methods at all is a complete in-process pardes" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 40, .rows = 10 }); defer p.deinit(); while (p.nextEffect()) |_| {} // A pump against the empty host renders a frame and blocks on nothing. try p.pump(.{}); try std.testing.expect(!p.quit); // the absent child is SILENT: the bytes are dropped, and nothing appears // on the pane's screen to suggest a program answered const before = p.panes[0].?.terminal.?.vt.screens.active.cursor.y; p.perform(.{ .write = .{ .pane = 0, .bytes = .from("ls\r") } }); try std.testing.expectEqual(before, p.panes[0].?.terminal.?.vt.screens.active.cursor.y); // the clipboard round-trips through the in-process one p.yank = try gpa.dupe(u8, "copied"); p.perform(.set_clipboard); try std.testing.expectEqualStrings("copied", p.fallback.clipboard.items); // ...and a save with no filesystem lands in the virtual one const doc = try p.setTestFile("body\n"); var doc_id: u8 = 0; for (p.panes, 0..) |slot, i| if (slot == doc) { doc_id = @intCast(i); }; p.perform(.{ .save_file = .{ .pane = doc_id } }); try std.testing.expectEqualStrings("body\n", p.fallback.files.get(doc.file.?.path).?); // a language backend nobody supplied answers nothing rather than hanging p.perform(.{ .lsp = .{ .id = 7, .kind = .hover, .pane = doc_id, .offset = 0, .arg = .{} } }); p.perform(.quit); try std.testing.expect(p.quit); } test "a builtin that needs a pane reports capacity failure when every slot is full" { const p = try Pardes.init(std.testing.allocator, .{ .shells = 3, .cols = 100, .rows = 30 }); defer p.deinit(); while (p.nextEffect()) |_| {} for (0..MAX_PANES) |id| if (p.panes[id] == null) { _ = try p.newDocPane(id); }; const help = std.meta.stringToEnum(Builtin, "Help") orelse return error.MissingHelpBuiltin; p.runBuiltin(help, 0, "", null); try std.testing.expect(std.mem.indexOf(u8, p.panes[0].?.msg[0..p.panes[0].?.msg_len], "help: NoPaneSlots") != null); } test "Msg writes the transient row by hand, bare or with text, and input ends it" { const p = try Pardes.init(std.testing.allocator, .{ .shells = 3, .cols = 100, .rows = 30 }); defer p.deinit(); while (p.nextEffect()) |_| {} const row = struct { fn of(pp: *Pardes, i: usize) []const u8 { const pane = pp.panes[i].?; return pane.msg[0..pane.msg_len]; } }.of; // the whole tail, spaces and all: the row is a sentence, not a word try std.testing.expect(p.executeBuiltinLine(2, "Msg saved /etc/hosts")); try std.testing.expectEqualStrings("saved /etc/hosts", row(p, 2)); // ...on the pane that ran it, and nowhere else try std.testing.expectEqualStrings("", row(p, 0)); // Bare, it reports ITSELF through reportError — the `: ` // every failed save and refused Look arrives in. try std.testing.expect(p.executeBuiltinLine(2, "Msg")); try std.testing.expectEqualStrings("Msg: NoMessage", row(p, 2)); // A message is exactly as old as your last input. p.update(.{ .key = .{ .cp = Key.escape } }); try std.testing.expectEqualStrings("", row(p, 2)); // Longer than the row's buffer is CUT, never refused and never overrun: // this word is the only way to reach that boundary without a 300-byte path. var long: [600]u8 = @splat('x'); var line: [608]u8 = undefined; const cmd = try std.fmt.bufPrint(&line, "Msg {s}", .{&long}); try std.testing.expect(p.executeBuiltinLine(2, cmd)); try std.testing.expectEqual(p.panes[2].?.msg.len, row(p, 2).len); } test "Font keeps requested and acknowledged faces as separate plain state" { if (comptime !font_picker) return; // there is no Font builtin on tty or web const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24 }); defer p.deinit(); var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); defer arena.deinit(); const installed = fonts.list(arena.allocator(), null); if (installed.len == 0) return; // a machine with no monospace face at all var line: [512]u8 = undefined; const cmd = try std.fmt.bufPrint(&line, "Font {s}", .{installed[0].name}); try std.testing.expect(p.executeBuiltinLine(p.active, cmd)); try std.testing.expect(p.settings.font.pending); try std.testing.expectEqualStrings(installed[0].name, p.settings.font.requested_name.get()); try std.testing.expectEqualStrings(installed[0].path, p.settings.font.requested_path.get()); try std.testing.expectEqual(@as(u16, 0), p.settings.font.requested_size_hundredths); try std.testing.expectEqualStrings("", p.settings.font.effective_name.get()); // Boot/zoom/display-scale reports describe the face already being drawn; // they must neither answer nor reject the new request waiting beside it. try std.testing.expect(p.observeFont("Embedded Mono", 1200, .pixels)); try std.testing.expectEqualStrings("Embedded Mono", p.settings.font.effective_name.get()); try std.testing.expect(p.settings.font.pending); try std.testing.expect(!p.acknowledgeFont("Stray Ack", 1250, .pixels)); p.rejectFont(); try std.testing.expect(p.settings.font.pending); const request = p.takeFontRequest() orelse return error.MissingFontRequest; try std.testing.expectEqualStrings(installed[0].path, request); try std.testing.expect(p.settings.font.pending); try std.testing.expect(p.takeFontRequest() == null); const fake_track: layout.Track = .{ .serial = p.panes[p.active].?.serial, .pane = @intCast(p.active), .effect = .slide, .from = .{ .w = 20, .h = 10 }, .to = .{ .x = 10, .w = 20, .h = 10 }, }; p.presentation.tracks[p.active] = fake_track; p.presentation.shown_tracks[p.active] = fake_track; p.presentation.acknowledged = true; try std.testing.expect(p.acknowledgeFont(installed[0].name, 1375, .pixels)); try std.testing.expect(!p.settings.font.pending); try std.testing.expectEqualStrings(installed[0].name, p.settings.font.effective_name.get()); try std.testing.expectEqual(@as(u16, 1375), p.settings.font.effective_size_hundredths); try std.testing.expectEqual(config.Runtime.FontSizeUnit.pixels, p.settings.font.effective_size_unit); try std.testing.expect(p.presentation.tracks[p.active] == null); try std.testing.expect(p.presentation.pending); try std.testing.expect(!p.acknowledgeFont("Duplicate Ack", 1400, .pixels)); p.acknowledgePanelPresentation(&.{}); try std.testing.expect(p.executeBuiltinLine(p.active, cmd)); _ = p.takeFontRequest() orelse return error.MissingRepeatedFontRequest; p.presentation.tracks[p.active] = fake_track; try std.testing.expect(p.acknowledgeFont(installed[0].name, 1375, .pixels)); try std.testing.expect(p.presentation.tracks[p.active] != null); try std.testing.expect(!p.presentation.pending); // A name nothing answers to changes neither request nor effective state. try std.testing.expect(p.executeBuiltinLine(p.active, "Font zzz-no-such-face")); try std.testing.expectEqualStrings(installed[0].name, p.settings.font.requested_name.get()); try std.testing.expectEqualStrings(installed[0].name, p.settings.font.effective_name.get()); } test "Font size suffix reaches native request and invalid input leaves it intact" { if (comptime !font_picker) return; const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24 }); defer p.deinit(); var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); defer arena.deinit(); const installed = fonts.list(arena.allocator(), null); if (installed.len == 0) return; var line: [512]u8 = undefined; try std.testing.expect(p.executeBuiltinLine(p.active, try std.fmt.bufPrint(&line, "Font {s}:18.5", .{installed[0].name}))); try std.testing.expectEqual(@as(u16, 1850), p.settings.font.requested_size_hundredths); try std.testing.expectEqualStrings(installed[0].path, p.takeFontRequest().?); try std.testing.expect(p.acknowledgeFont(installed[0].name, 1850, .pixels)); try std.testing.expect(p.executeBuiltinLine(p.active, try std.fmt.bufPrint(&line, "Font {s}:nan", .{installed[0].name}))); try std.testing.expectEqual(@as(u16, 1850), p.settings.font.requested_size_hundredths); try std.testing.expect(p.takeFontRequest() == null); try std.testing.expect(p.executeBuiltinLine(p.active, "Font zzz-no-such-face:20")); try std.testing.expectEqual(@as(u16, 1850), p.settings.font.requested_size_hundredths); try std.testing.expect(p.takeFontRequest() == null); try std.testing.expect(p.executeBuiltinLine(p.active, try std.fmt.bufPrint(&line, "Font {s}", .{installed[0].name}))); try std.testing.expectEqual(@as(u16, 0), p.settings.font.requested_size_hundredths); try std.testing.expectEqual(@as(u16, 1850), p.settings.font.effective_size_hundredths); } test "Pet command is SDL-only optional and validates its argument" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24 }); defer p.deinit(); try std.testing.expectEqual(.off, p.settings.pet); if (comptime platform != .gui) { try std.testing.expect(std.meta.stringToEnum(builtins.registry.Builtin(), "Pet") == null); return; } try std.testing.expect(p.executeBuiltinLine(p.active, "Pet cat")); try std.testing.expectEqual(.cat, p.settings.pet); try std.testing.expect(p.executeBuiltinLine(p.active, "Pet dog")); try std.testing.expectEqual(.cat, p.settings.pet); try std.testing.expect(p.executeBuiltinLine(p.active, "Pet frog")); try std.testing.expectEqual(.frog, p.settings.pet); try std.testing.expect(p.executeBuiltinLine(p.active, "Pet off")); try std.testing.expectEqual(.off, p.settings.pet); } test "startup config overrides restored styling before initFromDump returns" { const source = try Pardes.init(std.testing.allocator, .{ .startup_config = "Theme dark\n" }); defer source.deinit(); try std.testing.expectEqualStrings("dark", source.theme().name); try source.dumpState(); const restored = try Pardes.initFromDump( std.testing.allocator, .{ .startup_config = "Theme acme\n" }, source.dump_out.?, ); defer restored.deinit(); try std.testing.expectEqualStrings("acme", restored.theme().name); try std.testing.expect(!source.animationActive()); try std.testing.expect(!restored.animationActive()); try std.testing.expectEqual(ChromeTheme.fromTheme(source.theme()), source.chromeTheme().*); try std.testing.expectEqual(ChromeTheme.fromTheme(restored.theme()), restored.chromeTheme().*); } test "restored sessions animate layout changes after bootstrap" { if (platform == .web) return; const source = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 16 }); defer source.deinit(); try source.dumpState(); const restored = try Pardes.initFromDump( std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 16, .startup_config = "PanelSlide\n" }, source.dump_out.?, ); defer restored.deinit(); try std.testing.expectEqual(layout.Transition.slide, restored.settings.panel_transition); try std.testing.expect(!restored.animationActive()); var frame: std.heap.ArenaAllocator = .init(std.testing.allocator); defer frame.deinit(); const initial = try restored.render(frame.allocator()); restored.acknowledgePanelPresentation(initial.panelTracks()); _ = try restored.newShell(1, ""); try std.testing.expect(layout.splitColumn(restored, 0, 1, false)); restored.sync(); try std.testing.expect(restored.presentation.tracks[0] != null); try std.testing.expect(restored.presentation.tracks[1] != null); try std.testing.expect(restored.animationActive()); } test "restored terminals keep monotonic nonzero pane identities" { const gpa = std.testing.allocator; const source = try Pardes.init(gpa, .{ .shells = 3 }); defer source.deinit(); // Filter is a fresh presentation default, not emulator/dump state. Even a // pane toggled off in the source session comes back with the default on. source.panes[1].?.tty_filter = false; try source.dumpState(); const restored = try Pardes.initFromDump(gpa, .{}, source.dump_out.?); defer restored.deinit(); var ids: [MAX_PANES]usize = undefined; var serials: [MAX_PANES]u32 = undefined; var n: usize = 0; for (restored.panes, 0..) |slot, id| { const pane = slot orelse continue; if (!pane.isTerminal()) continue; try std.testing.expect(pane.tty_filter); try std.testing.expect(pane.serial != 0); for (serials[0..n]) |seen| try std.testing.expect(pane.serial != seen); ids[n] = id; serials[n] = pane.serial; n += 1; } try std.testing.expectEqual(@as(usize, 3), n); for (ids[0..n], serials[0..n]) |id, serial| try std.testing.expectEqual(id, restored.paneBySerial(serial).?); const before = restored.next_serial; const free = restored.freeSlot() orelse return error.NoFreePane; const fresh = try restored.newShell(free, ""); try std.testing.expect(fresh.tty_filter); try std.testing.expectEqual(before + 1, fresh.serial); try std.testing.expectEqual(fresh.serial, restored.next_serial); } fn chordName(comptime c: config.Chord) []const u8 { comptime { const mods = (if (c.ctrl) "C-" else "") ++ (if (c.alt) "A-" else "") ++ (if (c.shift) "S-" else ""); for (@typeInfo(Key).@"struct".decls) |d| { if (@TypeOf(@field(Key, d.name)) != u21) continue; if (@field(Key, d.name) == c.cp) return mods ++ d.name; } if (c.cp < 0x20 or c.cp > 0x7e) @compileError("chord has no name for the index"); return mods ++ &[_]u8{@as(u8, @intCast(c.cp))}; } } fn shortcutCount(comptime b: Builtin) comptime_int { var count = 0; for (config.window_keys) |wk| if (wk.cmd == b) { count += 2; }; for (config.jump_keys) |jk| if (jk.cmd == b) { count += 1; }; if (config.look_cmd == b) count += config.look_key.len + 1; if (config.exec_cmd == b) count += config.exec_key.len + 1; var it = std.mem.tokenizeScalar(u8, config.topbar_str, ' '); while (it.next()) |w| if (std.mem.eql(u8, w, @tagName(b))) { count += 1; }; return count; } fn shortcuts(comptime b: Builtin) []const u8 { comptime { @setEvalBranchQuota(20000); var parts: [shortcutCount(b)][]const u8 = undefined; var part: usize = 0; for (config.window_keys) |wk| { if (wk.cmd != b) continue; // the prefix has one spelling and this is it const pfx = chordName(config.window_prefix[0]) ++ " "; parts[part] = pfx ++ chordName(wk.letter); parts[part + 1] = pfx ++ chordName(wk.arrow); part += 2; } for (config.jump_keys) |jk| { if (jk.cmd == b) { parts[part] = chordName(jk.chord); part += 1; } } if (config.look_cmd == b) { for (config.look_key) |k| { parts[part] = chordName(k); part += 1; } parts[part] = @tagName(config.look_button) ++ "-click"; part += 1; } if (config.exec_cmd == b) { for (config.exec_key) |k| { parts[part] = chordName(k); part += 1; } parts[part] = @tagName(config.exec_button) ++ "-click"; part += 1; } var it = std.mem.tokenizeScalar(u8, config.topbar_str, ' '); while (it.next()) |w| { if (std.mem.eql(u8, w, @tagName(b))) { parts[part] = "topbar"; part += 1; } } var len: usize = if (parts.len == 0) 0 else 2 * (parts.len - 1); for (parts) |text| len += text.len; var out: [len]u8 = undefined; var at: usize = 0; for (parts, 0..) |text, i| { if (i > 0) { @memcpy(out[at..][0..2], ", "); at += 2; } @memcpy(out[at..][0..text.len], text); at += text.len; } return &out; } } fn leaderKeys(comptime path: []const u8) []const u8 { comptime { var out: [4 + 2 * path.len - 1]u8 = undefined; @memcpy(out[0..4], "SPC "); var at: usize = 4; for (path, 0..) |c, i| { if (i > 0) { out[at] = ' '; at += 1; } out[at] = c; at += 1; } return &out; } } const Row = struct { path: ?[]const u8, cmd: Builtin, line: []const u8 }; pub const builtin_rows: [std.enums.values(Builtin).len]Row = blk: { // one pass per builtin per config table, and the insertion sort below is // O(n²) comptime branches on top of it @setEvalBranchQuota(200000); var rows: [std.enums.values(Builtin).len]Row = undefined; // the widest spaced-out path ("l D" = 3) and the widest name, so the three // columns line up var keyw: usize = 0; var namew: usize = 0; for (std.enums.values(Builtin)) |b| { if (config.leader_path.get(b)) |p| keyw = @max(keyw, 2 * p.len - 1); namew = @max(namew, @tagName(b).len); } for (std.enums.values(Builtin), 0..) |b, i| { const keys = if (config.leader_path.get(b)) |path| leaderKeys(path) else ""; const rest = shortcuts(b); const named = @tagName(b) ++ (if (rest.len == 0) "" else (" " ** (namew - @tagName(b).len)) ++ " " ++ rest); rows[i] = .{ .path = config.leader_path.get(b), .cmd = b, .line = keys ++ (" " ** (4 + keyw - keys.len)) ++ " " ++ named, }; } const last = "\x7f"; for (1..rows.len) |i| { var j = i; while (j > 0 and std.mem.lessThan(u8, rows[j].path orelse last, rows[j - 1].path orelse last)) : (j -= 1) { const t = rows[j]; rows[j] = rows[j - 1]; rows[j - 1] = t; } } break :blk rows; }; comptime { @setEvalBranchQuota(20000); // one branch per string byte, behind the decl walk that folds Builtin var it = std.mem.tokenizeScalar(u8, config.topbar_str, ' '); words: while (it.next()) |w| { for (@typeInfo(Builtin).@"enum".fields) |f| if (std.mem.eql(u8, f.name, w)) continue :words; @compileError("topbar word is not a builtin: " ++ w); } } const WordBounds = struct { lo: usize, hi: usize }; fn wordBoundsAtCol(str: []const u8, col: usize) ?WordBounds { if (col >= str.len or str[col] == ' ') return null; var lo: usize = col; while (lo > 0 and str[lo - 1] != ' ') lo -= 1; var hi: usize = col; while (hi < str.len and str[hi] != ' ') hi += 1; return .{ .lo = lo, .hi = hi }; } /// the whitespace-delimited word covering `col` in `str` (topbar dispatch) fn wordAtCol(str: []const u8, col: usize) []const u8 { const bounds = wordBoundsAtCol(str, col) orelse return ""; return str[bounds.lo..bounds.hi]; } /// `a` IS `b` or lives under it — how Grep drops a pane's directory when /// another pane's directory already covers it fn pathUnder(a: []const u8, b: []const u8) bool { const root = std.mem.trimEnd(u8, b, "/"); if (!std.mem.startsWith(u8, a, root)) return false; return a.len == root.len or a[root.len] == '/'; } const tutor_text = @embedFile("tutor.txt"); // ---- theme ---- fn mix(a: [3]u8, b: [3]u8) [3]u8 { var out: [3]u8 = undefined; for (&out, a, b) |*c, x, y| c.* = @intCast((@as(u16, x) + y) / 2); return out; } pub const Theme = struct { name: []const u8, bg: ?[3]u8, fg: ?[3]u8, sel_bg: [3]u8, sel_fg: [3]u8, tag_bg: [3]u8, tag_fg: [3]u8, box: [3]u8, // move box, focused pane box_dim: [3]u8, // move box, everything else kw: [3]u8, str: [3]u8, num: [3]u8, comment: [3]u8, lineno: [3]u8, scroll_track: [3]u8, scroll_thumb: [3]u8, palette: ?[16][3]u8, // Pardes UI roles. Optional so existing ThemeFile documents and imported // editor palettes keep working; ChromeTheme resolves their legacy defaults. tag_active_bg: ?[3]u8 = null, tag_active_fg: ?[3]u8 = null, /// Filename/output-name accent, also used by the Tty command. tag_name_fg: ?[3]u8 = null, /// Active tint; absence preserves the shared name tint or active tag ink. tag_active_name_fg: ?[3]u8 = null, /// Column grips stay distinct from pane grips in both focus states. column_box: ?[3]u8 = null, column_box_dim: ?[3]u8 = null, border: ?[3]u8 = null, search_bg: ?[3]u8 = null, search_fg: ?[3]u8 = null, diagnostic_error: ?[3]u8 = null, diagnostic_warning: ?[3]u8 = null, diagnostic_info: ?[3]u8 = null, diagnostic_hint: ?[3]u8 = null, // The current line number is a quiet foreground cue, not a tag-colored // background block. Older themes fall back to their normal gutter ink. lineno_active: ?[3]u8 = null, }; const curated = struct { pub const orchard = @import("themes/orchard.zig"); pub const dusk = @import("themes/dusk.zig"); pub const ink = @import("themes/ink.zig"); pub const paper = @import("themes/paper.zig"); pub const daybreak = @import("themes/daybreak.zig"); pub const atelier = @import("themes/atelier.zig"); pub const forge = @import("themes/forge.zig"); pub const lagoon = @import("themes/lagoon.zig"); pub const solarium = @import("themes/solarium.zig"); pub const spectrum = @import("themes/spectrum.zig"); pub const harvest = @import("themes/harvest.zig"); pub const clay = @import("themes/clay.zig"); pub const forge_black = @import("themes/forge_black.zig"); pub const forge_soft = @import("themes/forge_soft.zig"); pub const orchard_black = @import("themes/orchard_black.zig"); pub const helix = @import("themes/helix.zig"); pub const dark = @import("themes/dark.zig"); pub const acme = @import("themes/acme.zig"); }; fn fold(comptime C: type) [@typeInfo(C).@"struct".decls.len]Theme { comptime { @setEvalBranchQuota(400000); var out: [@typeInfo(C).@"struct".decls.len]Theme = undefined; for (@typeInfo(C).@"struct".decls, 0..) |d, i| { var t: Theme = undefined; const source = @field(C, d.name).theme; for (@typeInfo(Theme).@"struct".fields) |f| { @field(t, f.name) = if (@hasField(@TypeOf(source), f.name)) @field(source, f.name) else if (f.default_value_ptr) |ptr| @as(*const f.type, @ptrCast(@alignCast(ptr))).* else @compileError("theme " ++ d.name ++ " is missing " ++ f.name); } out[i] = t; } return out; } } pub const themes = fold(curated) ++ fold(@import("generated_themes")); /// Original and classic-inspired Pardes palettes precede retained imports. pub const native_theme_count = 15; fn themeContrast(a: [3]u8, b: [3]u8) f64 { var luminance: [2]f64 = .{ 0, 0 }; for ([2][3]u8{ a, b }, 0..) |rgb, i| { for (rgb, [3]f64{ 0.2126, 0.7152, 0.0722 }) |channel, weight| { const srgb = @as(f64, @floatFromInt(channel)) / 255; const linear = if (srgb <= 0.04045) srgb / 12.92 else std.math.pow(f64, (srgb + 0.055) / 1.055, 2.4); luminance[i] += weight * linear; } } return (@max(luminance[0], luminance[1]) + 0.05) / (@min(luminance[0], luminance[1]) + 0.05); } test "native themes keep text and UI roles readable on their own surfaces" { const names = [_][]const u8{ "orchard", "dusk", "ink", "paper", "daybreak", "atelier", "forge", "lagoon", "solarium", "spectrum", "harvest", "clay", "forge_black", "forge_soft", "orchard_black" }; try std.testing.expectEqual(names.len, native_theme_count); for (themes[0..names.len], names) |th, name| { try std.testing.expectEqualStrings(name, th.name); const chrome = ChromeTheme.fromTheme(&th); const minimum: f64 = if (std.mem.eql(u8, name, "ink") or std.mem.eql(u8, name, "daybreak")) 7 else 4.5; const text_colors = [_][3]u8{ th.fg.?, th.kw, th.str, th.num, th.comment, chrome.diagnostic_error, chrome.diagnostic_warning, chrome.diagnostic_info, chrome.diagnostic_hint, }; for (text_colors) |fg| try std.testing.expect(themeContrast(fg, th.bg.?) >= minimum); // Gutters are orientation aids, not source text: stay visible without // competing with comments or pulling attention away from the body. try std.testing.expect(themeContrast(th.lineno, th.bg.?) >= 2.5); try std.testing.expect(themeContrast(th.lineno, th.bg.?) <= 4); try std.testing.expect(themeContrast(th.lineno_active.?, th.bg.?) >= themeContrast(th.lineno, th.bg.?)); try std.testing.expect(themeContrast(th.lineno_active.?, th.bg.?) <= 4.5); try std.testing.expect(themeContrast(th.comment, th.bg.?) >= 5); try std.testing.expect(themeContrast(th.comment, th.bg.?) >= themeContrast(th.lineno, th.bg.?) + 2); try std.testing.expect(themeContrast(th.tag_fg, th.tag_bg) >= minimum); try std.testing.expect(themeContrast(chrome.tag_active_fg, chrome.tag_active_bg) >= minimum); try std.testing.expect(th.tag_name_fg != null); try std.testing.expect(themeContrast(chrome.tag_name_fg, th.tag_bg) >= minimum); try std.testing.expect(themeContrast(chrome.tag_active_name_fg, chrome.tag_active_bg) >= minimum); // Focus stays on the same tonal surface, rather than inverting a // whole command strip. Text remains readable in both states. try std.testing.expect(themeContrast(chrome.tag_active_bg, th.tag_bg) <= 1.6); try std.testing.expect(themeContrast(chrome.tag_active_fg, th.tag_fg) <= 1.6); try std.testing.expect(themeContrast(th.sel_fg, th.sel_bg) >= minimum); try std.testing.expect(themeContrast(chrome.search_fg, chrome.search_bg) >= minimum); // ANSI black can intentionally match the page (for backgrounds). All // other ANSI foregrounds, including bright black, remain legible. for (th.palette.?[1..]) |fg| try std.testing.expect(themeContrast(fg, th.bg.?) >= minimum); } } test "native filename tints change hue without changing perceived lightness" { const Perceptual = struct { fn lab(rgb: [3]u8) [3]f64 { var linear: [3]f64 = undefined; for (rgb, 0..) |channel, i| { const value = @as(f64, @floatFromInt(channel)) / 255; linear[i] = if (value <= 0.04045) value / 12.92 else std.math.pow(f64, (value + 0.055) / 1.055, 2.4); } const r = linear[0]; const g = linear[1]; const b = linear[2]; const l = std.math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b); const m = std.math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b); const n = std.math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b); return .{ 0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * n, 1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * n, 0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * n, }; } }; for (themes[0..native_theme_count]) |th| { try std.testing.expect(th.tag_name_fg != null and th.tag_active_name_fg != null); const chrome = ChromeTheme.fromTheme(&th); const pairs = [2][2][3]u8{ .{ chrome.tag_fg, chrome.tag_name_fg }, .{ chrome.tag_active_fg, chrome.tag_active_name_fg }, }; for (pairs) |pair| { const base = Perceptual.lab(pair[0]); const name = Perceptual.lab(pair[1]); // Half a lightness point allows 8-bit quantization without a // basename becoming visibly brighter or darker than its tag. try std.testing.expectApproxEqAbs(base[0], name[0], 0.005); const base_chroma = @sqrt(base[1] * base[1] + base[2] * base[2]); const name_chroma = @sqrt(name[1] * name[1] + name[2] * name[2]); try std.testing.expect(base_chroma > 0.005 and name_chroma > 0.02); // A normalized dot product <= cos(60 degrees) means a clear // hue difference, independent of saturation or lightness. const hue_cosine = (base[1] * name[1] + base[2] * name[2]) / (base_chroma * name_chroma); try std.testing.expect(hue_cosine <= 0.5); } } } test "native dark palettes choose near-black or deliberately soft contrast" { for (themes[0..native_theme_count]) |th| { if (themeContrast(th.bg.?, .{ 0, 0, 0 }) > 10) continue; // light paper const soft = std.mem.eql(u8, th.name, "dusk") or std.mem.eql(u8, th.name, "lagoon") or std.mem.eql(u8, th.name, "solarium") or std.mem.eql(u8, th.name, "forge_soft"); if (soft) { try std.testing.expect(themeContrast(th.fg.?, th.bg.?) >= 4.5); try std.testing.expect(themeContrast(th.fg.?, th.bg.?) <= 7); } else { for (th.bg.?) |channel| try std.testing.expect(channel <= 0x14); try std.testing.expect(themeContrast(th.fg.?, th.bg.?) >= 14); } } const forge = curated.forge.theme; try std.testing.expectEqual(forge.bg[0], forge.bg[1]); // Forge chrome is cool-neutral, never the old green-over-blue mint cast. for ([_][3]u8{ forge.tag_bg, forge.tag_active_bg, forge.box, forge.border }) |rgb| try std.testing.expect(rgb[2] >= rgb[1]); try std.testing.expectEqual([3]u8{ 0, 0, 0 }, @as([3]u8, curated.forge_black.theme.bg)); try std.testing.expectEqual([3]u8{ 0, 0, 0 }, @as([3]u8, curated.orchard_black.theme.bg)); } test "line numbers stay quiet without a current-line tag background" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 }); defer p.deinit(); const pane = try p.setTestFile("first\nsecond\n"); pane.cur_pinned = true; pane.cur_row = 0; var custom = themes[0]; custom.name = try gpa.dupe(u8, "quiet-gutter"); custom.lineno = .{ 70, 70, 70 }; custom.lineno_active = .{ 95, 95, 95 }; p.custom_theme = custom; p.sync(); const x = p.rects[0].x + config.GUTTER; const y = p.rects[0].y + BOX_H; for ([_]bool{ false, true }) |focus_tint| { p.settings.focus_tint = focus_tint; const surface = try p.render(p.scratch.allocator()); try std.testing.expectEqual(Color{ .rgb = .{ 95, 95, 95 } }, surface.at(x, y).style.fg); try std.testing.expectEqual(Color{ .rgb = .{ 70, 70, 70 } }, surface.at(x, y + 1).style.fg); try std.testing.expectEqual(Color{ .rgb = custom.bg.? }, surface.at(x, y).style.bg); try std.testing.expect(surface.at(x, y).style.bold); try std.testing.expect(!surface.at(x, y + 1).style.bold); } // Old ThemeFile palettes need no new field, and do not regain the block. p.custom_theme.?.lineno_active = null; const fallback = try p.render(p.scratch.allocator()); try std.testing.expectEqual(Color{ .rgb = .{ 70, 70, 70 } }, fallback.at(x, y).style.fg); try std.testing.expectEqual(Color{ .rgb = custom.bg.? }, fallback.at(x, y).style.bg); try std.testing.expect(fallback.at(x, y).style.bold); // Moving the caret clears the previous cue; editing the tag parks it. pane.cur_row = 1; const moved = try p.render(p.scratch.allocator()); try std.testing.expect(!moved.at(x, y).style.bold); try std.testing.expect(moved.at(x, y + 1).style.bold); p.enterTagEdit(pane, -1); const tag_edit = try p.render(p.scratch.allocator()); try std.testing.expect(!tag_edit.at(x, y + 1).style.bold); } test "legacy ThemeFile documents inherit Pardes UI roles without new fields" { @setEvalBranchQuota(10000); var encoded: std.Io.Writer.Allocating = .init(std.testing.allocator); defer encoded.deinit(); try std.zon.stringify.serialize(curated.helix.theme, .{ .whitespace = true }, &encoded.writer); const source = try std.testing.allocator.dupeZ(u8, encoded.written()); defer std.testing.allocator.free(source); const parsed = try std.zon.parse.fromSliceAlloc(Theme, std.testing.allocator, source, null, .{}); defer std.zon.parse.free(std.testing.allocator, parsed); try std.testing.expectEqual(@as(?[3]u8, null), parsed.tag_active_bg); try std.testing.expectEqual(@as(?[3]u8, null), parsed.tag_name_fg); try std.testing.expectEqual(@as(?[3]u8, null), parsed.tag_active_name_fg); try std.testing.expectEqual(@as(?[3]u8, null), parsed.column_box); try std.testing.expectEqual(@as(?[3]u8, null), parsed.column_box_dim); try std.testing.expectEqual(@as(?[3]u8, null), parsed.search_bg); const chrome = ChromeTheme.fromTheme(&parsed); try std.testing.expectEqual(parsed.tag_bg, chrome.tag_active_bg); try std.testing.expectEqual(parsed.tag_fg, chrome.tag_name_fg); try std.testing.expectEqual(chrome.tag_active_fg, chrome.tag_active_name_fg); try std.testing.expectEqual(parsed.num, chrome.column_box); try std.testing.expectEqual(mix(parsed.num, parsed.tag_bg), chrome.column_box_dim); try std.testing.expectEqual(parsed.sel_bg, chrome.search_bg); try std.testing.expectEqual(parsed.fg.?, chrome.diagnostic_error); } test "column grip colors remain distinct from pane grips across native themes" { for (themes[0..native_theme_count]) |th| { try std.testing.expect(th.column_box != null and th.column_box_dim != null); const chrome = ChromeTheme.fromTheme(&th); for ([_][2][3]u8{ .{ chrome.box, chrome.column_box }, .{ chrome.box_dim, chrome.column_box_dim }, }) |pair| { var distance: u32 = 0; for (pair[0], pair[1]) |a, b| { const delta = @as(i32, a) - b; distance += @intCast(delta * delta); } try std.testing.expect(distance >= 30 * 30); } } var custom = themes[0]; custom.column_box = .{ 90, 130, 170 }; custom.column_box_dim = null; var chrome = ChromeTheme.fromTheme(&custom); try std.testing.expectEqual(custom.column_box.?, chrome.column_box); try std.testing.expectEqual(mix(custom.column_box.?, custom.tag_bg), chrome.column_box_dim); custom.column_box_dim = .{ 40, 60, 80 }; chrome = ChromeTheme.fromTheme(&custom); try std.testing.expectEqual(custom.column_box_dim.?, chrome.column_box_dim); } test "custom filename tints preserve optional active fallback precedence" { var theme = themes[0]; theme.tag_name_fg = null; theme.tag_active_name_fg = null; var chrome = ChromeTheme.fromTheme(&theme); try std.testing.expectEqual(theme.tag_fg, chrome.tag_name_fg); try std.testing.expectEqual(theme.tag_active_fg.?, chrome.tag_active_name_fg); theme.tag_name_fg = .{ 120, 130, 140 }; chrome = ChromeTheme.fromTheme(&theme); try std.testing.expectEqual(theme.tag_name_fg.?, chrome.tag_name_fg); try std.testing.expectEqual(theme.tag_name_fg.?, chrome.tag_active_name_fg); theme.tag_active_name_fg = .{ 150, 160, 170 }; chrome = ChromeTheme.fromTheme(&theme); try std.testing.expectEqual(theme.tag_name_fg.?, chrome.tag_name_fg); try std.testing.expectEqual(theme.tag_active_name_fg.?, chrome.tag_active_name_fg); theme.tag_name_fg = null; chrome = ChromeTheme.fromTheme(&theme); try std.testing.expectEqual(theme.tag_fg, chrome.tag_name_fg); try std.testing.expectEqual(theme.tag_active_name_fg.?, chrome.tag_active_name_fg); } comptime { if (themes.len > std.math.maxInt(u16) + 1) @compileError("runtime config theme index no longer fits u16"); } comptime { @setEvalBranchQuota(20000); const ours = fold(curated).len; for (themes[0..ours]) |c| for (themes[ours..]) |g| if (std.mem.eql(u8, c.name, g.name)) @compileError("theme name \"" ++ c.name ++ "\" is both ours and generated; rename the vendored source"); } const boot_buffer = \\x selects a line, Tab runs it. 0x optional. \\ \\-- flash: this image's rodata, then its code \\Hexdump 40000020 40 \\Hexdump 40050000 40 \\-- L2MEM: firmware data, then the heap \\Hexdump 4ff00000 40 \\Hexdump 4ff40000 40 \\-- the mask ROM \\Hexdump 4fc00000 20 \\-- UART0, the console you are reading on \\Peek 500ca000 4 \\-- LP_STORE0: write a word, read it back \\Poke 5011002c deadbeef \\Peek 5011002c \\-- RNG_DATA: not memory. Run it twice. \\Peek 501101a4 \\Peek 501101a4 \\-- the pins: Gpio draws JP1, Gpio 33 flips \\Gpio \\Gpio 33 ; test "every line of the board's boot buffer renders whole" { const cols: usize = @import("pardes_config").esp32p4_cols; var it = std.mem.splitScalar(u8, boot_buffer, '\n'); while (it.next()) |line| { std.testing.expect(line.len + 8 <= cols) catch |err| { std.debug.print("boot buffer line is {d} of {d} usable: \"{s}\"\n", .{ line.len, cols - 8, line }); return err; }; } // and the tour still visits what it says it visits try std.testing.expect(std.mem.indexOf(u8, boot_buffer, "Hexdump 40000020") != null); try std.testing.expect(std.mem.indexOf(u8, boot_buffer, "Poke 5011002c deadbeef") != null); try std.testing.expect(std.mem.indexOf(u8, boot_buffer, "\nGpio\n") != null); } pub const ChromeTheme = struct { tag_bg: [3]u8, tag_fg: [3]u8, tag_active_bg: [3]u8, tag_active_fg: [3]u8, tag_name_fg: [3]u8, tag_active_name_fg: [3]u8, border: [3]u8, search_bg: [3]u8, search_fg: [3]u8, diagnostic_error: [3]u8, diagnostic_warning: [3]u8, diagnostic_info: [3]u8, diagnostic_hint: [3]u8, box: [3]u8, box_dim: [3]u8, column_box: [3]u8, column_box_dim: [3]u8, lineno: [3]u8, scroll_track: [3]u8, scroll_thumb: [3]u8, pub fn fromTheme(th: *const Theme) ChromeTheme { return .{ .tag_bg = th.tag_bg, .tag_fg = th.tag_fg, .tag_active_bg = th.tag_active_bg orelse th.tag_bg, .tag_active_fg = th.tag_active_fg orelse th.tag_fg, .tag_name_fg = th.tag_name_fg orelse th.tag_fg, .tag_active_name_fg = th.tag_active_name_fg orelse (th.tag_name_fg orelse (th.tag_active_fg orelse th.tag_fg)), .border = th.border orelse th.scroll_track, .search_bg = th.search_bg orelse th.sel_bg, .search_fg = th.search_fg orelse th.sel_fg, .diagnostic_error = th.diagnostic_error orelse (th.fg orelse th.tag_fg), .diagnostic_warning = th.diagnostic_warning orelse (th.fg orelse th.tag_fg), .diagnostic_info = th.diagnostic_info orelse (th.fg orelse th.tag_fg), .diagnostic_hint = th.diagnostic_hint orelse (th.fg orelse th.tag_fg), .box = th.box, .box_dim = th.box_dim, .column_box = th.column_box orelse th.num, .column_box_dim = th.column_box_dim orelse mix(th.column_box orelse th.num, th.tag_bg), .lineno = th.lineno, .scroll_track = th.scroll_track, .scroll_thumb = th.scroll_thumb, }; } pub fn interpolate(from: ChromeTheme, to: ChromeTheme, step: u16, steps: u16) ChromeTheme { var out: ChromeTheme = undefined; inline for (@typeInfo(ChromeTheme).@"struct".fields) |field| @field(out, field.name) = layout.Animation.interpolateRgb(@field(from, field.name), @field(to, field.name), step, steps); return out; } }; const ChromeAnimation = if (theme_animation) layout.Animation.Transition(ChromeTheme) else layout.Animation.Immediate(ChromeTheme); const initial_chrome = ChromeTheme.fromTheme(&themes[0]); // ---- the boundary types ---- pub const Color = union(enum) { default, index: u8, rgb: [3]u8 }; pub const FontRole = enum(u8) { body, tagline }; pub const CellStyle = struct { fg: Color = .default, bg: Color = .default, bold: bool = false, dim: bool = false, italic: bool = false, blink: bool = false, reverse: bool = false, invisible: bool = false, strikethrough: bool = false, ul: enum { off, single, double, curly, dotted, dashed } = .off, font_role: FontRole = .body, }; pub const Cell = struct { text: [7]u8 = @splat(' '), len: u8 = 1, style: CellStyle = .{}, default: bool = true, pub fn grapheme(c: *const Cell) []const u8 { return c.text[0..c.len]; } pub fn visuallyEqual(a: *const Cell, b: *const Cell) bool { if (a.default or b.default) return a.default and b.default; return a.len == b.len and std.mem.eql(u8, a.grapheme(), b.grapheme()) and std.meta.eql(a.style, b.style); } pub fn printableAscii(c: *const Cell) ?u8 { if (c.default) return ' '; if (c.len != 1) return null; const byte = c.text[0]; return if (byte >= ' ' and byte <= '~') byte else null; } }; pub const AsciiDiff = struct { from: u8, to: u8, /// Long printable-byte walks complete in about the same time as the other /// panel effects. Extra distance is crossed by eased character skips. pub const max_movement_frames = layout.ascii_max_movement_frames; pub fn between(old: *const Cell, new: *const Cell) ?AsciiDiff { const from = old.printableAscii() orelse return null; const to = new.printableAscii() orelse return null; if (from == to) return null; return .{ .from = from, .to = to }; } pub fn distance(diff: AsciiDiff) u8 { return if (diff.from < diff.to) diff.to - diff.from else diff.from - diff.to; } pub fn movementFrames(diff: AsciiDiff) u16 { return @min(@as(u16, diff.distance()), max_movement_frames); } pub fn frameCount(diff: AsciiDiff) u16 { return diff.movementFrames() + 1; } pub fn byteAt(diff: AsciiDiff, frame: u16) u8 { const movements: u32 = diff.movementFrames(); const at: u32 = @min(@as(u32, frame), movements); const delta: u8 = @intCast(easedDistance(diff.distance(), movements, at)); return if (diff.from < diff.to) diff.from + delta else diff.from - delta; } fn easedDistance(span: u8, movements: u32, at: u32) u32 { if (movements == 0 or at >= movements) return span; const n: u64 = at; const d: u64 = movements; const shape = n * n * n * (10 * d * d + 6 * n * n - 15 * d * n); const denominator = d * d * d * d * d; return @intCast((@as(u64, span) * shape + denominator / 2) / denominator); } pub fn complete(diff: AsciiDiff, frame: u16) bool { return diff.byteAt(frame) == diff.to; } }; pub const PanelCellDiff = union(enum) { unchanged, visual, ascii: AsciiDiff, pub fn between(old: *const Cell, new: *const Cell) PanelCellDiff { if (old.visuallyEqual(new)) return .unchanged; if (AsciiDiff.between(old, new)) |diff| return .{ .ascii = diff }; return .visual; } pub fn changed(diff: PanelCellDiff) bool { return diff != .unchanged; } }; test "cell visual equality ignores dead grapheme tail bytes" { var a: Cell = .{ .default = false }; var b = a; a.text[0] = 'x'; b.text[0] = 'x'; a.text[4] = 'a'; b.text[4] = 'b'; try std.testing.expect(a.visuallyEqual(&b)); b.style.bold = true; try std.testing.expect(!a.visuallyEqual(&b)); a.default = true; b.default = true; try std.testing.expect(a.visuallyEqual(&b)); } test "ASCII cell diffs ease long byte walks in both directions" { var low: Cell = .{ .default = false }; low.text[0] = 'A'; var high: Cell = .{ .default = false }; high.text[0] = 'F'; const rising = AsciiDiff.between(&low, &high).?; try std.testing.expectEqual(@as(u16, 6), rising.frameCount()); try std.testing.expectEqual(@as(u8, 'A'), rising.byteAt(0)); try std.testing.expectEqual(@as(u8, 'A'), rising.byteAt(1)); try std.testing.expectEqual(@as(u8, 'C'), rising.byteAt(2)); try std.testing.expectEqual(@as(u8, 'F'), rising.byteAt(4)); try std.testing.expectEqual(@as(u8, 'F'), rising.byteAt(500)); const falling = AsciiDiff.between(&high, &low).?; try std.testing.expectEqual(@as(u8, 'D'), falling.byteAt(2)); try std.testing.expectEqual(@as(u8, 'A'), falling.byteAt(4)); low.text[0] = ' '; high.text[0] = '~'; const long_rising = AsciiDiff.between(&low, &high).?; try std.testing.expectEqual(@as(u16, 13), long_rising.frameCount()); try std.testing.expectEqual(@as(u8, ' '), long_rising.byteAt(0)); try std.testing.expectEqual(@as(u8, '#'), long_rising.byteAt(2)); try std.testing.expectEqual(@as(u8, 'O'), long_rising.byteAt(6)); try std.testing.expectEqual(@as(u8, '{'), long_rising.byteAt(10)); try std.testing.expectEqual(@as(u8, '~'), long_rising.byteAt(12)); const long_falling = AsciiDiff.between(&high, &low).?; try std.testing.expectEqual(@as(u8, 'O'), long_falling.byteAt(6)); try std.testing.expectEqual(@as(u8, ' '), long_falling.byteAt(12)); // The shape itself is the contract: never backwards, and the widest step // is in the middle of the walk rather than at either end. var previous = long_rising.byteAt(0); var first_step: u8 = 0; var middle_step: u8 = 0; var frame: u16 = 1; while (frame <= long_rising.movementFrames()) : (frame += 1) { const byte = long_rising.byteAt(frame); try std.testing.expect(byte >= previous); const step = byte - previous; if (frame == 1) first_step = step; if (frame == 6) middle_step = step; previous = byte; } try std.testing.expect(middle_step > first_step); try std.testing.expect(middle_step > long_rising.byteAt(12) - long_rising.byteAt(11)); } test "ASCII diff classification skips stable and non-ASCII glyphs" { var old: Cell = .{ .default = false }; old.text[0] = 'x'; var style_only = old; style_only.style.bold = true; try std.testing.expectEqual(PanelCellDiff.visual, PanelCellDiff.between(&old, &style_only)); var changed = old; changed.text[0] = 'z'; try std.testing.expectEqual(AsciiDiff{ .from = 'x', .to = 'z' }, PanelCellDiff.between(&old, &changed).ascii); var unicode = old; unicode.text[0..2].* = .{ 0xc3, 0xa9 }; unicode.len = 2; try std.testing.expectEqual(PanelCellDiff.visual, PanelCellDiff.between(&old, &unicode)); } pub const ImageCacheKey = if (pdf_enabled) struct { serial: u32, page: u32, revision: u32, pub fn eql(a: @This(), b: @This()) bool { return a.serial == b.serial and a.page == b.page and a.revision == b.revision; } } else struct { serial: u32, pub fn eql(a: @This(), b: @This()) bool { return a.serial == b.serial; } }; const PdfFitMode = panes.Pdf.FitMode; const PdfTintMode = panes.Pdf.TintMode; /// Dynamic placement exists only for native PDF pages. Static image panes need /// only their pane identity, so feature-off builds carry a zero-bit payload. pub const NativePlacement = if (pdf_enabled) struct { revision: u32 = 0, page: u32 = 0, fit: image.NativeFit = .contain, pan_x: u16 = 0, pan_y: u16 = 0, /// Continuous-document pages supply their already-clipped geometry. /// Static images and legacy callers leave this null and derive fit/pan. geometry: ?image.NativeGeometry = null, /// Subpixel vertical displacement retained from proportional SDL wheel dy. pixel_offset_y: f32 = 0, } else struct {}; pub const ImagePlace = struct { pane: u8, /// Pane slots are reused. This identity makes a cached GPU texture or /// kitty image unambiguously belong to the pane which supplied the bytes. serial: u32, native: NativePlacement = .{}, x: u16, y: u16, w: u16, h: u16, rgba: []const u8, iw: usize, ih: usize, pub fn cacheKey(place: ImagePlace) ImageCacheKey { if (comptime pdf_enabled) return .{ .serial = place.serial, .page = place.native.page, .revision = place.native.revision, }; return .{ .serial = place.serial }; } }; test "pixel attachment cache key follows both pane lifetime and rendered revision" { if (comptime pdf_enabled) { const first = ImageCacheKey{ .serial = 41, .page = 7, .revision = 3 }; try std.testing.expect(first.eql(.{ .serial = 41, .page = 7, .revision = 3 })); try std.testing.expect(!first.eql(.{ .serial = 41, .page = 8, .revision = 3 })); try std.testing.expect(!first.eql(.{ .serial = 41, .page = 7, .revision = 4 })); try std.testing.expect(!first.eql(.{ .serial = 42, .page = 7, .revision = 3 })); } else { const first = ImageCacheKey{ .serial = 41 }; try std.testing.expect(first.eql(.{ .serial = 41 })); try std.testing.expect(!first.eql(.{ .serial = 42 })); } try std.testing.expectEqual(pdf_enabled, @hasField(ImageCacheKey, "revision")); try std.testing.expectEqual(pdf_enabled, @hasField(NativePlacement, "fit")); try std.testing.expectEqual(pdf_enabled, @hasField(CellPixels, "w")); if (comptime !pdf_enabled) { try std.testing.expectEqual(@as(usize, 4), @sizeOf(ImageCacheKey)); try std.testing.expectEqual(@as(usize, 0), @sizeOf(NativePlacement)); try std.testing.expectEqual(@as(usize, 0), @sizeOf(CellPixels)); try std.testing.expectEqual(@as(usize, 0), @sizeOf(panes.Pdf.PointerDrag)); } } test "pixel attachment fit and pan do not invalidate cached pixels" { if (comptime !pdf_enabled) return; var place = ImagePlace{ .pane = 0, .serial = 41, .native = .{ .revision = 3 }, .x = 0, .y = 0, .w = 1, .h = 1, .rgba = &.{ 0, 0, 0, 255 }, .iw = 1, .ih = 1, }; const key = place.cacheKey(); place.native.fit = .width; place.native.pan_x = 1234; place.native.pan_y = 65535; try std.testing.expect(key.eql(place.cacheKey())); } /// The canonical interface: what a frame of pardes IS. The tty shell writes /// these cells to vaxis one-to-one; the SDL shells rasterize them. pub const Surface = struct { pub const PointerShape = enum(u8) { arrow, link }; pointer_shape: PointerShape = .arrow, body_layers: [MAX_PANES]BodyLayer = @splat(.{}), tag_layers: [MAX_TAG_LAYERS]TagLayer = @splat(.{}), cols: u16 = 0, rows: u16 = 0, cells: []Cell = &.{}, /// bar: draw an insert-style thin cursor instead of the block cursor: ?struct { x: u16, y: u16, bar: bool = false } = null, images: []?ImagePlace = &.{}, nimages: usize = 0, panel_tracks: [MAX_PANES * 2]layout.Track = undefined, npanel_tracks: usize = 0, previous_cells: []const Cell = &.{}, previous_body_layers: []const BodyLayer = &.{}, previous_tag_layers: []const TagLayer = &.{}, cell_diffs: []const PanelCellDiff = &.{}, pub fn tagLayers(s: *const Surface) []const TagLayer { return &s.tag_layers; } pub fn bodyLayers(s: *const Surface) []const BodyLayer { return &s.body_layers; } pub fn panelTracks(s: *const Surface) []const layout.Track { return s.panel_tracks[0..s.npanel_tracks]; } pub fn hasPanelDiff(s: *const Surface) bool { return s.previous_cells.len == s.cells.len and s.cell_diffs.len == s.cells.len; } pub fn panelCellChanged(s: *const Surface, x: u16, y: u16) bool { if (!s.hasPanelDiff() or x >= s.cols or y >= s.rows) return false; return s.cell_diffs[@as(usize, y) * s.cols + x].changed(); } pub fn panelCellDiff(s: *const Surface, x: u16, y: u16) PanelCellDiff { if (!s.hasPanelDiff() or x >= s.cols or y >= s.rows) return .unchanged; return s.cell_diffs[@as(usize, y) * s.cols + x]; } pub fn at(s: *Surface, x: u16, y: u16) *Cell { std.debug.assert(x < s.cols and y < s.rows); return &s.cells[@as(usize, y) * s.cols + x]; } pub fn set(s: *Surface, x: u16, y: u16, text: []const u8, style: CellStyle) void { const c = s.at(x, y); std.debug.assert(text.len <= c.text.len); @memcpy(c.text[0..text.len], text); c.len = @intCast(text.len); c.style = style; c.default = false; } fn print(s: *Surface, x: u16, y: u16, w: u16, text: []const u8, style: CellStyle) u16 { var col = x; const end = x + w; var i: usize = 0; while (i < text.len) { if (col >= end) break; { const b = text[i]; if (b >= 0x20 and b < 0x7f and (i + 1 == text.len or text[i + 1] < 0x80)) { s.set(col, y, text[i .. i + 1], style); i += 1; col += 1; continue; } } if (col >= end) break; // n == 0: not a start byte at all. A short tail or a bad // continuation decodes to null the same way — one U+FFFD, one byte. const n = std.unicode.utf8ByteSequenceLength(text[i]) catch 0; const decoded: ?u21 = if (n > 0 and i + n <= text.len) (std.unicode.utf8Decode(text[i .. i + n]) catch null) else null; var cp_slice: []const u8 = "\u{FFFD}"; var consumed: usize = 1; if (decoded != null) { var git = uucode.grapheme.utf8Iterator(text[i..]); if (git.nextGrapheme()) |g| { const candidate = text[i .. i + g.end]; if (std.unicode.utf8ValidateSlice(candidate)) { cp_slice = candidate; consumed = candidate.len; } else { cp_slice = text[i .. i + n]; consumed = n; } } } i += consumed; var cp = decoded orelse 0xFFFD; if (cp == '\r') continue; if (cp == '\t') { const spaces = @min(config.tab_width, end - col); s.fill(col, y, spaces, 1, style); col += spaces; continue; } // No other C0/C1 control or DEL reaches a font fallback either. if (cp < ' ' or cp == 0x7f or (cp >= 0x80 and cp <= 0x9f)) { cp = 0xFFFD; cp_slice = "\u{FFFD}"; } const width: u16 = if (decoded == null or (cp < 0x80 and cp_slice.len == 1)) 1 else @max(1, vaxis.gwidth.gwidth(cp_slice, .unicode)); if (width == 2 and col + 1 >= end) break; var shown = cp_slice; if (shown.len > @typeInfo(@FieldType(Cell, "text")).array.len) { const cap = @typeInfo(@FieldType(Cell, "text")).array.len; var prefix: usize = 0; while (prefix < shown.len) { const cp_len = std.unicode.utf8ByteSequenceLength(shown[prefix]) catch break; if (prefix + cp_len > cap) break; prefix += cp_len; } shown = if (prefix > 0) shown[0..prefix] else "\u{FFFD}"; } s.set(col, y, shown, style); if (width == 2 and col + 1 < end) { // spacer: empty cell under the wide glyph's tail s.set(col + 1, y, "", style); } col += width; } return col; } pub fn fill(s: *Surface, x: u16, y: u16, w: u16, h: u16, style: CellStyle) void { var yy = y; while (yy < y + h) : (yy += 1) { var xx = x; while (xx < x + w) : (xx += 1) s.set(xx, yy, " ", style); } } fn clearRect(s: *Surface, x: u16, y: u16, w: u16, h: u16) void { var yy = y; while (yy < y + h) : (yy += 1) { var xx = x; while (xx < x + w) : (xx += 1) s.at(xx, yy).* = .{}; } } /// swap ONLY the glyph, keeping the cell's colors — the resize-handle hint fn overlayDash(s: *Surface, x: u16, y: u16, glyph: []const u8) void { const c = s.at(x, y); @memcpy(c.text[0..glyph.len], glyph); c.len = @intCast(glyph.len); c.default = false; } }; test "surface print expands configured tabs and normalizes other controls" { const tab_cells: usize = @as(usize, config.tab_width) * 2; const replacement_cells = 5; const cell_count = tab_cells + replacement_cells + 1; var cells: [cell_count]Cell = @splat(.{}); var surface = Surface{ .cols = cells.len, .rows = 1, .cells = &cells }; const end = surface.print(0, 0, cells.len, "\t\t\x00\x0b\x0c\r\x7f\xc2\x85A", .{}); try std.testing.expectEqual(@as(u16, cells.len), end); for (cells[0..tab_cells]) |cell| try std.testing.expectEqualStrings(" ", cell.grapheme()); for (cells[tab_cells .. tab_cells + replacement_cells]) |cell| try std.testing.expectEqualStrings("\u{FFFD}", cell.grapheme()); try std.testing.expectEqualStrings("A", cells[cell_count - 1].grapheme()); } test "surface print keeps combining and wide graphemes in their display cells" { var cells: [8]Cell = @splat(.{}); var surface = Surface{ .cols = cells.len, .rows = 1, .cells = &cells }; const end = surface.print(0, 0, cells.len, "e\u{301}界👩🏽\u{200d}🚀1\u{fe0f}\u{20e3}A", .{}); try std.testing.expectEqual(@as(u16, 8), end); try std.testing.expectEqualStrings("e\u{301}", cells[0].grapheme()); try std.testing.expectEqualStrings("界", cells[1].grapheme()); try std.testing.expectEqualStrings("", cells[2].grapheme()); try std.testing.expectEqualStrings("👩", cells[3].grapheme()); try std.testing.expectEqualStrings("", cells[4].grapheme()); try std.testing.expectEqualStrings("1\u{fe0f}\u{20e3}", cells[5].grapheme()); try std.testing.expectEqualStrings("", cells[6].grapheme()); try std.testing.expectEqualStrings("A", cells[7].grapheme()); } test "the ASCII fast path in surface print paints what the general arm paints" { const ref = struct { fn print(s: *Surface, x: u16, y: u16, w: u16, text: []const u8, style: CellStyle) u16 { var col = x; const end = x + w; var i: usize = 0; while (i < text.len) { if (col >= end) break; const n = std.unicode.utf8ByteSequenceLength(text[i]) catch 0; const decoded: ?u21 = if (n > 0 and i + n <= text.len) (std.unicode.utf8Decode(text[i .. i + n]) catch null) else null; var cp_slice: []const u8 = "\u{FFFD}"; var consumed: usize = 1; if (decoded != null) { var git = uucode.grapheme.utf8Iterator(text[i..]); if (git.nextGrapheme()) |g| { const candidate = text[i .. i + g.end]; if (std.unicode.utf8ValidateSlice(candidate)) { cp_slice = candidate; consumed = candidate.len; } else { cp_slice = text[i .. i + n]; consumed = n; } } } i += consumed; var cp = decoded orelse 0xFFFD; if (cp == '\r') continue; if (cp == '\t') { const spaces = @min(config.tab_width, end - col); s.fill(col, y, spaces, 1, style); col += spaces; continue; } if (cp < ' ' or cp == 0x7f or (cp >= 0x80 and cp <= 0x9f)) { cp = 0xFFFD; cp_slice = "\u{FFFD}"; } const width: u16 = if (decoded == null or (cp < 0x80 and cp_slice.len == 1)) 1 else @max(1, vaxis.gwidth.gwidth(cp_slice, .unicode)); if (width == 2 and col + 1 >= end) break; var shown = cp_slice; if (shown.len > @typeInfo(@FieldType(Cell, "text")).array.len) { const cap = @typeInfo(@FieldType(Cell, "text")).array.len; var prefix: usize = 0; while (prefix < shown.len) { const cp_len = std.unicode.utf8ByteSequenceLength(shown[prefix]) catch break; if (prefix + cp_len > cap) break; prefix += cp_len; } shown = if (prefix > 0) shown[0..prefix] else "\u{FFFD}"; } s.set(col, y, shown, style); if (width == 2 and col + 1 < end) s.set(col + 1, y, "", style); col += width; } return col; } }.print; const H = struct { fn check(text: []const u8) !void { // Every width from 0 past the end, because clipping interacts with the fast path: the // wide-glyph bail at the right edge is only reachable from the general arm. var w: u16 = 0; while (w <= text.len + 4) : (w += 1) { var fast_cells: [64]Cell = @splat(.{}); var slow_cells: [64]Cell = @splat(.{}); var fast = Surface{ .cols = fast_cells.len, .rows = 1, .cells = &fast_cells }; var slow = Surface{ .cols = slow_cells.len, .rows = 1, .cells = &slow_cells }; const got = fast.print(0, 0, w, text, .{}); const want = ref(&slow, 0, 0, w, text, .{}); std.testing.expectEqual(want, got) catch |e| { std.debug.print("print end column: {any} w={d}\n", .{ text, w }); return e; }; for (fast_cells, slow_cells, 0..) |f, sc, col| { std.testing.expectEqualStrings(sc.grapheme(), f.grapheme()) catch |e| { std.debug.print("print cell {d}: {any} w={d}\n", .{ col, text, w }); return e; }; try std.testing.expectEqual(sc.default, f.default); } } } }; const neighbours = [_][]const u8{ "", "x", "\u{301}", "\u{200d}\u{1f680}", "\u{903}", "\u{fe0f}", "\u{20e3}", "\u{4e16}", "\u{1f642}", "\xff", "\xe4\xb8", "\xe4\x28\xb8", "\u{1f1e6}\u{1f1e7}", }; // Every byte 0x20..0x7e takes the fast path; \t, \r, the rest of the C0 controls and DEL are // excluded by its range test and must keep the general arm's tab expansion and U+FFFD. var buf: [16]u8 = undefined; var b: u8 = 0; while (b < 0x80) : (b += 1) { buf[0] = b; for (neighbours) |tail| { @memcpy(buf[1..][0..tail.len], tail); try H.check(buf[0 .. 1 + tail.len]); @memcpy(buf[0..tail.len], tail); buf[tail.len] = b; try H.check(buf[0 .. tail.len + 1]); } } // Runs long enough that the fast path starts, hands over and restarts inside one call. try H.check("ascii \u{4e16}\u{754c} e\u{301} \xff ok\t\r!"); try H.check("plain"); } test "insert and normal modes edit complete Unicode graphemes" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .cols = 60, .rows = 12 }); defer p.deinit(); const pane = try p.setTestFile("a" ++ "e\u{301}" ++ "👩🏽\u{200d}🚀" ++ "界" ++ "z"); pane.mode = .insert; pane.cur_col = 22; // on z, after the CJK grapheme p.insertKey(pane, .{ .cp = Key.left }); try std.testing.expectEqual(@as(i32, 19), pane.cur_col); p.insertKey(pane, .{ .cp = Key.left }); try std.testing.expectEqual(@as(i32, 4), pane.cur_col); p.insertKey(pane, .{ .cp = Key.right }); try std.testing.expectEqual(@as(i32, 19), pane.cur_col); p.insertKey(pane, .{ .cp = Key.backspace }); try std.testing.expectEqualStrings("ae\u{301}界z", pane.file.?.content); try std.testing.expectEqual(@as(i32, 4), pane.cur_col); pane.mode = .normal; pane.cur_col = 1; p.normalDelete(pane, false); try std.testing.expectEqualStrings("a界z", pane.file.?.content); try std.testing.expectEqual(@as(i32, 1), pane.cur_col); p.normalReplaceChar(pane, 'λ'); try std.testing.expectEqualStrings("aλz", pane.file.?.content); panes.File.setContent(p, &pane.file.?, try gpa.dupe(u8, "界a")); pane.cur_col = 3; pane.vsel = .{ .active = true, .row = 0, .col = 0, .explicit = true }; p.normalReplaceChar(pane, 'λ'); try std.testing.expectEqualStrings("λλ", pane.file.?.content); try std.testing.expectEqual(@as(i32, 2), pane.cur_col); try std.testing.expectEqual(@as(i32, 0), pane.vsel.col); panes.File.setContent(p, &pane.file.?, try gpa.dupe(u8, "界a")); pane.cur_col = 0; pane.vsel = .{ .active = true, .row = 0, .col = 3, .explicit = true }; p.normalReplaceChar(pane, 'λ'); try std.testing.expectEqualStrings("λλ", pane.file.?.content); try std.testing.expectEqual(@as(i32, 0), pane.cur_col); try std.testing.expectEqual(@as(i32, 2), pane.vsel.col); } test "flat text movement and selection replay need no scratch rows" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 60, .rows = 12 }); defer p.deinit(); const row_text = "one (two) λ\r\n"; const text = try gpa.alloc(u8, 4096 * row_text.len); defer gpa.free(text); for (0..4096) |row| @memcpy(text[row * row_text.len ..][0..row_text.len], row_text); const pane = try p.setTestFile(text); _ = try panes.File.lineIndex(gpa, &pane.file.?); var denied = std.testing.FailingAllocator.init(gpa, .{ .fail_index = 0 }); p.scratch.deinit(); p.scratch = .init(denied.allocator()); pane.cur_row = 4094; p.handleNormal(pane, .{ .cp = 'l' }); try std.testing.expectEqual(@as(i32, 1), pane.cur_col); p.handleNormal(pane, .{ .cp = 'j' }); try std.testing.expectEqual(@as(i32, 4095), pane.cur_row); try std.testing.expectEqual(@as(i32, 1), pane.cur_col); p.handleNormal(pane, .{ .cp = 'h' }); try std.testing.expectEqual(@as(i32, 0), pane.cur_col); p.executeNormalAction(pane, .{ .textobject = .{ .char = 'w', .around = false } }); try std.testing.expectEqual(@as(i32, 2), pane.cur_col); try std.testing.expectEqual(@as(i32, 0), pane.vsel.col); pane.vsel.active = false; pane.cur_row = 1024; pane.cur_col = 0; pane.nsel = 1; pane.sels[0] = .{ .row = 3072, .col = 0, .arow = 3072, .acol = 0 }; p.handleNormal(pane, .{ .cp = 'l' }); p.handleNormal(pane, .{ .cp = 'j' }); try std.testing.expectEqual(@as(u8, 1), pane.nsel); try std.testing.expectEqual(@as(i32, 1025), pane.cur_row); try std.testing.expectEqual(@as(i32, 1), pane.cur_col); try std.testing.expectEqual(@as(i32, 3073), pane.sels[0].row); try std.testing.expectEqual(@as(i32, 1), pane.sels[0].col); try std.testing.expect(!denied.has_induced_failure); try std.testing.expectEqual(@as(usize, 0), p.scratch.queryCapacity()); pane.nsel = 0; pane.cur_col = 4; p.executeNormalAction(pane, .match_bracket); try std.testing.expect(denied.has_induced_failure); try std.testing.expectEqual(@as(i32, 4), pane.cur_col); denied.fail_index = std.math.maxInt(usize); p.executeNormalAction(pane, .match_bracket); try std.testing.expectEqual(@as(i32, 8), pane.cur_col); } test "flat text replacement preserves a terminal fragment origin and direction" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 60, .rows = 12 }); defer p.deinit(); p.update(.{ .output = .{ .pane = 0, .bytes = "first\r\nsecond\r\nthird\r\n界a\r\n" } }); const pane = p.panes[0].?; pane.mode = .normal; pane.cur_pinned = true; pane.cur_row = 3; pane.cur_col = 0; pane.vsel = .{ .active = true, .row = 3, .col = 3, .explicit = true }; p.normalReplaceChar(pane, 'λ'); try std.testing.expectEqual(@as(i32, 3), pane.ovl.?.row); try std.testing.expectEqualStrings("λλ", pane.ovl.?.text); try std.testing.expectEqual(@as(i32, 3), pane.cur_row); try std.testing.expectEqual(@as(i32, 0), pane.cur_col); try std.testing.expectEqual(@as(i32, 3), pane.vsel.row); try std.testing.expectEqual(@as(i32, 2), pane.vsel.col); try std.testing.expect(pane.vsel.active and pane.vsel.explicit); const lines = try p.paneCursorLines(pane); try std.testing.expectEqualStrings("first", lines[0]); try std.testing.expectEqualStrings("λλ", lines[3]); const flat = try p.flatSurface(pane); try std.testing.expect(std.mem.startsWith(u8, flat, "first\nsecond\nthird\nλλ\n")); } test "Unicode display cells map back to body and tag byte cursors" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .cols = 60, .rows = 12 }); defer p.deinit(); const pane = try p.setTestFile("a界e\u{301}z\n"); const f = &pane.file.?; p.gpa.free(f.path); f.path = try p.gpa.dupe(u8, "界e\u{301}.txt"); var frame = std.heap.ArenaAllocator.init(gpa); defer frame.deinit(); _ = try p.render(frame.allocator()); const rect = p.rects[0]; const text_x = rect.x + config.GUTTER + config.PREFIX_W; const body_y = rect.y + BOX_H; // The second screen cell is the trailing half of the wide CJK grapheme. // Both halves map to its one byte boundary. p.update(.{ .mouse = .{ .button = config.select_button, .kind = .press, .col = text_x + 2, .row = body_y } }); p.update(.{ .mouse = .{ .button = config.select_button, .kind = .release, .col = text_x + 2, .row = body_y } }); try std.testing.expectEqual(@as(i32, 1), pane.cur_col); // A click in the wide path glyph likewise becomes a byte cursor at the // grapheme start; arrow motion then advances by the full UTF-8 cluster. p.enterTagEdit(pane, 1); try std.testing.expectEqual(@as(u16, 0), pane.tag_col); p.tagInsertKey(pane, .{ .cp = Key.right }); try std.testing.expectEqual(@as(u16, 3), pane.tag_col); p.tagInsertKey(pane, .{ .cp = Key.right }); try std.testing.expectEqual(@as(u16, 6), pane.tag_col); const before = try gpa.dupe(u8, pane.tagSlice()); defer gpa.free(before); p.enterTagEdit(pane, -1); const insertion = pane.tag_col; p.tagInsertKey(pane, .{ .cp = 'λ', .text = "λ" }); try std.testing.expectEqual(insertion + 2, pane.tag_col); p.tagInsertKey(pane, .{ .cp = Key.backspace }); try std.testing.expectEqual(insertion, pane.tag_col); try std.testing.expectEqualStrings(before, pane.tagSlice()); } test "tabbed file aligns syntax cursor and mouse at the nearest insertion gap" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .cols = 60, .rows = 12 }); defer p.deinit(); // Bold is an explicit preference; use it to expose the glyph boundaries. try std.testing.expect(p.executeBuiltinLine(0, "SyntaxBold")); const pane = try p.setTestFile("\treturn x\n"); const f = &pane.file.?; f.highlights = try p.tree_sitter_gpa.alloc(u8, f.content.len); @memset(f.highlights, @intFromEnum(syntax.Syn.none)); @memset(f.highlights[1..7], @intFromEnum(syntax.Syn.keyword)); f.syntax_dirty = false; pane.cur_row = 0; pane.cur_col = 1; var frame = std.heap.ArenaAllocator.init(gpa); defer frame.deinit(); const surface = try p.render(frame.allocator()); const rect = p.rects[0]; const text_x = rect.x + config.GUTTER + config.PREFIX_W; const body_y = rect.y + BOX_H; for (0..config.tab_width) |col| try std.testing.expect(!surface.at(text_x + @as(u16, @intCast(col)), body_y).style.bold); for (0.."return".len) |col| try std.testing.expect(surface.at(text_x + config.tab_width + @as(u16, @intCast(col)), body_y).style.bold); try std.testing.expectEqual(text_x + config.tab_width, surface.cursor.?.x); const click_x = text_x + config.tab_width + 2; p.update(.{ .mouse = .{ .button = config.select_button, .kind = .press, .col = click_x, .row = body_y } }); p.update(.{ .mouse = .{ .button = config.select_button, .kind = .release, .col = click_x, .row = body_y } }); try std.testing.expectEqual(@as(i32, 3), pane.cur_col); const line = modal.lineSlice(f.content, 0); const virtual: u16 = 3; const blank_x = text_x + @as(u16, @intCast(panes.File.displayWidth(line))) + virtual; p.update(.{ .mouse = .{ .button = config.select_button, .kind = .press, .col = blank_x, .row = body_y } }); p.update(.{ .mouse = .{ .button = config.select_button, .kind = .release, .col = blank_x, .row = body_y } }); try std.testing.expectEqual(@as(i32, @intCast(line.len)), pane.cur_col); _ = frame.reset(.retain_capacity); const virtual_surface = try p.render(frame.allocator()); try std.testing.expectEqual(blank_x - virtual, virtual_surface.cursor.?.x); const local_blank: i32 = @intCast(blank_x - rect.x - config.GUTTER); try std.testing.expect(p.expandedSel(pane, .{ .state = .dragging, .c0 = local_blank, .c1 = local_blank, .r0 = BOX_H, .r1 = BOX_H }) == null); // The immediately-adjacent EOL cell and an in-line separator are blank // too. Pointer expansion must not lean left into the preceding word. const adjacent: i32 = @intCast(config.PREFIX_W + panes.File.displayWidth(line)); try std.testing.expect(p.expandedSel(pane, .{ .state = .dragging, .c0 = adjacent, .c1 = adjacent, .r0 = BOX_H, .r1 = BOX_H }) == null); const separator: i32 = @intCast(config.PREFIX_W + config.tab_width + "return".len); try std.testing.expect(p.expandedSel(pane, .{ .state = .dragging, .c0 = separator, .c1 = separator, .r0 = BOX_H, .r1 = BOX_H }) == null); } test "Look hover waits without mutating the pane and input cancels it" { const delay = config.look_preview_delay_frames orelse return; try std.testing.expect(delay > 0); const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .cols = 60, .rows = 12, .tty_only = true }); defer p.deinit(); while (p.nextEffect()) |_| {} const pane = try p.setTestFile("alpha beta gamma\n"); while (p.nextEffect()) |_| {} const other_id = p.freeSlot() orelse return error.NoSparePaneForHoverTest; _ = try p.newShell(other_id, ""); try std.testing.expect(layout.splitColumn(p, 0, other_id, false)); p.sync(); while (p.nextEffect()) |_| {} var frame = std.heap.ArenaAllocator.init(gpa); defer frame.deinit(); _ = try p.render(frame.allocator()); const rect = p.rects[p.active]; const word_x = rect.x + config.GUTTER + config.PREFIX_W + 7; const body_y = if (p.settings.tag_bottom) rect.y else rect.y + BOX_H; const motion = Event{ .mouse = .{ .button = .none, .kind = .motion, .col = word_x, .row = body_y } }; const selections_before = pane.sel; const active_before = p.active; const cursor_before = .{ pane.cur_row, pane.cur_col }; const jumps_before = .{ p.njumps, p.jcur, p.n_look_src }; const effects_before = p.effects_len; p.update(motion); try std.testing.expect(p.look_hover_wait != null); try std.testing.expect(p.look_hover_preview == null); try std.testing.expect(p.animationActive()); var ticks: u16 = 0; while (ticks + 1 < delay) : (ticks += 1) { p.update(.tick); // Repeated motion reports for one cell are noise, not a fresh delay. if (ticks == delay / 2) p.update(motion); try std.testing.expect(p.look_hover_preview == null); } p.update(.tick); const preview = p.look_hover_preview orelse return error.MissingLookHoverPreview; const preview_sel = preview.sel orelse return error.MissingLookHoverSelection; try std.testing.expectEqual(.done, preview_sel.state); try std.testing.expectEqual(@as(i32, config.PREFIX_W + 6), preview_sel.c0); try std.testing.expectEqual(@as(i32, config.PREFIX_W + 9), preview_sel.c1); try std.testing.expect(!p.animationActive()); // Resolving and displaying the operand is observational only. try std.testing.expect(std.meta.eql(selections_before, pane.sel)); try std.testing.expectEqual(active_before, p.active); try std.testing.expectEqual(cursor_before, .{ pane.cur_row, pane.cur_col }); try std.testing.expectEqual(jumps_before, .{ p.njumps, p.jcur, p.n_look_src }); try std.testing.expectEqual(effects_before, p.effects_len); _ = frame.reset(.retain_capacity); const surface = try p.render(frame.allocator()); const expected_bg = mix(p.theme().bg orelse p.theme().tag_bg, mix(p.theme().bg orelse p.theme().tag_bg, p.theme().sel_bg)); try std.testing.expectEqual(Color{ .rgb = expected_bg }, surface.at(word_x, body_y).style.bg); // Background traffic in another pane does not change the text or geometry // under this pointer and must not strand a stationary hover unarmed. p.update(.{ .output = .{ .pane = @intCast(other_id), .bytes = "busy\r\n" } }); try std.testing.expect(p.look_hover_preview != null); const seam_x = p.col_x[0] + p.col_w[0] - 1; p.update(.{ .mouse = .{ .button = .none, .kind = .motion, .col = seam_x, .row = body_y } }); _ = frame.reset(.retain_capacity); const hovered_seam = try p.render(frame.allocator()); try std.testing.expectEqualStrings("╎", hovered_seam.at(seam_x, body_y).grapheme()); p.update(.pointer_leave); try std.testing.expect(p.look_hover_wait == null); try std.testing.expect(p.look_hover_preview == null); _ = frame.reset(.retain_capacity); const left_seam = try p.render(frame.allocator()); try std.testing.expect(!std.mem.eql(u8, "╎", left_seam.at(seam_x, body_y).grapheme())); const blank_x = rect.x + config.GUTTER + config.PREFIX_W + 30; p.update(.{ .mouse = .{ .button = .none, .kind = .motion, .col = blank_x, .row = body_y } }); for (0..delay) |_| p.update(.tick); try std.testing.expect(p.look_hover_wait == null); try std.testing.expect(p.look_hover_preview == null); try std.testing.expect(!p.animationActive()); // File PREFIX_W belongs only to body line numbers. A preview on the tag's // first character must be paintable all the way at tag column zero. const tag_y = if (p.settings.tag_bottom) rect.y + rect.h - BOX_H else rect.y; p.look_hover_preview = .{ .col = rect.x + config.GUTTER, .row = tag_y, .pane = p.active, .serial = pane.serial, .sel = .{ .state = .done, .c0 = 0, .c1 = 0, .r0 = 0, .r1 = 0 }, }; _ = frame.reset(.retain_capacity); const tag_surface = try p.render(frame.allocator()); try std.testing.expectEqual(Color{ .rgb = expected_bg }, tag_surface.at(rect.x + config.GUTTER, tag_y).style.bg); p.look_hover_preview = null; p.update(motion); try std.testing.expect(p.look_hover_wait != null); p.update(.{ .key = .{ .cp = Key.escape } }); try std.testing.expect(p.look_hover_wait == null); try std.testing.expect(p.look_hover_preview == null); // The following host repaint must not resurrect a hover after keyboard // input cancelled its raw-pointer intent. p.acknowledgePanelPresentation(&.{}); try std.testing.expect(p.look_hover_wait == null); } test "cwd changes cancel only the affected pane's Look hover" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .shells = 3 }); defer p.deinit(); const first = p.panes[0] orelse return error.MissingFirstPane; const other = p.panes[1] orelse return error.MissingOtherPane; p.setCwd(0, "/work/first"); p.setCwd(1, "/work/other"); p.look_hover_wait = .{ .col = 1, .row = 1, .pane = 0, .serial = first.serial }; // Hosts poll cwd every frame. An unchanged answer is observational and a // change reported for another pane cannot invalidate this pane's operand. p.setCwd(0, "/work/first"); try std.testing.expect(p.look_hover_wait != null); p.setCwd(1, "/work/elsewhere"); try std.testing.expect(p.look_hover_wait != null); try std.testing.expectEqual(other.serial, p.panes[1].?.serial); p.setCwd(0, "/work/changed"); try std.testing.expect(p.look_hover_wait == null); try std.testing.expect(p.look_hover_preview == null); } test "plain left click clears explicit modal selection" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .cols = 40, .rows = 10 }); defer p.deinit(); const pane = try p.setTestFile("abcdef\n"); pane.cur_row = 0; pane.cur_col = 5; pane.vsel = .{ .active = true, .row = 0, .col = 1, .explicit = true }; pane.msel = .{ .active = true, .r0 = 0, .r1 = 0 }; var frame = std.heap.ArenaAllocator.init(gpa); defer frame.deinit(); _ = try p.render(frame.allocator()); const rect = p.rects[0]; const click_x = rect.x + config.GUTTER + config.PREFIX_W + 3; const body_y = rect.y + BOX_H; p.update(.{ .mouse = .{ .button = config.select_button, .kind = .press, .col = click_x, .row = body_y } }); p.update(.{ .mouse = .{ .button = config.select_button, .kind = .release, .col = click_x, .row = body_y } }); try std.testing.expectEqual(@as(i32, 3), pane.cur_col); try std.testing.expect(!pane.vsel.active); try std.testing.expect(!pane.msel.active); try std.testing.expectEqual(.none, pane.sel[@intFromEnum(config.select_button)].state); } test "selection drag and queued release do not enter panel pending state" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 50, .rows = 10, .tty_only = true }); defer p.deinit(); const pane = try p.setTestFile("alpha beta\n"); p.acknowledgePanelPresentation(&.{}); const rect = p.rects[0]; const x = rect.x + config.GUTTER + config.PREFIX_W + 1; const y = rect.y + BOX_H; p.update(.{ .mouse = .{ .button = config.select_button, .kind = .press, .col = x, .row = y } }); p.update(.{ .mouse = .{ .button = config.select_button, .kind = .drag, .col = x + 2, .row = y } }); try std.testing.expect(!p.presentation.pending); p.update(.{ .mouse = .{ .button = config.select_button, .kind = .release, .col = x + 2, .row = y } }); try std.testing.expect(p.drag == .none); try std.testing.expectEqual(.done, pane.sel[sel_slot].state); } test "editable workspace and column tags replace selections and persist" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 20, .tty_only = true }); defer p.deinit(); _ = try p.setTestFile("untouched\n"); p.acknowledgePanelPresentation(&.{}); try std.testing.expectEqual(TOPBAR_H + COLBAR_H, p.rects[0].y); p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = 0, .row = 0 } }); p.update(.{ .mouse = .{ .button = .left, .kind = .drag, .col = 6, .row = 0 } }); p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = 6, .row = 0 } }); p.update(.{ .key = .{ .cp = 'H', .text = "Help" } }); try std.testing.expect(std.mem.startsWith(u8, p.global_tag.slice(""), "Help Joincol")); p.update(.{ .key = .{ .cp = Key.escape } }); p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = config.GUTTER, .row = TOPBAR_H } }); p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = config.GUTTER, .row = TOPBAR_H } }); p.update(.{ .key = .{ .cp = 'é', .text = "é" } }); try std.testing.expect(std.mem.startsWith(u8, p.columnTag(0), "éNew")); p.update(.{ .key = .{ .cp = Key.backspace } }); try std.testing.expect(std.mem.startsWith(u8, p.columnTag(0), "New")); p.update(.{ .paste = "Grep " }); try std.testing.expect(std.mem.startsWith(u8, p.columnTag(0), "Grep New")); try std.testing.expectEqualStrings("untouched\n", p.panes[0].?.file.?.content); try p.dumpState(); const restored = try p.restore(p.dump_out.?); defer restored.deinit(); try std.testing.expectEqualStrings(p.global_tag.slice(""), restored.global_tag.slice("")); try std.testing.expectEqualStrings(p.columnTag(0), restored.columnTag(0)); try std.testing.expect(p.col_tags[0].set("")); try p.dumpState(); const empty = try p.restore(p.dump_out.?); defer empty.deinit(); try std.testing.expectEqualStrings("", empty.columnTag(0)); } test "column tags follow their column through split and close" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 120, .rows = 20, .tty_only = true }); defer p.deinit(); _ = try p.setTestFile("left\n"); try std.testing.expect(p.col_tags[0].set("Left Find")); _ = p.execute(0, "Newcol"); try std.testing.expectEqual(@as(usize, 2), p.ncol); try std.testing.expectEqualStrings("Left Find", p.columnTag(0)); try std.testing.expect(p.col_tags[1].set("Right Grep")); p.active = p.col_panes[0][0]; layout.joinCol(p); try std.testing.expectEqual(@as(usize, 1), p.ncol); try std.testing.expectEqualStrings("Right Grep", p.columnTag(0)); } test "column tags render edit hover and scroll from the column edge" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 20, .tty_only = true }); defer p.deinit(); _ = try p.setTestFile("untouched\n"); p.newScratchColumn(0); p.sync(); p.acknowledgePanelPresentation(&.{}); var frame = std.heap.ArenaAllocator.init(std.testing.allocator); defer frame.deinit(); for (0..p.ncol) |column| { const x = p.col_x[column] + config.GUTTER; const width = p.col_w[column] - config.GUTTER; const initial = try p.render(frame.allocator()); for ("New", 0..) |byte, offset| try std.testing.expectEqual(byte, initial.at(x + @as(u16, @intCast(offset)), TOPBAR_H).text[0]); p.update(.{ .mouse = .{ .button = .none, .kind = .motion, .col = x, .row = TOPBAR_H } }); const hovered = try p.render(frame.allocator()); try std.testing.expectEqual(Color{ .rgb = p.theme().sel_bg }, hovered.at(x, TOPBAR_H).style.bg); p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = x, .row = TOPBAR_H } }); try std.testing.expectEqual(column, p.header_column.?); try std.testing.expectEqual(@as(u16, 0), p.topbar_col.?); const at_start = try p.render(frame.allocator()); try std.testing.expectEqual(x, at_start.cursor.?.x); p.update(.{ .mouse = .{ .button = .left, .kind = .drag, .col = x + 3, .row = TOPBAR_H } }); p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = x + 3, .row = TOPBAR_H } }); const selected = try p.render(frame.allocator()); for (0..3) |offset| try std.testing.expectEqual(Color{ .rgb = p.theme().sel_bg }, selected.at(x + @as(u16, @intCast(offset)), TOPBAR_H).style.bg); p.update(.{ .key = .{ .cp = 'H', .text = "Help" } }); try std.testing.expect(std.mem.startsWith(u8, p.columnTag(column), "Help Tty")); const edited = try p.render(frame.allocator()); try std.testing.expectEqual(x + 4, edited.cursor.?.x); // Only the text viewport participates in caret reveal. const long: [80]u8 = @splat('a'); try std.testing.expect(p.col_tags[column].set(long[0 .. width - 1])); p.update(.{ .key = .{ .cp = Key.end } }); const at_end = try p.render(frame.allocator()); try std.testing.expectEqual(@as(usize, 0), p.header_scroll); try std.testing.expectEqual(x + width - 1, at_end.cursor.?.x); p.update(.{ .key = .{ .cp = 'b', .text = "b" } }); const revealed = try p.render(frame.allocator()); try std.testing.expectEqual(@as(usize, 1), p.header_scroll); try std.testing.expectEqual(x + width - 1, revealed.cursor.?.x); p.update(.{ .key = .{ .cp = Key.home } }); const home = try p.render(frame.allocator()); try std.testing.expectEqual(@as(usize, 0), p.header_scroll); try std.testing.expectEqual(x, home.cursor.?.x); p.update(.{ .key = .{ .cp = Key.escape } }); p.update(.pointer_leave); _ = frame.reset(.retain_capacity); } try std.testing.expectEqualStrings("untouched\n", p.panes[0].?.file.?.content); } test "column tag first-cell execution keeps New local to the clicked column" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 20, .tty_only = true }); defer p.deinit(); _ = try p.setTestFile("untouched\n"); p.newScratchColumn(0); p.sync(); for (0..2) |column| { p.active = p.col_panes[1 - column][0]; p.acknowledgePanelPresentation(&.{}); const before = p.col_n; p.update(.{ .mouse = .{ .button = .middle, .kind = .press, .col = p.col_x[column] + config.GUTTER, .row = TOPBAR_H } }); p.update(.{ .mouse = .{ .button = .middle, .kind = .release, .col = p.col_x[column] + config.GUTTER, .row = TOPBAR_H } }); try std.testing.expectEqual(before[column] + 1, p.col_n[column]); try std.testing.expectEqual(before[1 - column], p.col_n[1 - column]); try std.testing.expectEqual(column, layout.findPane(p, p.active).?.col); } try std.testing.expectEqualStrings("untouched\n", p.panes[0].?.file.?.content); } test "header clicks beyond text append and long UTF-8 tags reveal their caret" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 50, .rows = 12, .tty_only = true }); defer p.deinit(); _ = try p.setTestFile("body\n"); try std.testing.expect(p.col_tags[0].set("New")); p.acknowledgePanelPresentation(&.{}); p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = 40, .row = TOPBAR_H } }); p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = 40, .row = TOPBAR_H } }); p.update(.{ .key = .{ .cp = 'x', .text = " extended" } }); try std.testing.expectEqualStrings("New extended", p.columnTag(0)); p.update(.{ .paste = " 界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界" }); const frame = try p.render(p.scratch.allocator()); try std.testing.expect(p.header_scroll > 0); try std.testing.expect(frame.cursor != null); try std.testing.expectEqual(TOPBAR_H, frame.cursor.?.y); try std.testing.expect(frame.cursor.?.x < p.screen_w); p.update(.{ .key = .{ .cp = Key.home } }); _ = try p.render(p.scratch.allocator()); try std.testing.expectEqual(@as(usize, 0), p.header_scroll); } test "tiny screens keep pane space before adding a column tag" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 8, .rows = 2, .tty_only = true }); defer p.deinit(); try std.testing.expectEqual(@as(u16, 0), p.columnBarHeight()); try std.testing.expectEqual(TOPBAR_H, p.rects[0].y); _ = try p.render(p.scratch.allocator()); } test "ColumnTags reclaims the row and releases hidden header focus without losing commands" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 60, .rows = 12, .tty_only = true }); defer p.deinit(); const pane = try p.setTestFile("unchanged body\n"); try std.testing.expect(p.col_tags[0].set("New Help")); p.acknowledgePanelPresentation(&.{}); const shown = p.rects[0]; try std.testing.expectEqual(TOPBAR_H + COLBAR_H, shown.y); p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = config.GUTTER, .row = TOPBAR_H } }); p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = config.GUTTER, .row = TOPBAR_H } }); try std.testing.expect(p.header_column != null and p.topbar_col != null); p.update(.{ .command = "ColumnTags" }); try std.testing.expect(!p.settings.column_tags); try std.testing.expectEqual(@as(u16, 0), p.columnBarHeight()); try std.testing.expectEqual(TOPBAR_H, p.rects[0].y); try std.testing.expectEqual(shown.h + COLBAR_H, p.rects[0].h); try std.testing.expect(p.header_column == null and p.topbar_col == null); try std.testing.expect(p.header_anchor == null and !p.header_drag); try std.testing.expectEqualStrings("New Help", p.columnTag(0)); p.update(.{ .command = "ColumnTags" }); try std.testing.expect(p.settings.column_tags); try std.testing.expectEqual(shown, p.rects[0]); p.acknowledgePanelPresentation(&.{}); p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = config.GUTTER, .row = TOPBAR_H } }); p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = config.GUTTER, .row = TOPBAR_H } }); try std.testing.expect(p.header_column != null); p.update(.{ .resize = .{ .cols = 60, .rows = 2 } }); try std.testing.expect(p.settings.column_tags); try std.testing.expectEqual(@as(u16, 0), p.columnBarHeight()); try std.testing.expectEqual(TOPBAR_H, p.rects[0].y); try std.testing.expect(p.header_column == null and p.topbar_col == null); p.update(.{ .resize = .{ .cols = 60, .rows = 12 } }); try std.testing.expectEqual(shown, p.rects[0]); try std.testing.expectEqualStrings("New Help", p.columnTag(0)); try std.testing.expectEqualStrings("unchanged body\n", pane.file.?.content); } test "normal header Enter executes its command rather than searching for it" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 12, .tty_only = true }); defer p.deinit(); _ = try p.setTestFile("body\n"); p.topbar_col = @intCast(std.mem.indexOf(u8, config.topbar_str, "Dump").?); p.header_insert = false; p.update(.{ .key = .{ .cp = Key.enter } }); try std.testing.expect(p.topbar_col == null); try std.testing.expect(p.dump_out != null); try std.testing.expectEqual(@as(usize, 1), p.col_n[0]); } pub const Mouse = struct { /// The host has inverted a compact body layer. Physical col/row still /// route pane chrome; these coordinates address the pane's logical text. pub const BodyHit = struct { serial: u32, row: u16, col: u16, pixel_x: f32, pixel_y: f32, metrics: RowMetrics, }; // sel indexing relies on left/middle/right being 0/1/2 pub const Button = enum { left, middle, right, wheel_up, wheel_down, wheel_left, wheel_right, none, back, forward }; pub const Kind = enum { press, release, motion, drag }; button: Button, kind: Kind, col: u16, row: u16, ctrl: bool = false, body_hit: ?BodyHit = null, tag_hit: ?TagHit = null, }; pub const Key = struct { cp: u21, // codepoint or one of the specials below text: []const u8 = "", ctrl: bool = false, alt: bool = false, shift: bool = false, pub const enter: u21 = 0x0D; pub const escape: u21 = 0x1B; pub const tab: u21 = 0x09; pub const backspace: u21 = 0x7F; // private-use plane for non-text keys, matching no real codepoint pub const up: u21 = 0xF0001; pub const down: u21 = 0xF0002; pub const left: u21 = 0xF0003; pub const right: u21 = 0xF0004; pub const home: u21 = 0xF0005; pub const end: u21 = 0xF0006; pub const page_up: u21 = 0xF0007; pub const page_down: u21 = 0xF0008; pub const delete: u21 = 0xF0009; }; fn hit(key: Key, binding: []const config.Chord) bool { for (binding) |c| { if (key.cp != c.cp or key.ctrl != c.ctrl or key.alt != c.alt) continue; if (c.shift and !key.shift) continue; return true; } return false; } const sel_slot = @intFromEnum(config.select_button); fn isPrefix(key: Key, cp: u21) bool { return key.cp == cp and !key.ctrl and !key.alt; } fn roleBindingName(comptime role: modal.Normal.Role) []const u8 { return switch (role) { .prefix_goto => "goto_prefix", .prefix_view => "view_prefix", .prefix_match => "match_prefix", .prefix_find_fwd => "find_char_fwd", .prefix_find_back => "find_char_back", .prefix_till_fwd => "till_char_fwd", .prefix_till_back => "till_char_back", .prefix_replace => "replace_prefix", .prefix_next => "next_prefix", .prefix_prev => "prev_prefix", else => @tagName(role), }; } fn normalInput(key: Key) modal.Normal.Input { var out: modal.Normal.Input = .{ .cp = key.cp, .ctrl = key.ctrl, .alt = key.alt, }; inline for (std.enums.values(modal.Normal.Role)) |role| { const name = comptime roleBindingName(role); if (!@hasDecl(config, name)) @compileError("normal input role has no config binding: " ++ name); const binding = @field(config, name); const matched = if (@TypeOf(binding) == comptime_int) isPrefix(key, binding) else if (@TypeOf(binding) == []const config.Chord) hit(key, binding) else @compileError("normal input binding has unsupported type: " ++ name); if (matched) out.roles.insert(role); } return out; } /// Pixel dimensions are meaningful only to the native PDF placement path. /// Keep resize events layout-only when that path is not part of the build. pub const CellPixels = if (pdf_enabled) struct { w: u16 = 8, h: u16 = 16, } else struct {}; pub const Event = union(enum) { key: Key, mouse: Mouse, resize: struct { cols: u16, rows: u16, cell_pixels: CellPixels = .{}, row_metrics: ?RowMetrics = null, }, output: struct { pane: u8, bytes: []const u8 }, eof: struct { pane: u8 }, lsp_resp: struct { id: u32, rows: ?[]const u8 }, pipe_resp: struct { id: u32, success: bool, outputs: []const []const u8, failure: ?selection_pipe.Failure = null, }, file_changed: struct { pane: u8, bytes: []const u8 }, paste: []const u8, command: []const u8, /// Native shells may preserve sub-cell wheel distance in physical pixels. /// TTY button events still enter through the ordinary mouse path. pdf_scroll: struct { pane: u8, delta_pixels: f32 }, pinch: f32, touch_scroll: f32, /// The host pointer left the drawable area. Unlike an out-of-range motion, /// this must not clamp onto and preview the final grid cell. pointer_leave, tick, fs_req: filesystem.Req, }; pub const attach_name_max = 256; pub const AttachRequest = struct { pane: u8, name: []const u8 }; pub const PendingWrite = struct { bytes: []u8, off: usize = 0 }; pub const PtySignal = enum(u8) { int, term, hup, quit, kill }; /// IO the core wants done. Payloads are inline (fixed buffers): effects are /// queued values with no lifetime ties back into the core. pub const WatchMode = enum { reconcile, baseline_disk }; pub const effect_path_cap = 256; pub const Effect = union(enum) { spawn: struct { pane: u8, cwd: Buf(effect_path_cap) }, write: struct { pane: u8, bytes: Buf(64) }, resize_pty: struct { pane: u8, cols: u16, rows: u16 }, signal_pty: struct { pane: u8, sig: PtySignal }, open_link: Buf(256), /// write this pane's file content to its path; the shell reads both off /// the core (content is unbounded, effects are fixed-size values) save_file: struct { pane: u8 }, save_text: struct { pane: u8, serial: u32, path: Buf(effect_path_cap) }, write_dump, set_clipboard, read_clipboard, lsp: struct { id: u32, kind: lsp.Kind, pane: u8, offset: u32, arg: Buf(128) }, /// Snapshot the matching request with pipeRequest(id), then run it away /// from the UI/event loop and answer with pipe_resp. pipe: struct { id: u32 }, watch: struct { pane: u8, on: bool, mode: WatchMode = .reconcile }, theme_file: struct { generation: u32, on: bool }, /// Write the build-time theme ring below the per-user config directory. /// The pane receives the completion/error message from the native host. dump_themes: struct { pane: u8 }, fs_reply: filesystem.Reply, attach: struct { pane: u8, name: Buf(attach_name_max) }, detach: struct { pane: u8 }, quit, fn Buf(comptime n: usize) type { return struct { data: [n]u8 = undefined, len: u16 = 0, pub const cap = n; pub fn slice(b: *const @This()) []const u8 { return b.data[0..b.len]; } pub fn from(text: []const u8) @This() { var b: @This() = .{}; std.debug.assert(text.len <= n); @memcpy(b.data[0..text.len], text); b.len = @intCast(text.len); return b; } }; } }; const LookHoverWait = struct { col: u16, row: u16, pane: usize, serial: u32, frames: u16 = 0, body_hit: ?Mouse.BodyHit = null, tag_hit: ?TagHit = null, }; const LookHoverPreview = struct { col: u16, row: u16, pane: usize, serial: u32, sel: ?Pane.Sel, file_word: ?FileWordSpan = null, body_hit: ?Mouse.BodyHit = null, tag_hit: ?TagHit = null, }; const FileWordSpan = struct { row: i32, lo: i32, hi: i32, // exclusive }; const PdfWordPreview = if (pdf_enabled) struct { col: u16, row: u16, pane: usize, serial: u32, probe: panes.Pdf.WordProbe, fn deinit(preview: *@This(), gpa: std.mem.Allocator) void { preview.probe.deinit(gpa); preview.* = undefined; } } else void; const Drag = union(enum) { none, border_v: struct { left_col: usize, cur_x: u16, corner: ?struct { col: usize, idx: usize } = null, cur_y: u16 = 0 }, border_h: struct { col: usize, top_idx: usize, cur_y: u16 }, move: struct { id: usize, cur_x: u16, cur_y: u16 }, column_move: struct { column: usize, serial: u32, cur_x: u16 }, tag: struct { id: usize }, /// chorded: a 1-2/1-3 cut/paste chord fired during this left drag — /// the drag's own release is then inert select: struct { id: usize, button: Mouse.Button, chorded: bool = false, ctrl: bool = false, /// This gesture began over a usable native PDF raster. The payload is /// zero-bit when PDF support is absent. pdf: panes.Pdf.PointerDrag = .{}, }, fn button(drag: Drag) ?Mouse.Button { return switch (drag) { .none => null, .border_v, .border_h, .move, .column_move, .tag => config.select_button, .select => |selection| selection.button, }; } }; fn clampBorderCol(lx: u16, lw: u16, rw: u16, mcol: u16) u16 { const lo = lx + config.MINW; const hi = lx +| lw +| rw -| config.MINW; return if (lo <= hi) std.math.clamp(mcol, lo, hi) else mcol; } fn clampBorderRow(ay: u16, ah: u16, bh: u16, mrow: u16, tag_bottom: bool) u16 { const d: u16 = if (tag_bottom) 1 else 0; const lo = ay + BOX_H - 1 + d; const hi = (ay +| ah +| bh -| (BOX_H + 1)) +| d; return if (lo <= hi) std.math.clamp(mrow, lo, hi) else mrow; } test "a corner drag's two axes clamp independently" { // a 100-wide window split 50/50, and a 30-row column split 15/14 under the // topbar: the shape windowops.snap drives const lx: u16 = 0; const lw: u16 = 50; const rw: u16 = 50; const ay: u16 = TOPBAR_H; const ah: u16 = 15; const bh: u16 = 14; const row_lo: u16 = ay + BOX_H - 1; const row_hi: u16 = ay + ah + bh - (BOX_H + 1); // each axis alone, at both walls try std.testing.expectEqual(@as(u16, config.MINW), clampBorderCol(lx, lw, rw, 0)); try std.testing.expectEqual(@as(u16, 100 - config.MINW), clampBorderCol(lx, lw, rw, 999)); try std.testing.expectEqual(row_lo, clampBorderRow(ay, ah, bh, 0, false)); try std.testing.expectEqual(row_hi, clampBorderRow(ay, ah, bh, 999, false)); // THE corner property: drag off the right edge while staying mid-height — // x sits at its wall, y still tracks the mouse row exactly try std.testing.expectEqual(@as(u16, 100 - config.MINW), clampBorderCol(lx, lw, rw, 999)); try std.testing.expectEqual(@as(u16, 12), clampBorderRow(ay, ah, bh, 12, false)); // and the mirror: pinned at the bottom, x still free try std.testing.expectEqual(row_hi, clampBorderRow(ay, ah, bh, 999, false)); try std.testing.expectEqual(@as(u16, 40), clampBorderCol(lx, lw, rw, 40)); // both pinned at once is just both walls, not a stuck gesture try std.testing.expectEqual(@as(u16, config.MINW), clampBorderCol(lx, lw, rw, 0)); try std.testing.expectEqual(row_hi, clampBorderRow(ay, ah, bh, 999, false)); try std.testing.expectEqual(row_lo + 1, clampBorderRow(ay, ah, bh, 0, true)); try std.testing.expectEqual(row_hi + 1, clampBorderRow(ay, ah, bh, 999, true)); try std.testing.expectEqual(@as(u16, 12), clampBorderRow(ay, ah, bh, 12, true)); // degenerate pairs pass through untouched, each on its own terms: a pane // pair with no room stays draggable even when the column pair has room try std.testing.expectEqual(@as(u16, 7), clampBorderCol(0, 5, 5, 7)); try std.testing.expectEqual(@as(u16, 40), clampBorderCol(lx, lw, rw, 40)); try std.testing.expectEqual(@as(u16, 9), clampBorderRow(TOPBAR_H, 1, 0, 9, false)); } fn seamRowOf(p: *const Pardes, c: usize, k: usize) u16 { const r = p.rects[p.col_panes[c][k]]; return if (p.settings.tag_bottom) r.y +| r.h else r.y + r.h -| 1; } fn cornerFixture(gpa: std.mem.Allocator) !*Pardes { const p = try Pardes.init(gpa, .{ .cols = 100, .rows = 30, .shells = 3 }); p.update(.{ .resize = .{ .cols = 100, .rows = 30 } }); p.active = p.col_panes[1][0]; p.update(.{ .key = .{ .cp = 'n', .alt = true } }); // a shell below it, same column p.sync(); return p; } fn nudgeRightSeam(p: *Pardes, delta: i32) u16 { const from = seamRowOf(p, 1, 0); const inside = p.col_x[1] + p.col_w[1] / 2; const to: u16 = @intCast(@as(i32, from) + delta); p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = inside, .row = from } }); p.update(.{ .mouse = .{ .button = .left, .kind = .drag, .col = inside, .row = to } }); p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = inside, .row = to } }); p.sync(); return seamRowOf(p, 1, 0); } test "a v-handle press at the RIGHT column's seam drags both boundaries" { if (platform == .web) return; const p = try cornerFixture(std.testing.allocator); defer p.deinit(); try std.testing.expectEqual(@as(usize, 2), p.col_n[1]); const handle = p.col_x[0] + p.col_w[0] - 1; const right_seam = nudgeRightSeam(p, 3); try std.testing.expect(right_seam != seamRowOf(p, 0, 0)); const w0 = p.col_weight[0]; const v_left = p.panes[p.col_panes[0][0]].?.vweight; const v_right = p.panes[p.col_panes[1][0]].?.vweight; p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = handle, .row = right_seam } }); try std.testing.expect(p.drag == .border_v); const corner = p.drag.border_v.corner orelse return error.NoCorner; try std.testing.expectEqual(@as(usize, 1), corner.col); try std.testing.expectEqual(@as(usize, 0), corner.idx); p.update(.{ .mouse = .{ .button = .left, .kind = .drag, .col = handle + 8, .row = right_seam - 4 } }); p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = handle + 8, .row = right_seam - 4 } }); p.sync(); // both halves committed: the column pair widened, and the RIGHT column's // pane pair reweighted — while the left column's panes stayed put try std.testing.expect(p.col_weight[0] > w0); try std.testing.expect(p.panes[p.col_panes[1][0]].?.vweight != v_right); try std.testing.expectEqual(v_left, p.panes[p.col_panes[0][0]].?.vweight); } test "a tie row still moves the LEFT column's pane pair only" { if (platform == .web) return; const p = try cornerFixture(std.testing.allocator); defer p.deinit(); // put column 1's seam exactly on column 0's, the row the eye reads as one // line across the whole window const left_seam = seamRowOf(p, 0, 0); _ = nudgeRightSeam(p, @as(i32, left_seam) - @as(i32, seamRowOf(p, 1, 0))); try std.testing.expectEqual(left_seam, seamRowOf(p, 1, 0)); const handle = p.col_x[0] + p.col_w[0] - 1; const v_left = p.panes[p.col_panes[0][0]].?.vweight; const v_right = p.panes[p.col_panes[1][0]].?.vweight; p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = handle, .row = left_seam } }); const corner = p.drag.border_v.corner orelse return error.NoCorner; try std.testing.expectEqual(@as(usize, 0), corner.col); p.update(.{ .mouse = .{ .button = .left, .kind = .drag, .col = handle, .row = left_seam - 4 } }); p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = handle, .row = left_seam - 4 } }); p.sync(); try std.testing.expect(p.panes[p.col_panes[0][0]].?.vweight != v_left); try std.testing.expectEqual(v_right, p.panes[p.col_panes[1][0]].?.vweight); } test "a v-handle press at nobody's seam is still a plain edge drag" { if (platform == .web) return; const p = try cornerFixture(std.testing.allocator); defer p.deinit(); const right_seam = nudgeRightSeam(p, 3); const left_seam = seamRowOf(p, 0, 0); const handle = p.col_x[0] + p.col_w[0] - 1; var row: u16 = TOPBAR_H + 1; while (row == left_seam or row == right_seam) row += 1; const w0 = p.col_weight[0]; const v_left = p.panes[p.col_panes[0][0]].?.vweight; const v_right = p.panes[p.col_panes[1][0]].?.vweight; p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = handle, .row = row } }); try std.testing.expect(p.drag == .border_v); try std.testing.expect(p.drag.border_v.corner == null); p.update(.{ .mouse = .{ .button = .left, .kind = .drag, .col = handle + 8, .row = row + 5 } }); p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = handle + 8, .row = row + 5 } }); p.sync(); // exactly ONE boundary moved try std.testing.expect(p.col_weight[0] > w0); try std.testing.expectEqual(v_left, p.panes[p.col_panes[0][0]].?.vweight); try std.testing.expectEqual(v_right, p.panes[p.col_panes[1][0]].?.vweight); } test "a RIGHT-column corner's two axes clamp independently" { if (platform == .web) return; const p = try cornerFixture(std.testing.allocator); defer p.deinit(); const right_seam = nudgeRightSeam(p, 3); const handle = p.col_x[0] + p.col_w[0] - 1; p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = handle, .row = right_seam } }); try std.testing.expectEqual(@as(usize, 1), (p.drag.border_v.corner orelse return error.NoCorner).col); // off the right edge at mid-height: x parks at its MINW wall, y is the mouse p.update(.{ .mouse = .{ .button = .left, .kind = .drag, .col = 999, .row = right_seam } }); try std.testing.expectEqual(@as(u16, 100 - config.MINW), p.drag.border_v.cur_x); try std.testing.expectEqual(right_seam, p.drag.border_v.cur_y); // and the mirror: below the bottom at mid-width. y parks, x tracks again p.update(.{ .mouse = .{ .button = .left, .kind = .drag, .col = handle, .row = 999 } }); try std.testing.expectEqual(handle, p.drag.border_v.cur_x); const a = p.rects[p.col_panes[1][0]]; const b = p.rects[p.col_panes[1][1]]; try std.testing.expectEqual(clampBorderRow(a.y, a.h, b.h, 999, p.settings.tag_bottom), p.drag.border_v.cur_y); p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = handle, .row = 999 } }); } test "a new column takes width only from the column that created it" { if (platform == .web) return; const p = try Pardes.init(std.testing.allocator, .{ .cols = 124, .rows = 24 }); defer p.deinit(); _ = try p.newShell(1, ""); try std.testing.expect(layout.splitColumn(p, 0, 1, false)); _ = try p.newShell(2, ""); try std.testing.expect(layout.splitColumn(p, 0, 2, true)); p.sync(); const left_before = p.rects[2]; const right_before = p.rects[1]; _ = try p.newShell(3, ""); try std.testing.expect(layout.splitColumn(p, 0, 3, false)); p.sync(); try std.testing.expectEqual(left_before.x, p.rects[2].x); try std.testing.expectEqual(left_before.w, p.rects[2].w); try std.testing.expectEqual(right_before.x, p.rects[1].x); try std.testing.expectEqual(right_before.w, p.rects[1].w); try std.testing.expectEqual(@as(u16, 16), p.rects[0].w); try std.testing.expectEqual(@as(u16, 15), p.rects[3].w); const narrow = try Pardes.init(std.testing.allocator, .{ .cols = config.MINW * 2 - 1, .rows = 10 }); defer narrow.deinit(); try std.testing.expect(!layout.splitColumn(narrow, 0, 1, false)); try std.testing.expectEqual(@as(usize, 1), narrow.ncol); const sequential = try Pardes.init(std.testing.allocator, .{ .cols = config.MINW * 2, .rows = 10 }); defer sequential.deinit(); _ = try sequential.newShell(1, ""); try std.testing.expect(layout.splitColumn(sequential, 0, 1, false)); _ = try sequential.newShell(2, ""); try std.testing.expect(!layout.splitColumn(sequential, 0, 2, false)); try std.testing.expectEqual(@as(usize, 2), sequential.ncol); const coarse = try Pardes.init(std.testing.allocator, .{ .cols = 22, .rows = 10 }); defer coarse.deinit(); coarse.col_weight[0] = 3; _ = try coarse.newShell(1, ""); try std.testing.expect(layout.splitColumn(coarse, 0, 1, false)); coarse.sync(); try std.testing.expectEqual(@as(u16, 11), coarse.col_w[0]); try std.testing.expectEqual(@as(u16, 11), coarse.col_w[1]); const extreme = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 20 }); defer extreme.deinit(); _ = try extreme.newShell(1, ""); try std.testing.expect(layout.splitColumn(extreme, 0, 1, false)); _ = try extreme.newShell(2, ""); layout.insert(extreme, 0, 1, 2); extreme.col_weight[0] = std.math.maxInt(u64) / 2 + 2; // odd and cannot double extreme.col_weight[1] = 1; layout.compute(extreme); const panes_before = extreme.col_panes; const counts_before = extreme.col_n; const weights_before = extreme.col_weight; const source_vweight = extreme.panes[0].?.vweight; const sibling_vweight = extreme.panes[2].?.vweight; try std.testing.expect(!layout.splitColumn(extreme, 0, 0, false)); try std.testing.expectEqual(panes_before, extreme.col_panes); try std.testing.expectEqual(counts_before, extreme.col_n); try std.testing.expectEqual(weights_before, extreme.col_weight); try std.testing.expectEqual(source_vweight, extreme.panes[0].?.vweight); try std.testing.expectEqual(sibling_vweight, extreme.panes[2].?.vweight); } test "Newcol refuses an unsplittable restored weight before spawning" { if (platform == .web) return; const p = try Pardes.init(std.testing.allocator, .{ .cols = 200, .rows = 20 }); defer p.deinit(); for (1..4) |id| { _ = try p.newShell(id, ""); try std.testing.expect(layout.splitColumn(p, id - 1, id, false)); } const restored_cap = std.math.maxInt(u64) / 6; for (0..4) |column| p.col_weight[column] = restored_cap; p.col_weight[3] -= 1; for ([_]usize{ 3, 2, 1 }) |id| { const pane = p.panes[id].?; try p.deinitPane(pane); layout.removePane(p, id); p.panes[id] = null; } while (p.nextEffect()) |_| {} try std.testing.expect(p.col_weight[0] > std.math.maxInt(u64) / 2); try std.testing.expect(p.col_weight[0] % 2 == 1); try std.testing.expect(!layout.canSplitColumn(p, 0)); const panes_before = p.panes; const serial_before = p.next_serial; try std.testing.expect(p.executeBuiltinLine(0, "Newcol")); try std.testing.expectEqual(panes_before, p.panes); try std.testing.expectEqual(serial_before, p.next_serial); try std.testing.expect(p.nextEffect() == null); } test "layout commits publish finite tracks only for changed panes" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 120, .rows = 20 }); defer p.deinit(); _ = try p.newShell(1, ""); try std.testing.expect(layout.splitColumn(p, 0, 1, false)); p.sync(); const untouched = p.rects[1]; p.settings.panel_transition = .slide; _ = try p.newShell(2, ""); try std.testing.expect(layout.splitColumn(p, 0, 2, true)); p.sync(); // Pane 1 belongs to the unrelated right column. Its exact layout stayed // fixed, so it does not receive a presentation record either. try std.testing.expect(p.presentation.tracks[1] == null); try std.testing.expect(p.presentation.tracks[0] != null); try std.testing.expect(p.presentation.tracks[2] != null); try std.testing.expectEqual(layout.Phase.moving, p.presentation.tracks[0].?.phase); try std.testing.expectEqual(layout.Phase.opening, p.presentation.tracks[2].?.phase); try std.testing.expectEqual(untouched, p.rects[1]); try std.testing.expect(p.animationActive()); var frame = std.heap.ArenaAllocator.init(std.testing.allocator); defer frame.deinit(); const surface = try p.render(frame.allocator()); try std.testing.expectEqual(@as(usize, 2), surface.panelTracks().len); for (0..layout.Transition.slide.frames()) |_| p.update(.tick); try std.testing.expect(!p.animationActive()); _ = frame.reset(.retain_capacity); try std.testing.expectEqual(@as(usize, 0), (try p.render(frame.allocator())).panelTracks().len); p.settings.panel_transition = .zoom; p.update(.{ .resize = .{ .cols = 101, .rows = 17 } }); try std.testing.expect(!p.animationActive()); for (p.presentation.tracks) |track| try std.testing.expect(track == null); } test "screen resize keeps its previous storage until allocation succeeds" { var failing: std.testing.FailingAllocator = .init(std.testing.allocator, .{}); const p = try Pardes.init(failing.allocator(), .{ .cols = 80, .rows = 24, .tty_only = true }); defer p.deinit(); var frame: std.heap.ArenaAllocator = .init(std.testing.allocator); defer frame.deinit(); const surface = try p.render(frame.allocator()); const cells = surface.cells; p.update(.{ .resize = .{ .cols = 81, .rows = 25 } }); failing.fail_index = failing.alloc_index; try std.testing.expectError(error.OutOfMemory, p.render(frame.allocator())); try std.testing.expectEqual(cells.ptr, surface.cells.ptr); try std.testing.expectEqual(cells.len, surface.cells.len); try std.testing.expectEqual(@as(u16, 80), surface.cols); try std.testing.expectEqual(@as(u16, 24), surface.rows); failing.fail_index = std.math.maxInt(usize); const resized = try p.render(frame.allocator()); try std.testing.expectEqual(@as(u16, 81), resized.cols); try std.testing.expectEqual(@as(u16, 25), resized.rows); try std.testing.expectEqual(@as(usize, 81 * 25), resized.cells.len); } test "vertical close samples only a canonical baseline containing that pane" { { const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 16, .tty_only = true }); defer p.deinit(); var frame = std.heap.ArenaAllocator.init(std.testing.allocator); defer frame.deinit(); const boot = try p.render(frame.allocator()); p.acknowledgePanelPresentation(boot.panelTracks()); p.settings.panel_transition = .vertical; _ = try p.newShell(1, ""); try std.testing.expect(layout.splitColumn(p, 0, 1, false)); p.sync(); _ = frame.reset(.retain_capacity); const opening = try p.render(frame.allocator()); p.acknowledgePanelPresentation(opening.panelTracks()); try p.removePane(1); p.sync(); try std.testing.expectEqual(@as(usize, 0), p.presentation.closing_len); } // Once the pane itself has reached a canonical acknowledged frame, that // exact box is safe frozen source data for its ordinary closing slide. { const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 16, .tty_only = true }); defer p.deinit(); var frame = std.heap.ArenaAllocator.init(std.testing.allocator); defer frame.deinit(); const boot = try p.render(frame.allocator()); p.acknowledgePanelPresentation(boot.panelTracks()); p.settings.panel_transition = .vertical; _ = try p.newShell(1, ""); try std.testing.expect(layout.splitColumn(p, 0, 1, false)); p.sync(); for (0..layout.Transition.vertical.frames()) |_| p.update(.tick); _ = frame.reset(.retain_capacity); const canonical = try p.render(frame.allocator()); try std.testing.expectEqual(@as(usize, 0), canonical.panelTracks().len); p.acknowledgePanelPresentation(canonical.panelTracks()); const serial = p.panes[1].?.serial; try p.removePane(1); p.sync(); try std.testing.expectEqual(@as(usize, 1), p.presentation.closing_len); try std.testing.expectEqual(serial, p.presentation.closing[0].serial); } } test "previous-grid animation refreshes its mask and snaps overlapping layout churn" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 120, .rows = 20, .tty_only = true }); defer p.deinit(); var frame = std.heap.ArenaAllocator.init(std.testing.allocator); defer frame.deinit(); const boot = try p.render(frame.allocator()); p.acknowledgePanelPresentation(boot.panelTracks()); p.settings.panel_transition = .vertical; _ = try p.newShell(1, ""); try std.testing.expect(layout.splitColumn(p, 0, 1, false)); p.sync(); _ = frame.reset(.retain_capacity); const opening = try p.render(frame.allocator()); const changed_index = for (opening.cell_diffs, 0..) |diff, index| { if (diff.changed()) break index; } else return error.MissingPanelDiff; p.acknowledgePanelPresentation(opening.panelTracks()); // Corrupt one cached classification to prove a later live frame derives it again // from the frozen old cells and freshly rendered new cells. p.presentation.diffs[changed_index] = .unchanged; _ = frame.reset(.retain_capacity); const refreshed = try p.render(frame.allocator()); try std.testing.expect(refreshed.cell_diffs[changed_index].changed()); p.acknowledgePanelPresentation(refreshed.panelTracks()); _ = try p.newShell(2, ""); try std.testing.expect(layout.splitColumn(p, 1, 2, false)); p.sync(); try std.testing.expect(p.presentation.diff_state == .none); try std.testing.expectEqual(@as(usize, 0), p.presentation.closing_len); for (p.presentation.tracks) |track| try std.testing.expect(track == null); try std.testing.expect(p.presentation.pending); _ = frame.reset(.retain_capacity); const snapped = try p.render(frame.allocator()); try std.testing.expectEqual(@as(usize, 0), snapped.panelTracks().len); } test "canonical fallback and transition toggle retire unpresented tracks" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 16, .tty_only = true }); defer p.deinit(); var frame = std.heap.ArenaAllocator.init(std.testing.allocator); defer frame.deinit(); const boot = try p.render(frame.allocator()); p.acknowledgePanelPresentation(boot.panelTracks()); p.settings.panel_transition = .slide; _ = try p.newShell(1, ""); try std.testing.expect(layout.splitColumn(p, 0, 1, false)); p.sync(); _ = frame.reset(.retain_capacity); const animated = try p.render(frame.allocator()); try std.testing.expect(animated.panelTracks().len > 0); // A host which rendered the canonical fallback acknowledges exactly that // empty record set; producers cannot resume on its next frame. p.acknowledgePanelPresentation(&.{}); try std.testing.expect(!p.animationActive()); try std.testing.expect(p.presentation.diff_state == .none); _ = frame.reset(.retain_capacity); try std.testing.expectEqual(@as(usize, 0), (try p.render(frame.allocator())).panelTracks().len); // Recreate a visible sample, then change the selected effect. Actual old // pixels remain on screen until the next submit, so pointer input waits. p.settings.panel_transition = .slide; p.col_weight[0] = column_weight_unit * 2; p.col_weight[1] = column_weight_unit; p.sync(); _ = frame.reset(.retain_capacity); const moving = try p.render(frame.allocator()); p.acknowledgePanelPresentation(moving.panelTracks()); try std.testing.expect(moving.panelTracks().len > 0); p.applySettingBuiltin(config.Runtime.find("PanelZoom").?, null); try std.testing.expectEqual(layout.Transition.zoom, p.settings.panel_transition); try std.testing.expect(p.presentation.pending); try std.testing.expect(p.presentation.pointer(p.screen_w, p.screen_h, 10, 4) == null); for (p.presentation.tracks) |track| try std.testing.expect(track == null); p.acknowledgePanelPresentation(&.{}); p.col_weight[0] = column_weight_unit; p.col_weight[1] = column_weight_unit * 2; p.sync(); _ = frame.reset(.retain_capacity); const before_resize = try p.render(frame.allocator()); p.acknowledgePanelPresentation(before_resize.panelTracks()); try std.testing.expect(before_resize.panelTracks().len > 0); const old_tagline_percent = p.settings.font.tagline_percent; var percent_buf: [3]u8 = undefined; const same_percent = try std.fmt.bufPrint(&percent_buf, "{d}", .{old_tagline_percent}); p.applySettingBuiltin(config.Runtime.find("TaglineSize").?, same_percent); try std.testing.expect(p.animationActive()); const changed_percent: u8 = if (old_tagline_percent == 73) 74 else 73; const changed_text = try std.fmt.bufPrint(&percent_buf, "{d}", .{changed_percent}); p.applySettingBuiltin(config.Runtime.find("TaglineSize").?, changed_text); try std.testing.expectEqual(changed_percent, p.settings.font.tagline_percent); try std.testing.expect(p.presentation.pending); for (p.presentation.tracks) |track| try std.testing.expect(track == null); } test "pointer coordinates follow presented panel geometry" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 120, .rows = 20, .tty_only = true }); defer p.deinit(); const pane = p.panes[0].?; const moving: layout.Track = .{ .serial = pane.serial, .pane = 0, .phase = .moving, .effect = .slide, .from = .{ .x = 20, .y = 5, .w = 40, .h = 10 }, .to = .{ .x = 0, .y = 1, .w = 80, .h = 18 }, }; p.acknowledgePanelPresentation(&.{moving}); // One shared physical-to-logical map feeds all pointer gestures. The // selected sample is 26.25% across and 35% down the presented rectangle. try std.testing.expectEqual(layout.Presentation.CellPosition{ .col = 21, .row = 7 }, p.presentation.pointer(p.screen_w, p.screen_h, 30, 8).?); // Canonical cells covered only by the not-yet-arrived target are inert. try std.testing.expect(p.presentation.pointer(p.screen_w, p.screen_h, 2, 2) == null); // Unrelated screen space remains in the ordinary grid coordinate system. try std.testing.expectEqual(layout.Presentation.CellPosition{ .col = 100, .row = 10 }, p.presentation.pointer(p.screen_w, p.screen_h, 100, 10).?); p.acknowledgePanelPresentation(&.{layout.Track{ .serial = pane.serial, .pane = 0, .phase = .opening, .effect = .zoom, .from = .{ .x = 40, .y = 10, .w = 0, .h = 0 }, .to = .{ .x = 0, .y = 1, .w = 80, .h = 18 }, }}); try std.testing.expect(p.presentation.pointer(p.screen_w, p.screen_h, 40, 10) == null); // Tracks paint in pane-slot order inside a phase. The later slot is the // top quad and therefore owns an overlap, even though both are moving. const overlap = [_]layout.Track{ .{ .serial = pane.serial, .pane = 0, .phase = .moving, .effect = .slide, .from = .{ .x = 20, .y = 2, .w = 20, .h = 4 }, .to = .{ .x = 0, .y = 2, .w = 20, .h = 4 }, }, .{ .serial = pane.serial + 1, .pane = 1, .phase = .moving, .effect = .slide, .from = .{ .x = 20, .y = 2, .w = 20, .h = 4 }, .to = .{ .x = 60, .y = 2, .w = 20, .h = 4 }, } }; p.presentation.shown_tracks = @splat(null); p.presentation.shown_tracks[0] = overlap[0]; p.presentation.shown_tracks[1] = overlap[1]; try std.testing.expectEqual(layout.Presentation.CellPosition{ .col = 65, .row = 3 }, p.presentation.pointer(p.screen_w, p.screen_h, 25, 3).?); } test "queued pointer input is inert until a changed layout is presented" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 16, .tty_only = true }); defer p.deinit(); // Establish that a real host has presented the boot layout. p.acknowledgePanelPresentation(&.{}); p.settings.panel_transition = .slide; _ = try p.newShell(1, ""); try std.testing.expect(layout.splitColumn(p, 0, 1, false)); p.sync(); try std.testing.expect(p.presentation.pending); try std.testing.expect(p.presentation.pointer(p.screen_w, p.screen_h, 10, 4) == null); var tracks: [MAX_PANES]layout.Track = undefined; var len: usize = 0; for (p.presentation.tracks) |maybe| if (maybe) |track| { tracks[len] = track; len += 1; }; p.acknowledgePanelPresentation(tracks[0..len]); try std.testing.expect(!p.presentation.pending); // Whether this particular opening sample exposes the chosen cell is the // transition's concern; it is no longer rejected merely as speculative. try std.testing.expect(p.presentation.pointer(p.screen_w, p.screen_h, 99, 4) != null); } test "back-to-back layout commits retarget from the last presented boxes" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 120, .rows = 20, .tty_only = true }); defer p.deinit(); _ = try p.newShell(1, ""); try std.testing.expect(layout.splitColumn(p, 0, 1, false)); p.sync(); var frame = std.heap.ArenaAllocator.init(std.testing.allocator); defer frame.deinit(); const initial = try p.render(frame.allocator()); p.acknowledgePanelPresentation(initial.panelTracks()); const shown_a = layout.panelBox(p.rects[0]); p.settings.panel_transition = .slide; p.col_weight[0] = column_weight_unit; p.col_weight[1] = column_weight_unit * 2; p.sync(); const target_b = p.presentation.tracks[0] orelse return error.MissingFirstRetarget; try std.testing.expect(target_b.from.eql(shown_a)); // No render or acknowledgement of B: the pixels are still A. C must not // jump to the logical-but-unseen B before beginning its own transition. p.col_weight[0] = column_weight_unit * 2; p.col_weight[1] = column_weight_unit; p.sync(); const target_c = p.presentation.tracks[0] orelse return error.MissingSecondRetarget; try std.testing.expect(target_c.from.eql(shown_a)); try std.testing.expect(target_c.to.eql(layout.panelBox(p.rects[0]))); try std.testing.expectEqual(layout.Phase.moving, target_c.phase); _ = frame.reset(.retain_capacity); const latest = try p.render(frame.allocator()); var submitted: ?layout.Track = null; for (latest.panelTracks()) |track| if (track.pane == 0) { submitted = track; break; }; const track = submitted orelse return error.MissingSubmittedRetarget; p.acknowledgePanelPresentation(latest.panelTracks()); try std.testing.expect(p.presentation.shown[0].?.box.eql(track.visualBox())); } test "an unpresented opening pane remains in the opening paint phase" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 120, .rows = 20, .tty_only = true }); defer p.deinit(); var frame = std.heap.ArenaAllocator.init(std.testing.allocator); defer frame.deinit(); const initial = try p.render(frame.allocator()); p.acknowledgePanelPresentation(initial.panelTracks()); p.settings.panel_transition = .slide; _ = try p.newShell(1, ""); try std.testing.expect(layout.splitColumn(p, 0, 1, false)); p.sync(); const first = p.presentation.tracks[1] orelse return error.MissingOpeningTrack; try std.testing.expectEqual(layout.Phase.opening, first.phase); _ = try p.newShell(2, ""); try std.testing.expect(layout.splitColumn(p, 1, 2, true)); p.sync(); const retargeted = p.presentation.tracks[1] orelse return error.MissingOpeningRetarget; try std.testing.expectEqual(layout.Phase.opening, retargeted.phase); try std.testing.expect(retargeted.from.eql(first.from)); } test "core composes ASCII bytes while visual-only cells pass through" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 8, .rows = 4, .tty_only = true }); defer p.deinit(); var current: [4]Cell = @splat(.{ .default = false }); var previous = current; for (¤t, "Dwxq") |*cell, byte| cell.text[0] = byte; for (&previous, "Azxq") |*cell, byte| cell.text[0] = byte; current[2].style.bold = true; // visual-only: same glyph, new style current[3].text[0..2].* = .{ 0xc3, 0xa9 }; current[3].len = 2; // non-ASCII: canonical immediately const diffs = [_]PanelCellDiff{ .{ .ascii = .{ .from = 'A', .to = 'D' } }, .{ .ascii = .{ .from = 'z', .to = 'w' } }, .visual, .visual, }; var surface: Surface = .{ .cols = 4, .rows = 1, .cells = ¤t, .previous_cells = &previous, .cell_diffs = &diffs, }; surface.panel_tracks[0] = .{ .effect = .ascii, .frame = 2, .frame_count = 4, .from = .{ .w = 4, .h = 1 }, .to = .{ .w = 4, .h = 1 }, }; surface.npanel_tracks = 1; var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const presented = try p.composeAsciiTransitions(arena.allocator(), &surface); try std.testing.expect(presented != &surface); try std.testing.expectEqualStrings("C", presented.at(0, 0).grapheme()); try std.testing.expectEqualStrings("x", presented.at(1, 0).grapheme()); try std.testing.expectEqual(current[2], presented.at(2, 0).*); try std.testing.expectEqual(current[3], presented.at(3, 0).*); try std.testing.expectEqualStrings("D", surface.at(0, 0).grapheme()); } test "core composes character motion out of the new grid, not a fade" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 8, .rows = 4, .tty_only = true }); defer p.deinit(); var current: [8]Cell = @splat(.{ .default = false }); var previous = current; for (¤t, "abcdefgh") |*cell, byte| cell.text[0] = byte; for (&previous, "........") |*cell, byte| cell.text[0] = byte; // Motion effects carry every glyph in the pane, so a cell's semantic // classification is irrelevant to them; only the frozen grid matters. const diffs: [8]PanelCellDiff = @splat(.visual); var surface: Surface = .{ .cols = 8, .rows = 1, .cells = ¤t, .previous_cells = &previous, .cell_diffs = &diffs, }; surface.panel_tracks[0] = .{ .effect = .edges, .frame = 4, .to = .{ .w = 8, .h = 1 }, }; surface.npanel_tracks = 1; var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const presented = try p.composeAsciiTransitions(arena.allocator(), &surface); try std.testing.expect(presented != &surface); var seen: [8]u8 = undefined; for (&seen, 0..) |*byte, col| byte.* = presented.at(@intCast(col), 0).text[0]; try std.testing.expectEqualStrings("cdefgh..", &seen); try std.testing.expectEqualStrings("a", surface.at(0, 0).grapheme()); // The last active sample is the untouched canonical grid, with no copy. surface.panel_tracks[0].frame = layout.Transition.edges.frames() - 1; try std.testing.expect(try p.composeAsciiTransitions(arena.allocator(), &surface) == &surface); } test "pointer rejects panel content cells which have not materialized" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 12, .tty_only = true }); defer p.deinit(); const pane = p.panes[0].?; const target: layout.Box = .{ .x = 4, .y = 2, .w = 32, .h = 4 }; p.presentation.diffs = try p.gpa.alloc(PanelCellDiff, @as(usize, p.screen_w) * p.screen_h); @memset(p.presentation.diffs, .unchanged); for (2..6) |row| { for (4..36) |col| { // Distances on both sides of frame five prove that finished ASCII // bytes are clickable while bytes still walking are not. p.presentation.diffs[row * p.screen_w + col] = .{ .ascii = .{ .from = ' ', .to = @intCast(' ' + (col - 4) % 12 + 1), } }; } } p.presentation.diff_state = .ready; for ([_]layout.Transition{ .dissolve, .ascii }) |effect| { const frame: u16 = if (effect == .dissolve) 3 else 5; const track: layout.Track = .{ .serial = pane.serial, .pane = 0, .phase = .opening, .effect = effect, .frame = frame, .from = target, .to = target, }; p.acknowledgePanelPresentation(&.{track}); var saw_visible = false; var saw_hidden = false; for (0..4) |relative_row| for (0..32) |relative_col| { const rcol: u16 = @intCast(relative_col); const rrow: u16 = @intCast(relative_row); const col: u16 = @intCast(4 + relative_col); const row: u16 = @intCast(2 + relative_row); const visible = switch (effect) { .dissolve => layout.dissolveRevealed( pane.serial, rcol, rrow, track.amount(), ), .ascii => switch (p.presentation.cellDiff(p.screen_w, p.screen_h, col, row)) { .ascii => |diff| diff.complete(track.frame), .unchanged, .visual => true, }, else => unreachable, }; const mapped = p.presentation.pointer(p.screen_w, p.screen_h, col, row); try std.testing.expectEqual(visible, mapped != null); if (mapped) |point| try std.testing.expectEqual(layout.Presentation.CellPosition{ .col = col, .row = row }, point); saw_visible = saw_visible or visible; saw_hidden = saw_hidden or !visible; }; try std.testing.expect(saw_visible); try std.testing.expect(saw_hidden); } } test "unchanged content cells remain clickable through data effects" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 40, .rows = 10, .tty_only = true }); defer p.deinit(); const pane = p.panes[0].?; p.presentation.diffs = try p.gpa.alloc(PanelCellDiff, @as(usize, p.screen_w) * p.screen_h); @memset(p.presentation.diffs, .unchanged); p.presentation.diff_state = .ready; const box: layout.Box = .{ .x = 2, .y = 2, .w = 20, .h = 4 }; for ([_]layout.Transition{ .dissolve, .ascii }) |effect| { p.acknowledgePanelPresentation(&.{.{ .serial = pane.serial, .pane = 0, .phase = .opening, .effect = effect, .from = box, .to = box, }}); try std.testing.expectEqual(layout.Presentation.CellPosition{ .col = 8, .row = 3 }, p.presentation.pointer(p.screen_w, p.screen_h, 8, 3).?); } } test "stationary Look hover follows only acknowledged panel samples" { _ = config.look_preview_delay_frames orelse return; const p = try Pardes.init(std.testing.allocator, .{ .cols = 120, .rows = 20, .tty_only = true }); defer p.deinit(); const pane = p.panes[0].?; const rect = p.rects[0]; const target: layout.Box = .{ .x = @floatFromInt(rect.x), .y = @floatFromInt(rect.y), .w = @floatFromInt(rect.w), .h = @floatFromInt(rect.h), }; var track: layout.Track = .{ .serial = pane.serial, .pane = 0, .phase = .moving, .effect = .slide, .from = .{ .x = 20, .y = 5, .w = 40, .h = 10 }, .to = target, }; p.acknowledgePanelPresentation(&.{track}); p.update(.{ .mouse = .{ .button = .none, .kind = .motion, .col = 30, .row = 8 } }); const first = p.look_hover_wait orelse return error.MissingInitialHoverWait; // Advancing the producer-side track alone cannot move the semantic cell // under a stationary pointer. track.frame = track.effect.frames() - 1; p.presentation.tracks[0] = track; p.refreshLookHoverFromRaw(); try std.testing.expectEqual(first, p.look_hover_wait.?); // Once that exact sample is acknowledged, the same raw cell maps through // the new panel rectangle and the debounce starts over for the new target. p.acknowledgePanelPresentation(&.{track}); const moved = p.look_hover_wait orelse return error.MissingRemappedHoverWait; try std.testing.expect(first.col != moved.col or first.row != moved.row); try std.testing.expectEqual(@as(u16, 0), moved.frames); // Re-acknowledging the same semantic target is host repaint noise, not a // new hover, so it preserves the accumulated delay. p.look_hover_wait.?.frames = 3; p.acknowledgePanelPresentation(&.{track}); try std.testing.expectEqual(@as(u16, 3), p.look_hover_wait.?.frames); // A zero-sized opening sample covers its future target but exposes no // clickable content. Successful presentation therefore cancels the hover. track.phase = .opening; track.effect = .zoom; track.frame = 0; track.from = .{ .x = target.x + target.w * 0.5, .y = target.y + target.h * 0.5, }; p.acknowledgePanelPresentation(&.{track}); try std.testing.expect(p.look_hover_wait == null); try std.testing.expect(p.look_hover_preview == null); track.frame = track.effect.frames() - 1; p.acknowledgePanelPresentation(&.{track}); try std.testing.expect(p.look_hover_wait != null); } test "held drag follows acknowledged panels and balances an invisible release" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 120, .rows = 20, .tty_only = true }); defer p.deinit(); const pane = p.panes[0].?; const target = layout.panelBox(p.rects[0]); var track: layout.Track = .{ .serial = pane.serial, .pane = 0, .phase = .moving, .effect = .slide, .from = .{ .x = 50, .y = 5, .w = 20, .h = 8 }, .to = target, }; const raw_col: u16 = 55; const raw_row: u16 = 7; p.acknowledgePanelPresentation(&.{track}); p.update(.{ .mouse = .{ .button = config.select_button, .kind = .press, .col = raw_col, .row = raw_row, } }); try std.testing.expect(p.drag == .select); const first_col = pane.sel[sel_slot].c1; const first_row = pane.sel[sel_slot].r1; // Advancing producer state alone cannot move input. Only the sample the // host says it actually drew may remap the stationary held endpoint. track.frame = track.effect.frames() - 1; p.presentation.tracks[0] = track; try std.testing.expectEqual(first_col, pane.sel[sel_slot].c1); try std.testing.expectEqual(first_row, pane.sel[sel_slot].r1); p.acknowledgePanelPresentation(&.{track}); try std.testing.expect(first_col != pane.sel[sel_slot].c1 or first_row != pane.sel[sel_slot].r1); const last_col = pane.sel[sel_slot].c1; const last_row = pane.sel[sel_slot].r1; track.phase = .opening; track.effect = .zoom; track.frame = 0; track.from = .{ .x = target.x + target.w * 0.5, .y = target.y + target.h * 0.5, }; p.acknowledgePanelPresentation(&.{track}); try std.testing.expect(p.presentation.pointer(p.screen_w, p.screen_h, raw_col, raw_row) == null); try std.testing.expectEqual(last_col, pane.sel[sel_slot].c1); try std.testing.expectEqual(last_row, pane.sel[sel_slot].r1); // An unrelated release cannot consume the left drag. Its own release is // nevertheless balanced at the retained endpoint while the cell is dark. p.update(.{ .mouse = .{ .button = config.exec_button, .kind = .release, .col = raw_col, .row = raw_row, } }); try std.testing.expect(p.drag == .select); p.update(.{ .mouse = .{ .button = config.select_button, .kind = .release, .col = raw_col, .row = raw_row, } }); try std.testing.expect(p.drag == .none); try std.testing.expectEqual(.done, pane.sel[sel_slot].state); } test "repeated non-dyadic column splits preserve every unrelated boundary" { if (platform == .web) return; const p = try Pardes.init(std.testing.allocator, .{ .cols = 979, .rows = 20 }); defer p.deinit(); _ = try p.newShell(1, ""); try std.testing.expect(layout.splitColumn(p, 0, 1, false)); p.sync(); const handle = p.col_x[0] + p.col_w[0] - 1; const untouched = p.col_weight; p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = handle, .row = TOPBAR_H + 1 } }); p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = handle, .row = TOPBAR_H + 1 } }); try std.testing.expectEqual(untouched, p.col_weight); p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = handle, .row = TOPBAR_H + 1 } }); p.update(.{ .mouse = .{ .button = .left, .kind = .drag, .col = 417, .row = TOPBAR_H + 1 } }); p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = 417, .row = TOPBAR_H + 1 } }); p.sync(); try std.testing.expectEqual(@as(u16, 418), p.rects[0].w); _ = try p.newShell(2, ""); try std.testing.expect(layout.splitColumn(p, 1, 2, false)); p.sync(); const left_before = p.rects[0]; const middle_before = p.rects[1]; _ = try p.newShell(3, ""); try std.testing.expect(layout.splitColumn(p, 2, 3, false)); p.sync(); try std.testing.expectEqual(left_before, p.rects[0]); try std.testing.expectEqual(middle_before, p.rects[1]); } pub const Rect = layout.Rect; pub const Options = struct { tty_only: bool = false, /// initial shell panes: 1 (default) or 3 for the classic two-column boot. /// A `file` outranks this — see there. shells: u8 = 1, file: ?[]const u8 = null, file_line: usize = 0, missing: ?struct { word: []const u8, dir: []const u8 } = null, tty_toggle: u21 = config.tty_toggle_default, /// load a dump of another instance instead of spawning shells (acme -l) load_path: ?[]const u8 = null, nested: bool = false, startup_config: ?[]const u8 = null, ninep_name: []const u8 = "", ninep_tcp: ?[]const u8 = null, ninep_quic: ?[]const u8 = null, ninep_identity: struct { socket_path: []const u8 = "", tcp_address: ?std.Io.net.IpAddress = null, quic_address: ?std.Io.net.IpAddress = null, } = .{}, mounts: []const filesystem.Mount = &.{}, config_dir: ?[]const u8 = null, startup_config_path: ?[]const u8 = null, image_allocator: ?std.mem.Allocator = null, pdf_allocator: ?std.mem.Allocator = null, tree_sitter_allocator: ?std.mem.Allocator = null, /// Where each frame's Surface text is built. Hosts pass a purpose-built /// stack-fallback arena; null means the general allocator. frame_allocator: ?std.mem.Allocator = null, cols: u16 = 80, rows: u16 = 24, }; /// The tag-tail marker each behaviour arms with. One function so the prompt /// that is DRAWN and the command that is PARSED can never disagree. fn pipeMarker(how: modal.Normal.PipeBehavior) []const u8 { return switch (how) { .replace => config.pipe_marker, .ignore => config.pipe_marker_to, .insert => config.pipe_marker_insert, .append => config.pipe_marker_append, }; } pub const LoggedMessage = struct { pub const cap = 256; text: [cap]u8 = undefined, len: u16 = 0, /// Which pane said it, or 0xff for one said to no pane in particular. pane: u8 = 0xff, /// How many times in a row. Saturating: a key held down against the same /// refusal is one event, not four hundred. repeats: u16 = 0, pub fn slice(m: *const LoggedMessage) []const u8 { return m.text[0..m.len]; } }; const PendingPipe = struct { id: u32, pane: usize, serial: u32, revision: u32, command: []u8, cwd: []u8, inputs: []selection_pipe.Input, ranges: [Pane.max_selections]modal.Selection, primary: u8, explicit: bool, how: modal.Normal.PipeBehavior, nranges: u8, fn deinit(wait: *PendingPipe, gpa: std.mem.Allocator) void { gpa.free(wait.command); gpa.free(wait.cwd); for (wait.inputs) |input| gpa.free(@constCast(input.bytes)); gpa.free(wait.inputs); wait.* = undefined; } fn request(wait: *const PendingPipe) selection_pipe.Request { return .{ .id = wait.id, .command = wait.command, .cwd = wait.cwd, .inputs = wait.inputs, }; } }; pub const Haptic = enum { none, exec, look }; /// Zero-sized off macOS, the way PdfSlot is off -Dmupdf: no other shell reads /// the field, so no other shell carries it. const HapticSlot = if (platform == .macos) Haptic else void; pub const RetiredPanes = struct { current: [2 * MAX_PANES]?*Pane = @splat(null), previous: [2 * MAX_PANES]?*Pane = @splat(null), fn retire(a: *RetiredPanes, pane: *Pane) void { for (&a.current) |*slot| if (slot.* == null) { slot.* = pane; return; }; // One update runs one builtin, and the widest drop is a whole column; // 2*MAX_PANES cannot fill in a single frame. unreachable; } }; pub const Pardes = struct { gpa: std.mem.Allocator, image_gpa: std.mem.Allocator, pdf_gpa: std.mem.Allocator, tree_sitter_gpa: std.mem.Allocator, opts: Options, screen_w: u16, screen_h: u16, cell_pixels: CellPixels = .{}, row_metrics: RowMetrics = .{}, resize_count: usize = 0, panes: [MAX_PANES]?*Pane = @splat(null), reserved_slots: [MAX_PANES]bool = @splat(false), /// Deferred teardown of dropped panes (see RetiredPanes). retired_panes: RetiredPanes = .{}, // layout: columns own x by weight; panes own y by vweight within a column. ncol: usize = 0, col_weight: [MAX_COLS]u64 = @splat(column_weight_unit), col_panes: [MAX_COLS][MAX_PANES]usize = undefined, col_n: [MAX_COLS]usize = @splat(0), col_tags: [MAX_COLS]TagLine = @splat(.{}), col_serial: [MAX_COLS]u32 = @splat(0), next_column_serial: u32 = 1, global_tag: TagLine = .{}, // derived each sync rects: [MAX_PANES]Rect = @splat(.{}), col_x: [MAX_COLS]u16 = undefined, col_w: [MAX_COLS]u16 = undefined, presentation: layout.Presentation = .{}, active: usize = 0, jumps: [MAX_JUMPS]Loc = undefined, njumps: usize = 0, jcur: usize = 0, jump_selection_cursor: ?struct { pane: usize, serial: u32, row: i32, col: i32 } = null, look_src: [MAX_PANES]u32 = undefined, n_look_src: usize = 0, look_walk_owner: ?u32 = null, /// hands out Pane.serial; monotonic, never reused next_serial: u32 = 0, settings: config.Runtime = .{ .font = .{ .tagline_percent = config.gui_tagline_font_percent } }, locations_config: locations_config.Config = .{}, tty_filter_palette: panes.Terminal.FilterPalette = .{}, font_request_taken: bool = false, custom_theme: ?Theme = null, theme_file_path: config.Runtime.Text(limits.host_path_cap) = .{}, theme_file_generation: u32 = 0, theme_file_pane: u8 = 0, chrome_animation: ChromeAnimation = ChromeAnimation.init(initial_chrome), animate_theme_changes: bool = false, native_images: bool = false, quit: bool = false, haptic: HapticSlot = if (platform == .macos) .none else {}, drag: Drag = .none, hover_col: u16 = 0, hover_row: u16 = 0, pointer_raw_col: u16 = 0, pointer_raw_row: u16 = 0, pointer_body_hit: ?Mouse.BodyHit = null, pointer_tag_hit: ?TagHit = null, raw_hover_intent: bool = false, pointer_inside: bool = false, look_hover_wait: ?LookHoverWait = null, look_hover_preview: ?LookHoverPreview = null, pdf_hover_preview: if (pdf_enabled) ?PdfWordPreview else void = if (pdf_enabled) null else {}, wheel_guard: u8 = 0, ctrl_w_pending: bool = false, multi_on: bool = false, multi_first: bool = false, multi_stop: bool = false, leader_on: bool = false, leader_keys: [4]u8 = undefined, leader_n: u8 = 0, topbar_col: ?u16 = null, header_column: ?usize = null, header_insert: bool = false, header_anchor: ?u16 = null, header_drag: bool = false, header_scroll: usize = 0, ov_pinch_scale: f32 = 1.0, ov_touch_scroll_delta: f32 = 0.0, /// middle+left chord argument: a kept left selection captured by a left /// press during an active middle select-drag; consumed on middle release chord_arg: ?[]u8 = null, /// how many `execute` calls are on the stack — see max_exec_depth exec_depth: u8 = 0, lsp_seq: u32 = 0, lsp_wait: ?struct { id: u32, kind: lsp.Kind, pane: usize, serial: u32, revision: u32, arg: Effect.Buf(128), row: i32 = 0, col: i32 = 0, } = null, pipe_seq: u32 = 0, pipe_wait: ?PendingPipe = null, fs: filesystem.Namespace = .{}, // Reject overflow: evicting an older effect would reorder a byte stream. effects: [limits.effect_cap]Effect = undefined, effects_head: usize = 0, effects_len: usize = 0, // Each pty retains its own overflow tail until earlier effects drain. pending_write: [MAX_PANES]?PendingWrite = @splat(null), /// Total bytes parked above, so the drain path costs one comparison when /// nothing is waiting — which is every frame that is not a large paste. pending_write_bytes: usize = 0, /// WHO SERVES THIS CORE. Every method optional; a null one is answered by /// `fallback` below, so a `Host{}` is a complete in-process pardes. host: Host = .{}, /// The in-program answers behind every unimplemented host method. Per /// instance, so several cores behind one fan-out host stay independent. fallback: host_io.Fallback, /// The message-row log: a fixed ring, never allocated, never grown. See /// `logMessage` and the `Messages` builtin. messages: [limits.message_log]LoggedMessage = @splat(.{}), /// Next slot to write. `messages_len` saturates at the ring's size. messages_head: usize = 0, messages_len: usize = 0, in_q: [64]Event = undefined, in_head: usize = 0, in_len: usize = 0, yank: ?[]u8 = null, clip_pending: ?ClipRequest = null, /// the last serialized dump (gpa-owned), read by the write_dump effect dump_out: ?[]u8 = null, /// where the shell wrote the last dump (shell reports back after /// write_dump); shown in the topbar as `Restore ` last_dump: ?[]const u8 = null, /// a Restore builtin wants this dump loaded into the live session; the /// shell consumes it via takeRestore each frame (restore contents stay host-fed) restore_req: ?[]const u8 = null, restore_buf: [1024]u8 = undefined, attach_req: ?AttachRequest = null, attach_buf: [attach_name_max]u8 = undefined, surface: Surface = .{}, /// per-update scratch (paneCursorLines, selection text); reset each update scratch: std.heap.ArenaAllocator, /// The arena each frame's Surface text is built in, reset by `pump`. Hosts /// that still own their loop pass their own arena to `render` instead. frame_arena: std.heap.ArenaAllocator, /// the terminal motion surface, memoized against the pane it was built /// for — see panes.Terminal.RowsCache for the lifetime rule shell_rows: panes.Terminal.RowsCache = .{}, pub fn init(gpa: std.mem.Allocator, opts: Options) !*Pardes { const image_gpa = opts.image_allocator orelse gpa; const pdf_gpa = opts.pdf_allocator orelse gpa; const tree_sitter_gpa = opts.tree_sitter_allocator orelse gpa; const p = try gpa.create(Pardes); p.* = .{ .gpa = gpa, .image_gpa = image_gpa, .pdf_gpa = pdf_gpa, .tree_sitter_gpa = tree_sitter_gpa, .opts = opts, .fs = .{ .socket_path = opts.ninep_identity.socket_path, .tcp_address = opts.ninep_identity.tcp_address, .quic_address = opts.ninep_identity.quic_address, }, .screen_w = opts.cols, .screen_h = opts.rows, .scratch = .init(gpa), .frame_arena = .init(opts.frame_allocator orelse gpa), .fallback = .{ .gpa = gpa }, }; errdefer p.deinit(); for (opts.mounts) |mount| try p.fs.mount(gpa, mount.name, mount.dial); p.opts.mounts = &.{}; p.opts.ninep_identity = .{}; if (opts.file) |path| { const opened = initial_doc: { if (comptime pdf_enabled) if (look.isPdfPath(path)) break :initial_doc panes.Pdf.openPane(p, 0, path, opts.file_line); if (look.isImagePath(path)) break :initial_doc panes.Image.create(p, 0, path, &.{}); break :initial_doc panes.File.open(p, 0, path, opts.file_line); }; if (opened) |_| {} else |err| { if (err == error.OutOfMemory) return err; const why = switch (@as(anyerror, err)) { error.PermissionDenied => "permission denied", error.FileNotFound => "no file of that name", error.IsDirectory => "that is a directory, and not one that could be read", error.PathTooLong => "that path is too long", error.FileTooLarge => "that file is too large to open", error.NotAFile => "that is a pipe or a device, not a document", else => @errorName(err), }; const content = try std.fmt.allocPrint(gpa, "cannot open\n\n\t{s}\n\n{s}\n", .{ path, why }); errdefer gpa.free(content); _ = try panes.Output.open(p, 0, std.fs.path.dirname(path) orelse "/", .errors, "", content); } p.ncol = 1; p.col_n[0] = 1; p.col_panes[0][0] = 0; } else if (opts.missing) |missing| { const content = try std.fmt.allocPrint( gpa, "file or directory not found\n\n\t{s}\n", .{missing.word}, ); errdefer gpa.free(content); _ = try panes.Output.open(p, 0, missing.dir, .errors, "", content); p.ncol = 1; p.col_n[0] = 1; p.col_panes[0][0] = 0; } else if (comptime platform == .esp32p4) { const content = try p.gpa.dupe(u8, boot_buffer); errdefer p.gpa.free(content); _ = try panes.Output.open(p, 0, "", .{ .cmd = .New }, "", content); p.ncol = 1; p.col_n[0] = 1; p.col_panes[0][0] = 0; } else if (opts.tty_only) { _ = try p.newShell(0, ""); p.panes[0].?.mode = .tty; p.ncol = 1; p.col_n[0] = 1; p.col_panes[0][0] = 0; } else if (opts.shells >= 3) { // classic layout: two columns, the left one split in two. _ = try p.newShell(0, ""); _ = try p.newShell(1, ""); _ = try p.newShell(2, ""); for (p.panes[0..3]) |slot| slot.?.greet = true; p.ncol = 2; p.col_n[0] = 2; p.col_panes[0][0] = 0; p.col_panes[0][1] = 1; p.col_n[1] = 1; p.col_panes[1][0] = 2; } else { // ponytail: 1 and 3 are the only boot layouts; anything else acts as 1 _ = try p.newShell(0, ""); p.panes[0].?.greet = true; p.ncol = 1; p.col_n[0] = 1; p.col_panes[0][0] = 0; } p.sync(); p.applyStartupConfig(); p.finishThemeInitialization(); p.sync(); p.presentation.enabled = true; // A config init that opens a file with `Look …` armed the pulse before // anyone touched anything. Nobody asked for that, so boot is silent. _ = p.takeHaptic(); return p; } pub fn deinit(p: *Pardes) void { p.cancelLookHover(); for (0..MAX_PANES) |id| p.dropPendingWrite(id); for (&p.panes) |*slot| if (slot.*) |pane| { p.teardownPane(pane); slot.* = null; }; for (&p.retired_panes.previous) |*slot| if (slot.*) |pane| { p.teardownPane(pane); slot.* = null; }; for (&p.retired_panes.current) |*slot| if (slot.*) |pane| { p.teardownPane(pane); slot.* = null; }; const gpa = p.gpa; if (p.yank) |y| gpa.free(y); if (p.dump_out) |d| gpa.free(d); p.fallback.deinit(); if (p.last_dump) |d| gpa.free(d); if (p.custom_theme) |theme_value| std.zon.parse.free(gpa, theme_value); if (p.chord_arg) |a| gpa.free(a); if (p.pipe_wait) |*wait| wait.deinit(gpa); p.fs.deinit(gpa); p.shell_rows.reset(gpa); p.scratch.deinit(); p.frame_arena.deinit(); gpa.free(p.surface.cells); for (&p.surface.body_layers) |*layer| gpa.free(layer.cells); for (&p.surface.tag_layers) |*layer| gpa.free(layer.cells); p.presentation.deinit(gpa); if (p.surface.images.len > 0) gpa.free(p.surface.images); gpa.destroy(p); } /// shell feedback after write_dump: remember where the dump landed (the /// topbar grows a `Restore ` word pointing at it) pub fn setLastDump(p: *Pardes, path: []const u8) void { const copy = p.gpa.dupe(u8, path) catch return; if (p.last_dump) |old| p.gpa.free(old); p.last_dump = copy; } /// the shell polls this each frame: a pending Restore's dump path, or null pub fn takeRestore(p: *Pardes) ?[]const u8 { const r = p.restore_req; p.restore_req = null; return r; } /// the shell polls this each frame beside takeRestore: a pending Attach's /// session and reporting pane, or null pub fn takeAttach(p: *Pardes) ?AttachRequest { const a = p.attach_req; p.attach_req = null; return a; } /// the topbar line: the fixed builtins, plus `Restore ` once a dump /// exists — render and click dispatch must agree on this exact string fn topbar(p: *Pardes, buf: []u8) []const u8 { if (p.global_tag.custom) return p.global_tag.slice(""); if (p.last_dump) |d| return std.fmt.bufPrint(buf, config.topbar_str ++ " Restore {s}", .{d}) catch config.topbar_str; return config.topbar_str; } pub fn columnBarHeight(p: *const Pardes) u16 { return if (p.settings.column_tags and p.screen_h >= 3) COLBAR_H else 0; } pub fn columnTag(p: *const Pardes, c: usize) []const u8 { return p.col_tags[c].slice("New Tty Find Grep Joincol"); } fn headerText(p: *Pardes) []const u8 { if (p.header_column) |c| return if (c < p.ncol) p.columnTag(c) else ""; var buf: [1200]u8 = undefined; return p.scratch.allocator().dupe(u8, p.topbar(&buf)) catch ""; } fn revealHeader(p: *Pardes) void { const cur = p.topbar_col orelse { p.header_scroll = 0; return; }; const text = p.headerText(); const width = p.tagCapacity(if (p.header_column) |c| p.col_w[c] -| config.GUTTER else p.screen_w); const col = panes.File.rawDisplayCol(text, @min(cur, text.len)); if (col < p.header_scroll) p.header_scroll = col; if (col >= p.header_scroll + width) p.header_scroll = col -| (width -| 1); var raw = panes.File.rawAtDisplay(text, p.header_scroll); p.header_scroll = panes.File.rawDisplayCol(text, raw); if (width > 0 and col >= p.header_scroll + width) { raw = modal.nextGrapheme(text, raw); p.header_scroll = panes.File.rawDisplayCol(text, raw); } } fn headerReplace(p: *Pardes, bytes: []const u8) void { const cur = p.topbar_col orelse return; const anchor = p.header_anchor orelse cur; const lo = @min(cur, anchor); const hi = @max(cur, anchor); const fallback = p.headerText(); const line = if (p.header_column) |c| &p.col_tags[c] else &p.global_tag; if (line.replace(fallback, lo, hi, bytes)) { p.topbar_col = @intCast(lo + bytes.len); p.header_anchor = null; } } fn detachCwds(p: *Pardes, parents: []const *Pane) !void { var copies: [MAX_PANES]?[]u8 = @splat(null); errdefer for (copies) |copy| if (copy) |bytes| p.gpa.free(bytes); for (p.panes, 0..) |slot, id| { const pane = slot orelse continue; const source = switch (pane.cwd) { .inherited => |parent| parent, else => continue, }; for (parents) |parent| if (source == parent) { copies[id] = try p.gpa.dupe(u8, paneDir(source)); break; }; } for (copies, 0..) |copy, id| if (copy) |bytes| { p.panes[id].?.cwd = .{ .owned = bytes }; }; } // The slot closes now; allocation teardown waits one frame. Caller clears panes[id]. pub fn deinitPane(p: *Pardes, pane: *Pane) !void { try p.detachCwds(&.{pane}); p.retirePane(pane); } fn retirePane(p: *Pardes, pane: *Pane) void { const watched = (if (pane.file) |f| f.output == null else false) or pane.hasPdf(); for (p.panes, 0..) |slot, id| if (slot == pane) { if (watched) p.emit(.{ .watch = .{ .pane = @intCast(id), .on = false } }); p.fs.forget(p.gpa, id); // ...and so do bytes still queued for the pty it no longer has. p.dropPendingWrite(id); }; if (p.lookHoverPane()) |h| if (h < p.panes.len and p.panes[h] == pane) p.cancelLookHover(); p.retired_panes.retire(pane); } // Retired panes keep their payloads until the following frame or core teardown. fn teardownPane(p: *Pardes, pane: *Pane) void { if (p.lookHoverPane()) |hovered| { if (hovered < p.panes.len and p.panes[hovered] == pane) p.cancelLookHover(); } p.shell_rows.dropPane(pane); for (0..pane.pointer_selections.len) |slot| pane.clearPointerSelection(slot); panes.Terminal.deinitPendingCommand(pane); if (pane.image) |*iv| { iv.deinit(p.image_gpa); } if (comptime pdf_enabled) if (pane.pdf) |*pv| panes.Pdf.deinitPane(p, pane, pv); if (pane.file) |*file| panes.File.deinit(p, pane, file); if (pane.ovl) |o| p.gpa.free(o.text); for (pane.ed_undo[0..pane.ed_undo_len]) |sn| if (sn.ovl) |o| p.gpa.free(o.text); for (pane.ed_redo[0..pane.ed_redo_len]) |sn| if (sn.ovl) |o| p.gpa.free(o.text); panes.Terminal.deinitEmulator(pane, p.gpa); pane.clearCwd(); p.gpa.destroy(pane); } fn reapPanes(p: *Pardes) void { for (&p.retired_panes.previous) |*slot| if (slot.*) |pane| { p.teardownPane(pane); slot.* = null; }; p.retired_panes.previous = p.retired_panes.current; p.retired_panes.current = @splat(null); } pub fn installPane(p: *Pardes, id: usize, pane: *Pane) void { std.debug.assert(p.panes[id] == null); p.next_serial += 1; pane.serial = p.next_serial; p.rects[id] = .{}; p.panes[id] = pane; } pub fn newShell(p: *Pardes, id: usize, cwd: []const u8) !*Pane { std.debug.assert(p.panes[id] == null); if (cwd.len > effect_path_cap) return error.PathTooLong; const pane = try panes.Terminal.create(p.gpa, p.screen_w, p.screen_h); panes.Terminal.armShellSpawn(pane); p.installPane(id, pane); p.emit(.{ .spawn = .{ .pane = @intCast(id), .cwd = .from(cwd) } }); return pane; } pub fn newDocPane(p: *Pardes, id: usize) !*Pane { std.debug.assert(p.panes[id] == null); const pane = try panes.Terminal.createDoc(p.gpa, p.screen_w, p.screen_h); p.installPane(id, pane); return pane; } fn newScratch(p: *Pardes, from_id: usize, free: usize) !*Pane { const src = p.panes[from_id] orelse return error.MissingPane; const content = try p.gpa.dupe(u8, ""); errdefer p.gpa.free(content); const np = try panes.Output.open(p, free, paneDir(src), .{ .cmd = .New }, "", content); np.cwd = .{ .inherited = src }; np.cur_pinned = true; return np; } /// New: a scratch below the calling pane, in its column. pub fn newScratchBelow(p: *Pardes, from_id: usize) void { const free = p.freeSlot() orelse return; const sf = layout.findPane(p, from_id) orelse return; const np = p.newScratch(from_id, free) catch return; layout.insert(p, sf.col, sf.idx + 1, free); layout.splitBelow(p, from_id, np); p.active = free; } /// Newcol: a scratch in a fresh column beside the calling pane. pub fn newScratchColumn(p: *Pardes, from_id: usize) void { const free = p.freeSlot() orelse return; if (!layout.canSplitColumn(p, from_id)) return; _ = p.newScratch(from_id, free) catch return; std.debug.assert(layout.splitColumn(p, from_id, free, false)); p.active = free; } pub fn freeSlot(p: *Pardes) ?usize { return for (p.panes, 0..) |slot, i| { if (slot == null and !p.reserved_slots[i]) break i; } else null; } /// The slot holding the pane with this serial, if it is still open. pub fn paneBySerial(p: *Pardes, serial: u32) ?usize { return for (p.panes, 0..) |slot, i| { if (slot) |pane| if (pane.serial == serial) break i; } else null; } fn noteLookSource(p: *Pardes, id: usize) void { const pane = p.panes[id] orelse return; var w: usize = 0; for (p.look_src[0..p.n_look_src]) |s| { if (s == pane.serial) continue; p.look_src[w] = s; w += 1; } if (w == p.look_src.len) { std.mem.copyForwards(u32, p.look_src[0 .. w - 1], p.look_src[1..w]); w -= 1; } p.look_src[w] = pane.serial; p.n_look_src = w + 1; p.look_walk_owner = pane.serial; } pub fn armLookWalk(p: *Pardes, id: usize) void { const pane = p.panes[id] orelse return; if (pane.file) |file| if (file.content.len == 0) return; p.look_walk_owner = pane.serial; } pub fn emitWrite(p: *Pardes, id: usize, bytes: []const u8) void { var off: usize = 0; // Anything already parked for this pane owns the stream's position, so // new bytes queue BEHIND it — emitting them now would reorder the pty. if (p.pending_write[id] == null) { while (off < bytes.len and p.effects_len < p.effects.len) { const n = @min(bytes.len - off, 64); p.emit(.{ .write = .{ .pane = @intCast(id), .bytes = .from(bytes[off .. off + n]) } }); off += n; } } if (off < bytes.len) p.parkPendingWrite(id, bytes[off..]); } fn parkPendingWrite(p: *Pardes, id: usize, bytes: []const u8) void { if (comptime limits.pending_write_cap == 0) return; if (p.pending_write_bytes + bytes.len > limits.pending_write_cap) return; if (p.pending_write[id]) |*pw| { // Compact what the drain already sent before growing: a burst that // arrives in pieces must not keep re-copying bytes nobody wants. const keep = pw.bytes.len - pw.off; const grown = p.gpa.alloc(u8, keep + bytes.len) catch return; @memcpy(grown[0..keep], pw.bytes[pw.off..]); @memcpy(grown[keep..], bytes); p.gpa.free(pw.bytes); pw.* = .{ .bytes = grown }; } else { p.pending_write[id] = .{ .bytes = p.gpa.dupe(u8, bytes) catch return }; } p.pending_write_bytes += bytes.len; } /// The shell reports the spawned pane's working directory (and later cwd /// changes it observes, e.g. via /proc//cwd before each frame). pub fn setCwd(p: *Pardes, id: usize, cwd: []const u8) void { const pane = p.panes[id] orelse return; const cur = switch (pane.cwd) { .owned => |dir| dir, else => "", }; if (std.mem.eql(u8, cur, cwd)) return; pane.setOwnedCwd(cwd) catch |err| return p.reportError(id, "directory", err); if (p.lookHoverPane() == id) p.cancelLookHover(); } fn takesCommandLine(p: *const Pardes, id: usize) bool { const pane = p.panes[id] orelse return false; if (!pane.isTerminal()) return false; // A mounted shell sits behind sudo's process supervisor. Its OSC 133 // prompt marks identify input readiness; the launcher's executable is // not the shell executable used by the ordinary process heuristic. if (pane.v9fs_on_spawn) return panes.Terminal.promptInputReady(pane); return !p.hostTtyTaken(id); } pub fn shellBin(p: *const Pardes) []const u8 { const selected = p.settings.shell.requested.get(); return if (selected.len == 0) config.default_shell else selected; } pub fn acknowledgeShell(p: *Pardes, id: usize, executable: []const u8, prompt_marks: bool) void { if (id < MAX_PANES) panes.Terminal.shellSpawned(p, id, prompt_marks); if (p.settings.shell.effective.set(executable)) p.settings.shell.pending = false; } pub fn takeFontRequest(p: *Pardes) ?[]const u8 { if (comptime !font_picker) return null; if (!p.settings.font.pending or p.font_request_taken) return null; const path = p.settings.font.requested_path.get(); if (path.len == 0) return null; p.font_request_taken = true; return path; } pub fn observeFont( p: *Pardes, effective_name: []const u8, effective_size_hundredths: u16, unit: config.Runtime.FontSizeUnit, ) bool { if (comptime !font_picker) return false; if (!p.settings.font.effective_name.set(effective_name)) return false; p.settings.font.effective_size_hundredths = effective_size_hundredths; p.settings.font.effective_size_unit = unit; return true; } /// Resolve the request already handed to the GUI host. Requested and /// effective tuples deliberately remain separate so Config can show both. pub fn acknowledgeFont( p: *Pardes, effective_name: []const u8, effective_size_hundredths: u16, unit: config.Runtime.FontSizeUnit, ) bool { if (comptime !font_picker) return false; if (!p.settings.font.pending or !p.font_request_taken) return false; const changed = !std.mem.eql( u8, p.settings.font.effective_name.get(), effective_name, ) or p.settings.font.effective_size_hundredths != effective_size_hundredths or p.settings.font.effective_size_unit != unit; if (!p.observeFont(effective_name, effective_size_hundredths, unit)) return false; p.settings.font.pending = false; p.font_request_taken = false; if (changed) p.abandonPanelAnimations(); return true; } pub fn rejectFont(p: *Pardes) void { if (comptime !font_picker) return; if (!p.settings.font.pending or !p.font_request_taken) return; p.settings.font.pending = false; p.font_request_taken = false; } fn nextThemeFileGeneration(p: *Pardes) u32 { p.theme_file_generation +%= 1; if (p.theme_file_generation == 0) p.theme_file_generation = 1; return p.theme_file_generation; } pub fn requestThemeFile(p: *Pardes, id: usize, argument: []const u8) void { if (comptime !hosted) return; const input = std.mem.trim(u8, argument, " \t\r\n"); if (input.len == 0 or !std.mem.endsWith(u8, input, ".zon")) { p.reportError(id, "theme file", error.InvalidThemePath); return; } const absolute = std.fs.path.isAbsolute(input); if (!absolute and p.opts.config_dir == null) { p.reportError(id, "theme file", error.NoConfigDirectory); return; } var joined: [4095]u8 = undefined; const resolved = if (absolute) input else path: { const dir = p.opts.config_dir.?; const separator = std.Io.Dir.path.sep; const needs_separator = dir.len > 0 and dir[dir.len - 1] != separator; const len = dir.len + @intFromBool(needs_separator) + input.len; if (len > joined.len) { p.reportError(id, "theme file", error.PathTooLong); return; } @memcpy(joined[0..dir.len], dir); var at = dir.len; if (needs_separator) { joined[at] = separator; at += 1; } @memcpy(joined[at..][0..input.len], input); break :path joined[0..len]; }; if (!p.theme_file_path.set(resolved)) { p.reportError(id, "theme file", error.PathTooLong); return; } p.theme_file_pane = @intCast(@min(id, MAX_PANES - 1)); const generation = p.nextThemeFileGeneration(); p.emit(.{ .theme_file = .{ .generation = generation, .on = true } }); } pub const ThemeFileRequest = struct { path: []const u8, pane: u8, }; /// Borrow the current request while a native host performs its synchronous /// read. A later command invalidates an already-queued generation. pub fn themeFileRequest(p: *const Pardes, generation: u32) ?ThemeFileRequest { if (generation == 0 or generation != p.theme_file_generation) return null; const path = p.theme_file_path.get(); if (path.len == 0) return null; return .{ .path = path, .pane = p.theme_file_pane }; } pub fn failThemeFile(p: *Pardes, generation: u32, err: anyerror) void { const request = p.themeFileRequest(generation) orelse return; p.reportError(request.pane, "theme file", err); } pub fn loadThemeFile(p: *Pardes, generation: u32, bytes: []const u8, animate: bool) bool { // ZON's generated field dispatch grows with the complete theme schema. @setEvalBranchQuota(10000); const request = p.themeFileRequest(generation) orelse return false; if (bytes.len > 1024 * 1024) { p.reportError(request.pane, "theme file", error.ThemeFileTooLarge); return false; } const source = p.gpa.dupeZ(u8, bytes) catch |err| { p.reportError(request.pane, "theme file", err); return false; }; defer p.gpa.free(source); var diagnostics: std.zon.parse.Diagnostics = .{}; defer diagnostics.deinit(p.gpa); const parsed = std.zon.parse.fromSliceAlloc(Theme, p.gpa, source, &diagnostics, .{}) catch |err| { p.reportError(request.pane, "theme file", err); return false; }; if (parsed.name.len == 0 or parsed.name.len > 255) { std.zon.parse.free(p.gpa, parsed); p.reportError(request.pane, "theme file", error.InvalidThemeName); return false; } const target_chrome = ChromeTheme.fromTheme(&parsed); if (animate and p.animate_theme_changes) p.chrome_animation.retarget(target_chrome) else p.chrome_animation.snap(target_chrome); const old = p.custom_theme; p.custom_theme = parsed; if (old) |theme_value| std.zon.parse.free(p.gpa, theme_value); p.invalidateThemeDependentRasters(); _ = p.scratch.reset(.retain_capacity); p.sync(); return true; } pub const Message = struct { const libc = std.c; const Tm = extern struct { sec: c_int, min: c_int, hour: c_int, mday: c_int, mon: c_int, year: c_int, wday: c_int, yday: c_int, isdst: c_int, gmtoff: c_long, zone: ?[*:0]const u8, }; extern "c" fn localtime_r(timep: *const libc.time_t, result: *Tm) ?*Tm; pub fn body(text: []const u8) []const u8 { if (text.len < 10) return text; if (text[2] != ':' or text[5] != ':' or text[8] != ' ' or text[9] != ' ') return text; for ([_]usize{ 0, 1, 3, 4, 6, 7 }) |i| { if (!std.ascii.isDigit(text[i]) and text[i] != '-') return text; } return text[10..]; } pub fn stamp(buf: []u8, verb: []const u8, subject: []const u8) []const u8 { var clock: [8]u8 = "--:--:--".*; const notime = if (libc.getenv("PARDES_NOTIME")) |v| std.mem.span(v).len != 0 else false; if (!notime) { var ts: libc.timespec = undefined; _ = libc.clock_gettime(.REALTIME, &ts); const secs: libc.time_t = ts.sec; var tm: Tm = undefined; if (localtime_r(&secs, &tm) != null) { _ = std.fmt.bufPrint(&clock, "{d:0>2}:{d:0>2}:{d:0>2}", .{ @as(u32, @intCast(tm.hour)), @as(u32, @intCast(tm.min)), @as(u32, @intCast(tm.sec)), }) catch {}; } } const head = std.fmt.bufPrint(buf, "{s} {s} ", .{ &clock, verb }) catch return buf[0..0]; const room = buf.len - head.len; const tail = if (subject.len <= room) subject else subject[subject.len - room ..]; @memcpy(buf[head.len..][0..tail.len], tail); return buf[0 .. head.len + tail.len]; } }; pub fn setStatus(p: *Pardes, id: usize, text: []const u8) void { p.showMessage(id, text); } pub fn setMessage(p: *Pardes, id: usize, text: []const u8) void { p.logMessage(id, text); p.showMessage(id, text); } fn showMessage(p: *Pardes, id: usize, text: []const u8) void { if (id >= MAX_PANES) return; const pane = p.panes[id] orelse return; pane.msg_len = @intCast(@min(text.len, pane.msg.len)); @memcpy(pane.msg[0..pane.msg_len], text[0..pane.msg_len]); } fn logMessage(p: *Pardes, id: usize, text: []const u8) void { if (text.len == 0) return; const pane: u8 = if (id < MAX_PANES) @intCast(id) else 0xff; const kept = text[0..@min(text.len, LoggedMessage.cap)]; if (p.messages_len > 0) { const last = &p.messages[(p.messages_head + limits.message_log - 1) % limits.message_log]; if (last.pane == pane and std.mem.eql(u8, Message.body(last.slice()), Message.body(kept))) { // The NEWEST wording wins, so the row carries the latest clock // rather than the moment the run started. last.len = @intCast(kept.len); @memcpy(last.text[0..last.len], kept); last.repeats +|= 1; return; } } const slot = &p.messages[p.messages_head]; slot.len = @intCast(kept.len); @memcpy(slot.text[0..slot.len], kept); slot.pane = pane; slot.repeats = 1; p.messages_head = (p.messages_head + 1) % limits.message_log; p.messages_len = @min(p.messages_len + 1, limits.message_log); } /// The log oldest-first, which is reading order. pub fn messageLog(p: *const Pardes, i: usize) ?*const LoggedMessage { if (i >= p.messages_len) return null; const first = (p.messages_head + limits.message_log - p.messages_len) % limits.message_log; return &p.messages[(first + i) % limits.message_log]; } pub fn reportError(p: *Pardes, id: usize, operation: []const u8, err: anyerror) void { var buf: [256]u8 = undefined; const text = std.fmt.bufPrint(&buf, "{s}: {s}", .{ operation, @errorName(err) }) catch operation; p.setMessage(id, text); } fn applyWatchedFileChanged(p: *Pardes, id: u8, bytes: []const u8) bool { if (id >= MAX_PANES) return false; const pane = p.panes[id] orelse return false; if (comptime pdf_enabled) if (pane.pdf != null) { panes.Pdf.reloadWatched(p, pane) catch |err| { p.reportError(id, "PDF reload", err); return false; }; return true; }; if (pane.file == null) return false; panes.File.changed(p, id, bytes); return if (p.panes[id]) |current| if (current.file) |file| std.mem.eql(u8, file.content, bytes) else false else false; } pub fn reloadWatchedFile(p: *Pardes, id: u8, bytes: []const u8) bool { p.invalidateLookHover(id); const applied = p.applyWatchedFileChanged(id, bytes); _ = p.scratch.reset(.retain_capacity); p.sync(); return applied; } pub fn invalidateLookHover(p: *Pardes, id: usize) void { if (p.lookHoverPane() != id) return; p.raw_hover_intent = false; p.cancelLookHover(); } pub fn emit(p: *Pardes, e: Effect) void { if (p.effects_len == p.effects.len) return; const tail = (p.effects_head + p.effects_len) % p.effects.len; p.effects[tail] = e; p.effects_len += 1; } pub fn nextEffect(p: *Pardes) ?Effect { p.refillPendingWrites(); if (p.effects_len == 0) { p.effects_head = 0; return null; } const e = p.effects[p.effects_head]; p.effects_head = (p.effects_head + 1) % p.effects.len; p.effects_len -= 1; return e; } /// Move parked pty bytes into whatever room the ring now has, oldest pane /// slot first. Costs one comparison when nothing is parked. fn refillPendingWrites(p: *Pardes) void { if (p.pending_write_bytes == 0) return; for (&p.pending_write, 0..) |*slot, id| { while (p.effects_len < p.effects.len) { // `|*pw|` points INTO the slot: capturing by value would advance // a copy's cursor and re-send the same chunk for ever. const pw = if (slot.*) |*live| live else break; const n = @min(pw.bytes.len - pw.off, 64); p.emit(.{ .write = .{ .pane = @intCast(id), .bytes = .from(pw.bytes[pw.off..][0..n]) } }); pw.off += n; p.pending_write_bytes -= n; if (pw.off == pw.bytes.len) { p.gpa.free(pw.bytes); slot.* = null; break; } } if (p.effects_len == p.effects.len) return; } } /// Drop a pane's parked bytes: its pty is gone, and the slot it occupied /// may be handed to a different pane next frame. fn dropPendingWrite(p: *Pardes, id: usize) void { const pw = p.pending_write[id] orelse return; p.pending_write_bytes -= pw.bytes.len - pw.off; p.gpa.free(pw.bytes); p.pending_write[id] = null; } pub fn postEvent(p: *Pardes, ev: Event) void { switch (ev) { .key => |k| std.debug.assert(k.text.len == 0), .output, .paste, .lsp_resp, .pipe_resp, .file_changed, .command, .fs_req => unreachable, else => {}, } if (p.in_len == p.in_q.len) return; p.in_q[(p.in_head + p.in_len) % p.in_q.len] = ev; p.in_len += 1; } fn nextQueued(p: *Pardes) ?Event { if (p.in_len == 0) { p.in_head = 0; return null; } const ev = p.in_q[p.in_head]; p.in_head = (p.in_head + 1) % p.in_q.len; p.in_len -= 1; return ev; } pub fn hostTtyTaken(p: *const Pardes, id: usize) bool { const f = p.host.vtable.tty_taken orelse return false; return f(p.host.ctx, @intCast(id)); } fn hostWriteFile(p: *Pardes, pane: u8, path: []const u8, bytes: []const u8) void { if (p.host.vtable.write_file) |f| return f(p.host.ctx, pane, path, bytes); // The in-process filesystem reports the same way a real host does, so // an OOM here leaves the pane dirty rather than looking saved. if (!p.fallback.writeFile(path, bytes)) p.saveFailed(pane, "save", error.OutOfMemory); } pub fn saveFailed(p: *Pardes, id: u8, what: []const u8, err: anyerror) void { if (p.panes[id]) |pane| if (pane.file) |*f| { // The `-%` spelling fs.zig already uses for "make this dirty". f.saved_revision = f.revision -% 1; }; p.reportError(id, what, err); } /// The path a watch is about: a real file's, or a PDF's. fn watchPath(p: *const Pardes, id: u8) ?[]const u8 { const pane = p.panes[id] orelse return null; if (pane.file) |f| return if (f.output == null) f.path else null; return pane.pdfPath(); } pub fn fsPayload(p: *const Pardes, r: filesystem.Reply) []const u8 { return switch (r.payload) { .none => &.{}, .staged => |n| p.fs.out.items[0..@min(n, p.fs.out.items.len)], .region => |g| region: { const pane = p.panes[g.pane] orelse break :region &.{}; if (pane.serial != g.serial) break :region &.{}; const text = if (pane.file) |*f| f.content else break :region &.{}; const lo = @min(g.off, text.len); break :region text[lo..@min(lo + g.len, text.len)]; }, }; } pub fn perform(p: *Pardes, e: Effect) void { const v = p.host.vtable; switch (e) { .spawn => |s| if (v.spawn) |f| f(p.host.ctx, s.pane, s.cwd.slice()) else { p.fallback.spawned[s.pane] = true; }, // A pane with no child is silent: nothing invents output on its // screen, and the bytes are dropped rather than transcribed. .write => |w| if (v.pty_write) |f| f(p.host.ctx, w.pane, w.bytes.slice()), .resize_pty => |r| if (v.pty_resize) |f| f(p.host.ctx, r.pane, r.cols, r.rows), .signal_pty => |s| if (v.pty_signal) |f| f(p.host.ctx, s.pane, s.sig), .open_link => |u| if (v.open_link) |f| f(p.host.ctx, u.slice()) else p.fallback.setLink(u.slice()), .save_file => |sf| { const pane = p.panes[sf.pane] orelse return; const f = if (pane.file) |*file| file else return; const serial = pane.serial; f.saved_revision = f.revision; p.hostWriteFile(sf.pane, f.path, f.content); const saved_pane = p.panes[sf.pane] orelse return; if (saved_pane.serial != serial) return; const saved = if (saved_pane.file) |*file| file else return; if (saved.saved_revision != saved.revision or !saved.watch_after_save) return; saved.watch_after_save = false; if (filesystem.localPath(saved.path) != null) p.emit(.{ .watch = .{ .pane = sf.pane, .on = true } }); }, .save_text => |st| { const pane = p.panes[st.pane] orelse return; if (pane.serial != st.serial) return; // a recycled slot: not ours if (pane.file) |f| return p.hostWriteFile(st.pane, st.path.slice(), f.content); if (!pane.isTerminal()) return; const text = panes.Terminal.screenTextAlloc(pane, p.gpa) catch return; defer p.gpa.free(text); p.hostWriteFile(st.pane, st.path.slice(), text); }, .write_dump => { const out = p.dump_out orelse return; if (v.write_dump) |f| { f(p.host.ctx, out); } else { _ = p.fallback.writeFile(host_io.Fallback.dump_path, out); p.setLastDump(host_io.Fallback.dump_path); } }, .set_clipboard => { const text = p.yank orelse ""; if (v.set_clipboard) |f| f(p.host.ctx, text) else p.fallback.setClipboard(text); }, // No desktop to ask: answer from the in-process clipboard at once, // which is the same shape as a host answering later. .read_clipboard => if (v.read_clipboard) |f| f(p.host.ctx) else p.update(.{ .paste = p.fallback.clipboard.items }), .lsp => |q| if (v.lsp) |f| f(p.host.ctx, .{ .id = q.id, .kind = q.kind, .pane = q.pane, .offset = q.offset, .arg = q.arg.slice() }) else p.update(.{ .lsp_resp = .{ .id = q.id, .rows = "" } }), .pipe => |q| if (v.pipe) |f| f(p.host.ctx, q.id) else p.update(.{ .pipe_resp = .{ .id = q.id, .success = false, .outputs = &.{} } }), .watch => |w| if (v.watch_file) |f| f(p.host.ctx, w.pane, p.watchPath(w.pane) orelse "", w.on, w.mode) else { p.fallback.watched[w.pane] = w.on; }, .theme_file => |t| if (v.watch_theme) |f| f(p.host.ctx, t.generation, t.on), .dump_themes => |d| if (v.dump_themes) |f| f(p.host.ctx, d.pane), .fs_reply => {}, .attach => |a| { const name = a.name.slice(); @memcpy(p.attach_buf[0..name.len], name); p.attach_req = .{ .pane = a.pane, .name = p.attach_buf[0..name.len] }; }, .detach => |d| if (v.detach) |f| f(p.host.ctx) else p.reportError(d.pane, "detach", error.NotAttached), // the loop's own condition; a host tears down after its own loop .quit => p.quit = true, } } pub fn pump(p: *Pardes, h: Host) !void { p.host = h; const v = h.vtable; if (v.wait_input) |f| f(h.ctx, if (p.animationActive()) layout.Animation.frame_ms else 0); while (p.nextQueued()) |ev| p.update(ev); while (p.nextEffect()) |e| p.perform(e); // A quitting frame has already freed what it would draw. if (p.quit) return; if (v.poll_frame) |f| f(h.ctx); _ = p.frame_arena.reset(.retain_capacity); const surface = try p.render(p.frame_arena.allocator()); if (v.present) |f| f(h.ctx, surface); if (v.post_present) |f| f(h.ctx); } pub fn update(p: *Pardes, ev: Event) void { p.shell_rows.sweep(p.gpa); switch (ev) { .tick => {}, .fs_req => |req| if (req.changesPane()) { p.raw_hover_intent = false; p.cancelLookHover(); }, .mouse => |m| if (!(m.button == .none and m.kind == .motion)) { p.raw_hover_intent = false; p.cancelLookHover(); }, .output => |o| if (p.lookHoverPane() == o.pane) { p.raw_hover_intent = false; p.cancelLookHover(); }, .file_changed => |changed| if (p.lookHoverPane() == changed.pane) { p.raw_hover_intent = false; p.cancelLookHover(); }, .pdf_scroll => |scroll| if (p.lookHoverPane() == scroll.pane) { p.raw_hover_intent = false; p.cancelLookHover(); }, else => { p.raw_hover_intent = false; p.cancelLookHover(); }, } switch (ev) { .key, .mouse => { for (p.panes) |slot| { if (slot) |pane| pane.msg_len = 0; } p.clip_pending = null; }, else => {}, } if (p.fs.listeners != 0) p.fs.origin = switch (ev) { .key => 'K', .mouse => 'M', else => p.fs.origin, }; switch (ev) { .resize => |sz| { p.presentation.snap_once = true; if (sz.row_metrics) |metrics| p.row_metrics = metrics; if (comptime pdf_enabled) { var before: [MAX_PANES]?panes.Pdf.Viewport = @splat(null); for (p.panes, 0..) |slot, id| { const pane = slot orelse continue; if (pane.pdf != null) before[id] = panes.Pdf.paneViewport(p, pane); } p.screen_w = sz.cols; p.screen_h = sz.rows; p.cell_pixels.w = @max(1, sz.cell_pixels.w); p.cell_pixels.h = @max(1, sz.cell_pixels.h); layout.compute(p); for (p.panes, 0..) |slot, id| { const pane = slot orelse continue; if (pane.pdf) |*pv| { if (!std.meta.eql(before[id], panes.Pdf.paneViewport(p, pane))) { panes.Pdf.captureLayoutAnchor(pv); pv.layout_valid = false; pv.search_reveal_pending = pv.search_query.len > 0; } } } } else { p.screen_w = sz.cols; p.screen_h = sz.rows; } p.resize_count += 1; }, .output => |o| { const pane = p.panes[o.pane] orelse return; filesystem.notePtyOutput(p, o.pane, o.bytes); panes.Terminal.feedOutput(p, pane, o.bytes); }, .eof => |e| p.removePane(e.pane) catch |err| { if (p.panes[e.pane]) |pane| pane.mode = .normal; p.reportError(e.pane, "terminal exited; Del retries close", err); }, .lsp_resp => |r| p.lspResponse(r.id, r.rows), .pipe_resp => |r| p.pipeResponse(r.id, r.success, r.outputs, r.failure), .file_changed => |fc| _ = p.applyWatchedFileChanged(fc.pane, fc.bytes), .key => |key| p.handleKey(key), .mouse => |m| { if (m.kind == .release) switch (p.drag) { .border_v, .border_h, .move => p.presentation.snap_once = true, else => {}, }; p.handleMouse(m); }, .pdf_scroll => |scroll| { if (comptime pdf_enabled) { if (scroll.pane < MAX_PANES) { if (p.panes[scroll.pane]) |pane| { if (pane.hasPdf() and p.native_images) _ = panes.Pdf.scrollPane(p, pane, @floatCast(scroll.delta_pixels)); } } } }, .paste => |bytes| p.applyPaste(bytes), .command => |line| _ = p.executeBuiltinLine(p.active, line), .fs_req => |r| p.emit(.{ .fs_reply = filesystem.handle(p, r) }), .pinch => |scale| p.ov_pinch_scale = scale, .touch_scroll => |delta| p.ov_touch_scroll_delta = delta, .pointer_leave => { p.pointer_inside = false; p.pointer_body_hit = null; p.pointer_tag_hit = null; }, .tick => { p.chrome_animation.advance(); p.presentation.advance(); p.advanceLookHover(); }, } _ = p.scratch.reset(.retain_capacity); p.sync(); p.fsReport(); } fn fsReport(p: *Pardes) void { if (p.fs.listeners == 0) return; for (p.panes, 0..) |slot, id| { const pane = slot orelse continue; if (!p.fs.scripted(id)) continue; const tag = p.tagText(p.scratch.allocator(), pane) catch continue; const snap = &p.fs.panes[id].tag_snap; if (std.mem.eql(u8, snap.items, tag)) continue; if (snap.capacity != 0 or snap.items.len != 0) filesystem.noteReplace(p, id, true, snap.items, tag); snap.clearRetainingCapacity(); snap.appendSlice(p.gpa, tag) catch {}; } } fn setYank(p: *Pardes, text: []const u8) void { if (p.multi_on and !p.multi_first) { const old = p.yank orelse ""; const sep: []const u8 = if (text.len > 0 and text[text.len - 1] == '\n') "" else "\n"; const joined = std.fmt.allocPrint(p.gpa, "{s}{s}{s}", .{ text, sep, old }) catch return; if (p.yank) |y| p.gpa.free(y); p.yank = joined; return; } if (p.yank) |y| p.gpa.free(y); p.yank = p.gpa.dupe(u8, text) catch null; } fn setClipboard(p: *Pardes, text: []const u8) void { p.setYank(text); if (!p.multi_on or p.multi_first) p.emit(.{ .set_clipboard = {} }); } /// Where a `SPC p` / `SPC P` / `SPC R` goes once the shell answers. pub const ClipRequest = struct { pane: usize, serial: u32, mode: enum { after, before, replace }, }; pub fn clipRequest(p: *Pardes, id: usize, mode: @FieldType(ClipRequest, "mode")) void { const pane = p.panes[id] orelse return; p.clip_pending = .{ .pane = id, .serial = pane.serial, .mode = mode }; p.emit(.read_clipboard); } pub fn typeToTty(p: *Pardes, id: usize, pane: *const Pane, text: []const u8) void { if (text.len == 0) return; if (panes.Terminal.bracketedPaste(pane)) { p.emitWrite(id, "\x1b[200~"); p.emitWrite(id, text); p.emitWrite(id, "\x1b[201~"); return; } const cp = p.scratch.allocator().dupe(u8, text) catch return; for (cp) |*ch| { if (ch.* == '\n') ch.* = '\r'; } p.emitWrite(id, cp); } fn applyPaste(p: *Pardes, bytes: []const u8) void { if (p.topbar_col != null) { p.clip_pending = null; p.headerReplace(bytes); return; } const req = p.clip_pending; p.clip_pending = null; if (bytes.len == 0) return; const id = if (req) |r| r.pane else p.active; const pane = p.panes[id] orelse return; if (req) |r| if (pane.serial != r.serial) return; p.active = id; if (pane.tag_edit) { const text = p.tagText(p.scratch.allocator(), pane) catch return; const range: @TypeOf(tagSelBounds(pane)) = if (pane.tag_sel) tagSelBounds(pane) else .{ .lo = pane.tag_col, .hi = pane.tag_col }; const hi = if (pane.tag_sel) modal.nextGrapheme(text, range.hi) else range.hi; _ = p.replaceTagText(pane, range.lo, hi, bytes); if (selRegexArmed(pane)) |a| p.applySelRegex(pane, a.pat, a.split); return; } if (pane.isTerminal() and pane.mode == .tty) return p.typeToTty(id, pane, bytes); switch (if (req) |r| r.mode else .after) { .after => p.pasteText(pane, bytes, false, 1), .before => p.pasteText(pane, bytes, true, 1), .replace => p.replaceWithText(pane, bytes), } } pub fn clipYank(p: *Pardes, pane: *Pane, main_only: bool) void { if (comptime pdf_enabled) if (pane.pdf) |pv| { if (pv.selection_text.len > 0) p.setClipboard(pv.selection_text); return; }; if (pane.nsel > 0 and !main_only) { p.replaySels(pane, .{ .normal = .{ .edit = .{ .kind = .yank, .count = 1 } } }); } else { const others = pane.nsel; pane.nsel = 0; p.normalYank(pane); pane.nsel = others; } p.emit(.{ .set_clipboard = {} }); } fn multiOnce(p: *Pardes) bool { if (!p.multi_on) return true; p.multi_stop = true; return p.multi_first; } // ---- tag + selection text (chord sources) ---- pub fn tagPrefix(p: *Pardes, pane: *Pane) ![]u8 { const arena = p.scratch.allocator(); if (comptime pdf_enabled) if (pane.pdf) |pv| return std.fmt.allocPrint( arena, "{s} [{d}/{d}]", .{ pv.path, pv.page + 1, pv.page_count }, ); if (pane.image) |*state| return panes.Image.tagPrefix(arena, state); if (pane.file) |f| { const name = if (pane.tag_name_len) |len| pane.tag_name[0..len] else f.path; if (panes.Output.fileTraits(f.output).saves and f.revision != f.saved_revision) return std.fmt.allocPrint(arena, "{s}{s}", .{ name, dirty_marker }); return arena.dupe(u8, name); } return arena.dupe(u8, pane.cwdSlice()); } /// the editable tail: the user's edited buffer once touched, else defaults fn curTail(pane: *Pane) []const u8 { if (pane.tag_init) return pane.tagSlice(); return defaultTail(pane); } fn defaultTail(pane: *const Pane) []const u8 { if (comptime pdf_enabled) if (pane.pdf != null) return pdf_pane_tail; if (pane.file) |file| if (file.output) |out| if (panes.Output.usesLocationsConfig(out.from)) return locations_pane_tail; if (panes.File.supportsContext(pane)) return context_pane_tail; if (pane.file != null) return file_pane_tail; if (pane.isTerminal()) return terminal_pane_tail; return pane_tail; } fn paneIdOf(p: *const Pardes, pane: *const Pane) ?usize { for (p.panes, 0..) |slot, id| if (slot) |sp| { if (sp == pane) return id; }; return null; } pub fn tagText(p: *Pardes, arena: std.mem.Allocator, pane: *Pane) ![]u8 { const prefix = try p.tagPrefix(pane); const tail = curTail(pane); const out = try arena.alloc(u8, prefix.len + tail.len); @memcpy(out[0..prefix.len], prefix); @memcpy(out[prefix.len..], tail); return out; } fn tagTintRange(pane: *const Pane, tag: []const u8, prefix_len: usize) ?struct { lo: usize, hi: usize } { if (pane.isTerminal()) { var words = std.mem.tokenizeAny(u8, tag[prefix_len..], " \t\r\n"); while (words.next()) |word| { if (!std.mem.eql(u8, word, "Tty")) continue; const lo = @intFromPtr(word.ptr) - @intFromPtr(tag.ptr); return .{ .lo = lo, .hi = lo + word.len }; } return null; } var start: usize = 0; const name = if (pane.file) |file| if (pane.tag_name_len) |len| pane.tag_name[0..len] else file.path else if (pane.image) |state| blk: { // Image tags put renderer controls before the path. start = prefix_len -| state.path.len; break :blk state.path; } else if (comptime pdf_enabled) blk: { break :blk if (pane.pdf) |state| state.path else return null; } else return null; const basename = if (std.mem.lastIndexOfScalar(u8, name, '/')) |slash| slash + 1 else 0; if (basename == name.len) return null; return .{ .lo = start + basename, .hi = start + name.len }; } pub fn seedTail(_: *Pardes, pane: *Pane) void { if (pane.tag_init) return; const tail = curTail(pane); if (tail.len > pane.tag_tail.len) return; @memcpy(pane.tag_tail[0..tail.len], tail); pane.tag_tail_len = tail.len; pane.tag_init = true; } fn enterTagEdit(p: *Pardes, pane: *Pane, col: i32) void { if (pane.tag_edit and pane.prompt == .none) pane.tag_saved_col = pane.tag_col; const edit0: i32 = @intCast((p.tagPrefix(pane) catch return).len); p.seedTail(pane); if (!pane.tag_init) return; if (!pane.tag_edit) pane.tag_mode = pane.mode; pane.tag_edit = true; pane.tag_sel = false; // a one-line tag has no use for normal mode: always insert pane.mode = .insert; pane.normal.clear(); const end = edit0 + @as(i32, @intCast(pane.tag_tail_len)); const tail = pane.tagSlice(); const lead: i32 = @intCast(tail.len - std.mem.trimStart(u8, tail, " ").len); if (col < 0) { const text = p.tagText(p.scratch.allocator(), pane) catch return; if (pane.tag_saved_col) |saved| { pane.tag_col = @intCast(modal.graphemeStart(text, @min(saved, text.len))); } else { pane.tag_col = @intCast(@min(edit0 + lead, end)); } if (pane.tag_saved_col == null) if (p.paneIdOf(pane)) |id| { const width = p.tagCapacity(p.rects[id].w -| config.GUTTER); // Reveal the suffix together with its commands, not merely the // first command letter at the viewport's far right edge. pane.tag_scroll = @intCast(panes.File.displayWidth(text) + 1 -| width); }; } else { const text = p.tagText(p.scratch.allocator(), pane) catch return; pane.tag_col = @intCast(@min(text.len, panes.File.rawAtDisplay(text, @as(usize, @intCast(col)) + pane.tag_scroll))); } } fn exitTagEdit(pane: *Pane) void { if (pane.tag_edit and pane.prompt == .none) pane.tag_saved_col = pane.tag_col; if (pane.isTerminal()) { if (pane.tag_edit) pane.mode = pane.tag_mode; } else pane.mode = .normal; pane.tag_edit = false; pane.tag_sel = false; pane.tag_name_len = null; pane.tag_scroll = 0; pane.prompt = .none; // an abandoned prompt stays as tag text pane.nsel_snap = 0; // ...and its s/S preview keeps whatever it previewed } fn tagSelBounds(pane: *Pane) struct { lo: usize, hi: usize } { const a: usize = pane.tag_anchor; const c: usize = pane.tag_col; return .{ .lo = @min(a, c), .hi = @max(a, c) }; } fn tagChordText(p: *Pardes, pane: *Pane) ?[]const u8 { const text = p.tagText(p.scratch.allocator(), pane) catch return null; if (pane.tag_sel) { const b = tagSelBounds(pane); const hi = modal.nextGrapheme(text, b.hi); return if (hi > b.lo) text[b.lo..hi] else null; } const b = config.wordBounds(text, @min(@as(usize, pane.tag_col), text.len)); return if (b.hi > b.lo) text[b.lo..b.hi] else null; } fn tagNameEnd(pane: *const Pane) ?usize { const f = pane.file orelse return null; if (!panes.Output.fileTraits(f.output).saves or f.path.len > pane.tag_name.len) return null; return if (pane.tag_name_len) |len| len else f.path.len; } /// Commit a staged buffer name, never a disk rename or write. Keep the /// draft visible on failure so the user can correct it or cancel with Esc. pub fn commitTagName(p: *Pardes, pane: *Pane) bool { const len = pane.tag_name_len orelse return true; const id = p.paneIdOf(pane) orelse return false; const name = pane.tag_name[0..len]; if (name.len == 0 or name[name.len - 1] == '/' or !std.unicode.utf8ValidateSlice(name)) { p.reportError(id, "name", error.InvalidName); return false; } for (name) |byte| if (byte < ' ') { p.reportError(id, "name", error.InvalidName); return false; }; const old_prefix = (p.tagPrefix(pane) catch return false).len; const tail_col = @as(usize, pane.tag_col) -| old_prefix; const full = std.fs.path.resolvePosix(p.scratch.allocator(), &.{ paneDir(pane), name }) catch |err| { p.reportError(id, "name", err); return false; }; filesystem.nameBuffer(p, id, full, true) catch |err| { p.reportError(id, "name", err); return false; }; pane.tag_name_len = null; const prefix = (p.tagPrefix(pane) catch return false).len; pane.tag_col = @intCast(prefix + @min(tail_col, pane.tag_tail_len)); pane.tag_sel = false; return true; } /// Replace one range in the rendered tag. The file name and command tail /// retain separate identities even when a selection crosses their boundary. fn replaceTagText(p: *Pardes, pane: *Pane, start: usize, finish: usize, input: []const u8) bool { const prefix_len = (p.tagPrefix(pane) catch return false).len; const end = prefix_len + pane.tag_tail_len; const lo = @min(start, end); const hi = @max(lo, @min(finish, end)); const name_end = tagNameEnd(pane); const editing_name = if (name_end) |n| lo <= n else false; if (!editing_name and lo < prefix_len) return false; var bytes: [limits.max_tag_tail]u8 = undefined; var count: usize = 0; for (input) |byte| { if (byte == '\n' or byte == '\r') continue; if (count == bytes.len) return false; bytes[count] = byte; count += 1; } const tail_lo = lo -| prefix_len; const tail_hi = hi -| prefix_len; const tail_insert = if (editing_name) 0 else count; const tail_len = pane.tag_tail_len - (tail_hi - tail_lo) + tail_insert; if (tail_len > pane.tag_tail.len) return false; if (editing_name) { const n = name_end.?; const name_hi = @min(hi, n); const name_len = n - (name_hi - lo) + count; if (name_len > pane.tag_name.len) return false; if (pane.tag_name_len == null) @memcpy(pane.tag_name[0..n], pane.file.?.path); if (count > name_hi - lo) std.mem.copyBackwards(u8, pane.tag_name[lo + count .. name_len], pane.tag_name[name_hi..n]) else std.mem.copyForwards(u8, pane.tag_name[lo + count .. name_len], pane.tag_name[name_hi..n]); @memcpy(pane.tag_name[lo..][0..count], bytes[0..count]); pane.tag_name_len = @intCast(name_len); } if (tail_insert > tail_hi - tail_lo) std.mem.copyBackwards(u8, pane.tag_tail[tail_lo + tail_insert .. tail_len], pane.tag_tail[tail_hi..pane.tag_tail_len]) else std.mem.copyForwards(u8, pane.tag_tail[tail_lo + tail_insert .. tail_len], pane.tag_tail[tail_hi..pane.tag_tail_len]); if (!editing_name) @memcpy(pane.tag_tail[tail_lo..][0..count], bytes[0..count]); pane.tag_tail_len = tail_len; pane.tag_col = @intCast(lo + count); pane.tag_sel = false; return true; } fn tagInsertKey(p: *Pardes, pane: *Pane, key: Key) void { if (hit(key, config.escape)) { exitTagEdit(pane); // the tag is ALWAYS insert; Esc leaves it return; } // the acme chords on the selection or the word at the cursor if (hit(key, config.look_key) or hit(key, config.exec_key)) { if (pane.tag_name_len != null) { const in_name = pane.tag_col < (p.tagPrefix(pane) catch return).len; if (!p.commitTagName(pane)) return; if (hit(key, config.look_key) or in_name) { exitTagEdit(pane); return; } } if (p.tagChordText(pane)) |txt| { pane.tag_sel = false; p.runBuiltin(if (hit(key, config.look_key)) config.look_cmd else config.exec_cmd, p.active, "", txt); } return; } const text = p.tagText(p.scratch.allocator(), pane) catch return; const end = text.len; if (key.text.len > 0) { const range: @TypeOf(tagSelBounds(pane)) = if (pane.tag_sel) tagSelBounds(pane) else .{ .lo = pane.tag_col, .hi = pane.tag_col }; const hi = if (pane.tag_sel) modal.nextGrapheme(text, range.hi) else range.hi; _ = p.replaceTagText(pane, range.lo, hi, key.text); return; } switch (key.cp) { Key.backspace, Key.delete => { if (pane.tag_sel) { const range = tagSelBounds(pane); _ = p.replaceTagText(pane, range.lo, modal.nextGrapheme(text, range.hi), ""); } else if (key.cp == Key.backspace and pane.tag_col > 0) { _ = p.replaceTagText(pane, modal.prevGrapheme(text, pane.tag_col), pane.tag_col, ""); } else if (key.cp == Key.delete and pane.tag_col < end) { _ = p.replaceTagText(pane, pane.tag_col, modal.nextGrapheme(text, pane.tag_col), ""); } }, Key.left => if (pane.tag_col > 0) { pane.tag_col = @intCast(modal.prevGrapheme(text, pane.tag_col)); }, Key.right => if (pane.tag_col < end) { pane.tag_col = @intCast(modal.nextGrapheme(text, pane.tag_col)); }, Key.home => pane.tag_col = 0, Key.end => pane.tag_col = @intCast(end), else => {}, } } /// write a helix range back onto the tag cursor + selection: the rendered /// tag's one-line mirror of setPaneRange. fn setTagRange(pane: *Pane, text: []const u8, r: modal.Selection) void { const lo = @min(r.anchor, r.head); const hi = @max(r.anchor, r.head); pane.tag_col = @intCast(modal.selectionCursor(text, r)); pane.tag_sel = modal.nextGrapheme(text, lo) < hi; // one grapheme IS the block cursor if (pane.tag_sel) pane.tag_anchor = @intCast(if (r.head > r.anchor) lo else modal.prevGrapheme(text, hi)); } fn tagNormalKey(p: *Pardes, pane: *Pane, key: Key) void { const text = p.tagText(p.scratch.allocator(), pane) catch return; const cur: usize = @min(@as(usize, pane.tag_col), text.len); // Esc abandons the command line: back to the body, tail kept as text if (hit(key, config.escape) or hit(key, &.{.{ .cp = 'j' }})) return exitTagEdit(pane); if (hit(key, config.look_key) or hit(key, config.exec_key)) { const cmd = if (hit(key, config.look_key)) config.look_cmd else config.exec_cmd; const txt = p.tagChordText(pane) orelse return; const id = p.active; exitTagEdit(pane); // leave the tag FIRST: Del frees the pane p.runBuiltin(cmd, id, "", txt); return; } if (hit(key, config.tag_yank)) { if (p.tagChordText(pane)) |txt| p.setClipboard(txt); return; } if (hit(key, config.insert) or hit(key, config.append) or hit(key, config.insert_line_start) or hit(key, config.insert_line_end)) { const edit0: usize = if (tagNameEnd(pane) != null) 0 else (p.tagPrefix(pane) catch return).len; pane.tag_col = @intCast(@max(edit0, if (hit(key, config.append)) modal.nextGrapheme(text, cur) else if (hit(key, config.insert_line_start)) 0 else if (hit(key, config.insert_line_end)) text.len else cur)); pane.tag_sel = false; pane.mode = .insert; return; } const dir: ?Builtin = for (config.window_keys) |wk| { var letter = wk.letter; letter.cp = std.ascii.toUpper(@intCast(letter.cp)); if (hit(key, &.{letter})) break wk.cmd; } else null; if (dir) |d| { const from = p.active; p.runBuiltin(d, from, "", null); if (p.active == from) { if (d == .Up) { exitTagEdit(pane); p.enterHeader(if (p.columnBarHeight() > 0) (layout.findPane(p, from) orelse return).col else null); } return; } const dest = p.panes[p.active] orelse return; exitTagEdit(pane); // the pane we left is fully out of its tag... p.enterTagEdit(dest, -1); if (dest.tag_edit) dest.mode = .normal; return; } if (lineMotion(text, cur, key)) |r| setTagRange(pane, text, r); } fn lineMotion(text: []const u8, cur: usize, key: Key) ?modal.Selection { const target: ?usize = if (hit(key, config.line_move_left)) modal.prevGrapheme(text, cur) else if (hit(key, config.line_move_right)) modal.nextGrapheme(text, cur) else if (hit(key, config.line_start)) 0 else if (hit(key, config.line_end)) modal.prevGrapheme(text, text.len) else if (hit(key, config.line_first_nonws)) modal.firstNonWs(text) else null; if (target) |t| return .{ .anchor = t, .head = t }; const wt: ?modal.WordTarget = if (hit(key, config.next_word_start)) .next_word_start else if (hit(key, config.prev_word_start)) .prev_word_start else if (hit(key, config.next_word_end)) .next_word_end else if (hit(key, config.next_long_word_start)) .next_long_word_start else if (hit(key, config.prev_long_word_start)) .prev_long_word_start else if (hit(key, config.next_long_word_end)) .next_long_word_end else null; const t = wt orelse return null; return modal.moveWord(text, .{ .anchor = cur, .head = modal.nextGrapheme(text, cur) }, 1, t); } fn rememberHeader(p: *Pardes) void { const col = p.topbar_col orelse return; const line = if (p.header_column) |c| &p.col_tags[c] else &p.global_tag; line.saved_col = col; } pub fn exitHeader(p: *Pardes) void { p.rememberHeader(); p.topbar_col = null; p.header_column = null; p.header_anchor = null; p.header_drag = false; p.header_scroll = 0; } fn enterHeader(p: *Pardes, column: ?usize) void { p.rememberHeader(); p.header_column = column; const line = if (column) |c| &p.col_tags[c] else &p.global_tag; const text = p.headerText(); p.topbar_col = @intCast(modal.graphemeStart(text, @min(line.saved_col orelse 0, text.len))); p.header_insert = false; p.header_anchor = null; p.header_drag = false; p.header_scroll = 0; } fn topbarKey(p: *Pardes, key: Key) void { const bar = p.headerText(); // the line GROWS a `Restore ` word the moment a dump lands, so // the column is clamped against the live line on every use const cur: usize = @min(@as(usize, p.topbar_col orelse return), bar.len); if (hit(key, config.escape) or (!p.header_insert and hit(key, &.{.{ .cp = 'j' }}))) { p.exitHeader(); return; } if (hit(key, config.look_key) or hit(key, config.exec_key)) { const a = p.header_anchor orelse @as(u16, @intCast(cur)); const word = if (a != cur) bar[@min(a, cur)..@max(a, cur)] else wordAtCol(bar, cur); p.exitHeader(); if (word.len > 0) { if (!p.header_insert) { // Preserve the workspace tag's keyboard command semantics. _ = p.execute(p.active, word); } else p.runBuiltin(if (hit(key, config.look_key)) config.look_cmd else config.exec_cmd, p.active, "", word); } return; } if (p.header_insert) { if (key.ctrl and key.cp == 'v') { p.emit(.read_clipboard); return; } if (key.ctrl and (key.cp == 'c' or key.cp == 'x')) { const anchor = p.header_anchor orelse @as(u16, @intCast(cur)); if (anchor != cur) { p.setClipboard(bar[@min(anchor, cur)..@max(anchor, cur)]); if (key.cp == 'x') p.headerReplace(""); } return; } if (key.text.len > 0 and !key.ctrl and !key.alt) { p.headerReplace(key.text); return; } if (key.cp == Key.backspace or key.cp == Key.delete) { if (p.header_anchor == null or p.header_anchor.? == cur) p.header_anchor = @intCast(if (key.cp == Key.backspace) modal.prevGrapheme(bar, cur) else modal.nextGrapheme(bar, cur)); p.headerReplace(""); return; } const next: ?usize = switch (key.cp) { Key.left => modal.prevGrapheme(bar, cur), Key.right => modal.nextGrapheme(bar, cur), Key.home => 0, Key.end => bar.len, else => null, }; if (next) |at| { if (key.shift) { if (p.header_anchor == null) p.header_anchor = @intCast(cur); } else p.header_anchor = null; p.topbar_col = @intCast(at); } return; } if (hit(key, config.insert) or hit(key, config.append) or hit(key, config.insert_line_start) or hit(key, config.insert_line_end)) { p.header_insert = true; p.topbar_col = @intCast(if (hit(key, config.insert_line_end)) bar.len else if (hit(key, config.insert_line_start)) 0 else if (hit(key, config.append)) modal.nextGrapheme(bar, cur) else cur); return; } if (hit(key, config.topbar_down)) { if (p.header_column == null and p.columnBarHeight() > 0) { p.enterHeader(if (layout.findPane(p, p.active)) |f| f.col else 0); return; } var dest: ?usize = null; for (p.panes, 0..) |slot, i| { if (slot == null or p.rects[i].y != TOPBAR_H + p.columnBarHeight()) continue; if (p.header_column) |c| if (p.rects[i].x != p.col_x[c]) continue; if (i == p.active) { dest = i; break; } if (dest == null or p.rects[i].x < p.rects[dest.?].x) dest = i; } const d = dest orelse return; const pane = p.panes[d].?; p.exitHeader(); p.active = d; p.enterTagEdit(pane, -1); if (pane.tag_edit) pane.mode = .normal; return; } if (hit(key, config.topbar_up)) { p.enterHeader(null); return; } const col: ?usize = if (hit(key, config.topbar_left)) modal.prevGrapheme(bar, cur) else if (hit(key, config.topbar_right)) modal.nextGrapheme(bar, cur) else if (lineMotion(bar, cur, key)) |r| modal.selectionCursor(bar, r) else null; // never past the last cell: there is nothing to append up here, so the // block cursor stays ON a character (which is where `$` already lands) if (col) |c| p.topbar_col = @intCast(@min(c, modal.prevGrapheme(bar, bar.len))); } /// tag + '\n' + body: the full selectable pane text (row 0 = the tag) fn paneText(p: *Pardes, pane: *Pane) ![]u8 { const arena = p.scratch.allocator(); return std.fmt.allocPrint(arena, "{s}\n{s}", .{ try p.tagText(arena, pane), try p.bodyText(arena, pane) }); } fn pointerSourceLine(pane: *Pane, terminal_lines: []const []const u8, row: i32) []const u8 { if (row < 0) return ""; if (pane.file != null) return panes.File.sourceLine(pane, row); const index: usize = @intCast(row); return if (index < terminal_lines.len) terminal_lines[index] else ""; } fn capturePointerSelection(p: *Pardes, pane: *Pane, slot: usize) !void { var gesture = pane.sel[slot]; if (pane.hasPdf()) return; if (gesture.state != .done or @min(gesture.r0, gesture.r1) < BOX_H) return; if (pane.pointerSelection(gesture) != null) return; pane.clearPointerSelection(slot); // Refresh the source map before translating the rendered rectangle. const raw = pane.isTerminal() and pane.mode == .tty; const body = try p.bodyText(p.scratch.allocator(), pane); const terminal_lines = if (pane.file == null and !raw) try panes.Terminal.cursorLines(p, pane) else &.{}; const first = @max(0, @min(gesture.r0, gesture.r1) - BOX_H); const logical_rows = if (pane.body_rows > 0) pane.body_rows else pane.rows; const last = @min(@as(i32, logical_rows) - 1, @max(gesture.r0, gesture.r1) - BOX_H); if (first > last) return; const rows = try p.gpa.alloc(Pane.PointerRow, @intCast(last - first + 1)); errdefer p.gpa.free(rows); const raw_text = if (raw) try p.gpa.dupe(u8, try p.selectionText(pane, gesture)) else null; const prefix: i32 = if (pane.file != null) panes.File.gutterWidth(pane) else 0; const left = @max(0, @min(gesture.c0, gesture.c1)); const right = @max(0, @max(gesture.c0, gesture.c1)); for (rows, 0..) |*row, index| { const visible = first + @as(i32, @intCast(index)); var source = pane.wrapAt(visible); if (raw) source = .{ .line = panes.Terminal.gridOffset(pane) + visible, .at = 0 }; const line = if (raw) modal.lineSlice(body, @intCast(visible)) else pointerSourceLine(pane, terminal_lines, source.line); var next = pane.wrapAt(visible + 1); if (raw) next = .{ .line = source.line + 1, .at = 0 }; const end = if (next.line == source.line) @min(line.len, @as(usize, @intCast(@max(0, next.at)))) else line.len; const start: usize = @intCast(@max(0, source.at)); const lo = @min(end, panes.File.byteAtDisplayFrom(line, start, @intCast(@max(0, left - prefix)))); const at = @min(end, panes.File.byteAtDisplayFrom(line, start, @intCast(@max(0, right - prefix)))); row.* = .{ .row = source.line, .lo = lo, .hi = if (right < prefix) lo else @min(end, modal.nextGrapheme(line, at)), .raw_terminal = raw, .prompt_bytes = if (raw) panes.Terminal.promptPrefixBytes(pane, source.line, line) else 0, }; const rendered = modal.lineSlice(body, @intCast(visible)); const prefix_end = @min(rendered.len, @as(usize, @intCast(@min(prefix, right + 1)))); const prefix_start = @min(prefix_end, @as(usize, @intCast(left))); row.prefix_len = @intCast(prefix_end - prefix_start); @memcpy(row.prefix[0..row.prefix_len], rendered[prefix_start..prefix_end]); } pane.next_pointer_selection += 1; gesture.source_id = pane.next_pointer_selection; pane.sel[slot] = gesture; pane.pointer_selections[slot] = .{ .gesture = gesture, .rows = rows, .raw_text = raw_text }; } /// block-selected text, newline-joined per row; reads the rendered screen /// so typed text and shell output select identically. Scratch-owned. fn selectionText(p: *Pardes, pane: *Pane, sl: Pane.Sel) ![]const u8 { const arena = p.scratch.allocator(); if (pane.rawPointerText(sl)) |text| return arena.dupe(u8, text); if (pane.pointerSelection(sl)) |rows| { const terminal_lines = if (pane.file == null) try panes.Terminal.cursorLines(p, pane) else &.{}; var total: usize = rows.len -| 1; for (rows) |row| { const line = pointerSourceLine(pane, terminal_lines, row.row); total += row.prefix_len + @min(row.hi, line.len) - @min(row.lo, line.len); } const result = try arena.alloc(u8, total); var at: usize = 0; for (rows, 0..) |row, index| { if (index > 0) { result[at] = '\n'; at += 1; } @memcpy(result[at..][0..row.prefix_len], row.prefix[0..row.prefix_len]); at += row.prefix_len; const line = pointerSourceLine(pane, terminal_lines, row.row); const bytes = line[@min(row.lo, line.len)..@min(row.hi, line.len)]; @memcpy(result[at..][0..bytes.len], bytes); at += bytes.len; } return result; } const r0 = @min(sl.r0, sl.r1); const r1 = @max(sl.r0, sl.r1); const c0: usize = @intCast(@max(0, @min(sl.c0, sl.c1))); const c1: usize = @intCast(@max(0, @max(sl.c0, sl.c1))); const text = try p.paneText(pane); var total: usize = 0; var selected: usize = 0; var count_it = std.mem.splitAny(u8, text, "\n"); var count_row: i32 = 0; while (count_it.next()) |line| : (count_row += 1) { if (count_row < r0 or count_row > r1) continue; const b0 = @min(panes.File.renderedLineByteCol(pane, count_row, line, c0), line.len); const b1 = modal.nextGrapheme(line, @min(panes.File.renderedLineByteCol(pane, count_row, line, c1), line.len)); total += b1 - b0 + @intFromBool(selected > 0); selected += 1; } const out = try arena.alloc(u8, total); var at: usize = 0; var it = std.mem.splitAny(u8, text, "\n"); var v: i32 = 0; var first = true; while (it.next()) |line| : (v += 1) { if (v < r0 or v > r1) continue; if (!first) { out[at] = '\n'; at += 1; } first = false; const b0 = @min(panes.File.renderedLineByteCol(pane, v, line, c0), line.len); const b1 = modal.nextGrapheme(line, @min(panes.File.renderedLineByteCol(pane, v, line, c1), line.len)); @memcpy(out[at..][0 .. b1 - b0], line[b0..b1]); at += b1 - b0; } return out; } fn spanHas(r: i32, c: i32, ar: i32, ac: i32, br: i32, bc: i32) bool { const fwd = ar < br or (ar == br and ac <= bc); const sr, const sc = if (fwd) .{ ar, ac } else .{ br, bc }; const er, const ec = if (fwd) .{ br, bc } else .{ ar, ac }; return (r > sr or (r == sr and c >= sc)) and (r < er or (r == er and c <= ec)); } const ExpandedWord = struct { lo: usize, hi: usize }; fn expandedWord(line: []const u8, col: usize) ?ExpandedWord { if (col >= line.len) return null; const b = config.wordBounds(line, col); const command_run = b.hi > b.lo and config.commandWord(line[b.lo..b.hi]) != null; if (!config.isFileChar(line[col]) and !command_run) return null; if (b.hi <= b.lo) return null; return .{ .lo = b.lo, .hi = b.hi }; } /// acme: a no-drag middle/right click expands to the word under it — /// file-ish, or a whole `` @`...` `` run (config.wordBounds is the spelling) fn expandedSel(p: *Pardes, pane: *Pane, at: Pane.Sel) ?Pane.Sel { var sl = at; if (sl.c0 != sl.c1 or sl.r0 != sl.r1) return sl; const text = p.paneText(pane) catch return null; var it = std.mem.splitAny(u8, text, "\n"); var v: i32 = 0; while (it.next()) |line| : (v += 1) { if (v != sl.r0) continue; const display_col: usize = @intCast(@max(0, sl.c0)); const col = panes.File.renderedLineByteCol(pane, v, line, display_col); const b = expandedWord(line, col) orelse return null; sl.c0 = @intCast(panes.File.renderedLineDisplayCol(pane, v, line, b.lo)); sl.c1 = @intCast(panes.File.renderedLineDisplayCol(pane, v, line, b.hi) - 1); return sl; } return null; } /// the word under the modal cursor as a pane-local selection (paneText /// coords: row 0 is the tag; file panes carry the line-number prefix) fn cursorWordSel(p: *Pardes, pane: *Pane) Pane.Sel { const w = pane.wrapRow(pane.cur_row, pane.cur_col); const vrow = w.row + @as(i32, BOX_H); const vcol = if (pane.file != null) panes.File.displayOffset(pane, pane.cur_row, w.at, pane.cur_col) + @as(i32, panes.File.gutterWidth(pane)) else blk: { const lines = p.paneCursorLines(pane) catch break :blk pane.cur_col; const local = pane.cur_row; if (local < 0 or @as(usize, @intCast(local)) >= lines.len) break :blk pane.cur_col; break :blk panes.File.lineDisplayOffset(lines[@intCast(local)], @intCast(@max(0, w.at)), @intCast(@max(0, pane.cur_col))); }; return .{ .state = .done, .c0 = vcol, .c1 = vcol, .r0 = vrow, .r1 = vrow }; } /// the active modal selection as text (v range or x lines), if any fn currentSelText(p: *Pardes, pane: *Pane) ?[]const u8 { if (comptime pdf_enabled) if (p.native_images) if (pane.pdf) |pv| if (pv.selection != null and pv.selection_text.len > 0) return pv.selection_text; if (pane.vsel.active) return p.vselText(pane); if (!pane.msel.active) return null; return p.yankRows(pane, @min(pane.msel.r0, pane.msel.r1), @max(pane.msel.r0, pane.msel.r1)); } fn chordEachSel(p: *Pardes, pane: *Pane, cmd: Builtin) bool { if (pane.nsel == 0) return false; const text = p.flatSurface(pane) catch return false; var ranges: [Pane.max_selections]modal.Selection = undefined; const got = pane.ranges(text, 0, &ranges); if (got.n < 2) return false; var texts: [Pane.max_selections][]u8 = undefined; var made: usize = 0; defer for (texts[0..made]) |t| p.gpa.free(t); for (ranges[0..got.n]) |range| { const lo = @min(range.anchor, range.head); const hi = @max(range.anchor, range.head); if (hi > text.len) break; texts[made] = p.gpa.dupe(u8, text[lo..hi]) catch break; made += 1; } // All or nothing, like the pipe: half a chord is not a chord. if (made != got.n) return false; const id = p.active; const serial = pane.serial; pane.vsel.active = false; pane.msel.active = false; pane.select = false; pane.nsel = 0; // the chord consumed them, exactly as it consumes one var primary_active: ?usize = null; for (texts[0..made], 0..) |txt, i| { p.runBuiltin(cmd, id, "", txt); if (i == got.pri) primary_active = p.active; const still = p.panes[id] orelse break; if (still.serial != serial) break; } if (primary_active) |a| if (p.panes[a] != null) { p.active = a; }; return true; } const PointerOperand = struct { /// Absolute body position corresponding to the pointed screen cell. row: i32, col: i32, /// Exactly what a no-drag middle/right click will dispatch. text: ?[]const u8 = null, /// What hover paints. Null names the pane's live modal selection. preview: ?Pane.Sel = null, /// File words stay logical so a soft-wrapped operand is not cut into /// unrelated rendered fragments. Null for tags and non-file panes. file_word: ?FileWordSpan = null, /// A newly expanded word is installed in the gesture's button slot; /// an existing selection is only borrowed and must not replace it. expanded: ?Pane.Sel = null, }; fn pointerOperand(p: *Pardes, pane: *Pane, clicked: Pane.Sel) PointerOperand { const visible = clicked.r0 - @as(i32, BOX_H); const wrapped = pane.wrapAt(visible); const raw = pane.isTerminal() and pane.mode == .tty and clicked.r0 >= BOX_H; const row = if (raw) panes.Terminal.gridOffset(pane) + visible else wrapped.line; const raw_line = if (raw) modal.lineSlice(p.bodyText(p.scratch.allocator(), pane) catch "", @intCast(@max(0, visible))) else ""; const col = if (raw) @as(i32, @intCast(panes.File.rawAtDisplay(raw_line, @intCast(@max(0, clicked.c0))))) else if (clicked.r0 >= BOX_H) p.paneByteAtDisplay( pane, wrapped.line, wrapped.at, clicked.c0 - (if (pane.file != null) @as(i32, panes.File.gutterWidth(pane)) else 0), ) else clicked.c0; var result: PointerOperand = .{ .row = row, .col = col }; const kept = pane.sel[sel_slot]; const inside_kept = if (pane.pointerSelection(kept)) |rows| inside: { if (clicked.r0 < BOX_H) break :inside false; if (pane.file != null and clicked.c0 < panes.File.gutterWidth(pane)) break :inside false; const source_col: usize = @intCast(@max(0, col)); for (rows) |span| { const target = if (span.raw_terminal) (if (raw) span.row else pane.surfRow(span.row)) else (if (raw) pane.gridRow(span.row) else span.row); var lo = span.lo; var hi = span.hi; if (span.raw_terminal and !raw) { lo -|= span.prompt_bytes; hi -|= span.prompt_bytes; } else if (!span.raw_terminal and raw) { const prefix = panes.Terminal.promptPrefixBytes(pane, row, raw_line); lo += prefix; hi += prefix; } if (target == row and source_col >= lo and source_col < hi) break :inside true; } break :inside false; } else spanHas(clicked.r0, clicked.c0, kept.r0, kept.c0, kept.r1, kept.c1); if (kept.state == .done and inside_kept) { result.text = p.selectionText(pane, kept) catch null; result.preview = kept; return result; } if (pane.vsel.active and pane.vsel.explicit and spanHas(row, col, pane.vsel.row, pane.vsel.col, pane.cur_row, pane.cur_col)) { result.text = p.currentSelText(pane); return result; } if (pane.msel.active and row >= @min(pane.msel.r0, pane.msel.r1) and row <= @max(pane.msel.r0, pane.msel.r1)) { result.text = p.currentSelText(pane); return result; } if (pane.file != null and clicked.r0 >= BOX_H and clicked.r0 == clicked.r1 and clicked.c0 == clicked.c1) { const line = panes.File.sourceLine(pane, row); const source_col: usize = @intCast(@max(0, col)); const b = expandedWord(line, source_col) orelse return result; const lo = std.math.cast(i32, b.lo) orelse return result; const hi = std.math.cast(i32, b.hi) orelse return result; result.text = line[b.lo..b.hi]; result.file_word = .{ .row = row, .lo = lo, .hi = hi }; var expanded = clicked; const first = pane.wrapRow(row, lo); const last = pane.wrapRow(row, hi - 1); if (first.row >= 0 and last.row >= 0) { expanded.r0 = first.row + @as(i32, BOX_H); expanded.c0 = @as(i32, panes.File.gutterWidth(pane)) + panes.File.displayOffset(pane, row, first.at, lo); expanded.r1 = last.row + @as(i32, BOX_H); expanded.c1 = @as(i32, panes.File.gutterWidth(pane)) + panes.File.displayEndOffset(pane, row, last.at, hi - 1); } result.preview = expanded; result.expanded = expanded; return result; } const expanded = p.expandedSel(pane, clicked) orelse return result; result.text = p.selectionText(pane, expanded) catch null; if (result.text == null or result.text.?.len == 0) return result; result.preview = expanded; result.expanded = expanded; return result; } fn heldSelection(p: *Pardes, first: usize) ?[]const u8 { var k: usize = 0; while (k < p.panes.len + 2) : (k += 1) { const i = switch (k) { 0 => first, 1 => p.active, else => k - 2, }; if (k > 0 and (i == first or (k > 1 and i == p.active))) continue; const t = p.panes[i] orelse continue; if (comptime pdf_enabled) if (p.native_images) if (t.pdf) |pv| { if (pv.selection != null and pv.selection_text.len > 0) return pv.selection_text; }; if (t.sel[sel_slot].state == .done) { if (p.selectionText(t, t.sel[sel_slot]) catch null) |tx| return tx; } else if ((t.vsel.active and t.vsel.explicit) or t.msel.active) { if (p.currentSelText(t)) |tx| return tx; } } return null; } fn withArg(p: *Pardes, txt: []const u8, arg: ?[]const u8) []const u8 { const a = std.mem.trim(u8, arg orelse return txt, " \t\r\n"); if (a.len == 0) return txt; const t = std.mem.trim(u8, txt, " \t\r\n"); return std.fmt.allocPrint(p.scratch.allocator(), "{s} {s}", .{ t, a }) catch txt; } /// One mode transition for the terminal tag command and the Ctrl-B chord. pub fn toggleTty(p: *Pardes, id: usize) void { const pane = p.panes[id] orelse return; if (!pane.isTerminal()) return; p.active = id; p.exitHeader(); if (pane.tag_edit) exitTagEdit(pane); if (pane.mode == .tty) { pane.mode = .normal; pane.normal.clear(); } else panes.Terminal.enterTty(p, id); } fn handleKey(p: *Pardes, key: Key) void { if (p.topbar_col != null) return p.topbarKey(key); const pane = p.panes[p.active] orelse return; if (pane.isTerminal() and pane.mode == .tty and !pane.tag_edit) { p.leader_on = false; p.ctrl_w_pending = false; if (key.ctrl and !key.alt and key.cp == p.opts.tty_toggle) return p.toggleTty(p.active); if (key.cp == Key.escape and !key.ctrl and !key.alt and !key.shift and p.takesCommandLine(p.active)) return p.runBuiltin(.Last, p.active, "", null); return panes.Terminal.forwardKey(p, p.active, key); } if (p.leader_on) return p.leaderKey(key); if (p.ctrl_w_pending) { p.ctrl_w_pending = false; for (config.window_keys) |wk| { if (hit(key, &.{ wk.letter, wk.arrow })) return p.runBuiltin(wk.cmd, p.active, "", null); } return; } // insert mode owns Ctrl-w (delete word backward, helix); the focus // prefix applies outside raw tty mode if (hit(key, config.window_prefix) and pane.mode != .insert) { p.ctrl_w_pending = true; return; } // global window ops (any mode): Alt-n new terminal below, Alt-c move // the active terminal into a fresh column if (hit(key, config.new_shell_below)) { const free = p.freeSlot() orelse return; const nt = p.newShell(free, "") catch return; nt.greet = true; const src = layout.splitParent(p, p.active); const f = layout.findPane(p, src).?; layout.insert(p, f.col, f.idx + 1, free); layout.splitBelow(p, src, nt); p.active = free; return; } for (config.jump_keys) |jk| { if (hit(key, &.{jk.chord})) return p.runBuiltin(jk.cmd, p.active, "", null); } if (hit(key, config.pane_to_new_column)) { const f = layout.findPane(p, p.active).?; if (p.ncol < MAX_COLS and p.col_n[f.col] > 1) { _ = layout.splitColumn(p, p.active, p.active, false); } return; } const tty_alt = hit(key, config.tty_toggle_alt); const tty_toggle = (key.ctrl and key.cp == p.opts.tty_toggle) or tty_alt; if (pane.isTerminal() and tty_toggle) { return p.toggleTty(p.active); } if (pane.prompt != .none and (hit(key, config.search_submit) or hit(key, config.escape))) { const submit = hit(key, config.search_submit); switch (pane.prompt) { .pipe => |pipe| { if (submit) p.submitPipe(p.active); pane.tag_tail_len = @min(pipe.at, pane.tag_tail_len); }, .save => |at| { if (submit) p.submitSave(p.active) else pane.tag_tail_len = @min(at, pane.tag_tail_len); }, .search => |at| { if (submit) p.submitSearch(p.active) else if (selRegexArmed(pane)) |_| p.applySelRegex(pane, "", false); pane.tag_tail_len = @min(at, pane.tag_tail_len); }, .none => unreachable, } exitTagEdit(pane); pane.mode = .normal; pane.normal.clear(); return; } if (pane.tag_edit) { if (pane.mode == .normal) p.tagNormalKey(pane, key) else p.tagInsertKey(pane, key); const pn = p.panes[p.active] orelse return; if (selRegexArmed(pn)) |a| p.applySelRegex(pn, a.pat, a.split); return; } if (pane.mode == .normal and (hit(key, config.look_key) or hit(key, config.exec_key))) { const cmd = if (hit(key, config.look_key)) config.look_cmd else config.exec_cmd; p.pinPaneCursor(pane); const explicit = (p.native_images and pane.hasPdfSelection()) or (pane.vsel.active and pane.vsel.explicit) or pane.msel.active; if (explicit) { if (p.chordEachSel(pane, cmd)) return; if (p.currentSelText(pane)) |txt| { pane.vsel.active = false; pane.msel.active = false; pane.select = false; p.runBuiltin(cmd, p.active, "", txt); return; } } const sel = p.expandedSel(pane, p.cursorWordSel(pane)) orelse return; const word = p.selectionText(pane, sel) catch return; p.runBuiltin(cmd, p.active, "", word); return; } if (pane.mode == .normal and pane.hasPdf()) return p.handlePdfNormal(pane, key); switch (pane.mode) { .normal => { p.handleNormal(pane, key); }, .insert => { if (hit(key, config.escape)) { pane.mode = .normal; pane.msel.active = false; pane.normal.clear(); if (pane.append_at) |aa| { pane.append_at = null; const text = p.flatSurface(pane) catch return; const gap = modal.offsetAt(text, .{ .row = @intCast(@max(0, pane.cur_row)), .col = @intCast(@max(0, pane.cur_col)) }); const a_off = modal.offsetAt(text, .{ .row = @intCast(@max(0, aa.row)), .col = @intCast(@max(0, aa.col)) }); const back = @max(a_off, modal.prevGrapheme(text, gap)); const bc = modal.positionAt(text, back); pane.cur_row = @intCast(bc.row); pane.cur_col = @intCast(bc.col); pane.vsel = .{ .active = a_off != back, .row = aa.row, .col = aa.col, .explicit = false }; pane.cur_pinned = true; pane.ensureCursorVisible(); for (pane.sels[0..pane.nsel]) |*s| { const sgap = modal.offsetAt(text, .{ .row = @intCast(@max(0, s.row)), .col = @intCast(@max(0, s.col)) }); const b2 = if (back == gap) sgap else modal.prevGrapheme(text, sgap); const bp = modal.positionAt(text, b2); s.row = @intCast(bp.row); s.col = @intCast(bp.col); s.arow = s.row; s.acol = s.col; } } return; } p.handleInsert(pane, key); }, .tty => panes.Terminal.forwardKey(p, p.active, key), } } fn leaderKey(p: *Pardes, key: Key) void { p.leader_on = false; // only "still a prefix" below re-arms it if (key.ctrl or key.alt or key.cp < 0x20 or key.cp > 0x7e) return; const c: u8 = @intCast(key.cp); if (c == config.leader_help) { panes.Output.openHelp(p, p.active, p.leader_keys[0..p.leader_n]) catch |err| p.reportError(p.active, "help", err); return; } if (p.leader_n >= p.leader_keys.len) return; p.leader_keys[p.leader_n] = c; p.leader_n += 1; const pfx = p.leader_keys[0..p.leader_n]; // a path-less builtin is a row here too (it is the same index Help // reads), and SPC cannot reach one — so both scans skip it for (builtin_rows) |row| { if (std.mem.eql(u8, row.path orelse continue, pfx)) return p.runBuiltin(row.cmd, p.active, "", null); } for (builtin_rows) |row| { if (std.mem.startsWith(u8, row.path orelse continue, pfx)) { p.leader_on = true; return; } } } // ---- helix-modal normal mode ---- pub fn paneCursorLines(p: *Pardes, pane: *Pane) ![]const []const u8 { const arena = p.scratch.allocator(); if (pane.file) |*f| return panes.File.cursorLines(arena, pane, f); if (pane.hasPdf()) { if (comptime pdf_enabled) return panes.Pdf.textLines(&pane.pdf.?, p.pdf_gpa, arena); unreachable; } return panes.Terminal.cursorLines(p, pane); } fn paneByteAtDisplay(p: *Pardes, pane: *Pane, row: i32, from_raw: i32, display_col: i32) i32 { if (pane.file != null) return panes.File.byteAtRowDisplay(pane, row, from_raw, display_col); const lines = p.paneCursorLines(pane) catch return @max(0, from_raw + display_col); const local = row; if (local < 0 or @as(usize, @intCast(local)) >= lines.len) return @max(0, from_raw + display_col); return @intCast(panes.File.byteAtDisplayFrom( lines[@intCast(local)], @intCast(@max(0, from_raw)), @intCast(@max(0, display_col)), )); } /// Freeze the live terminal cursor into the modal coordinate space. The /// emulator reports screen cells; editing state stores UTF-8 byte offsets. fn pinPaneCursor(p: *Pardes, pane: *Pane) void { if (pane.cur_pinned) return; pane.pinCursor(); if (pane.file == null and !pane.hasPdf()) pane.cur_col = p.paneByteAtDisplay(pane, pane.cur_row, 0, pane.cur_col); } fn flatSurface(p: *Pardes, pane: *Pane) ![]const u8 { if (pane.file) |f| return f.content; if (pane.hasPdf()) { if (comptime pdf_enabled) return pane.pdf.?.ensureText(p.pdf_gpa); unreachable; } const lines = try panes.Terminal.cursorLines(p, pane); return panes.Terminal.flatSurface(p, pane, lines); } fn startSelRegex(p: *Pardes, pane: *Pane, split: bool) void { const text = p.flatSurface(pane) catch return; const got = pane.ranges(text, 0, &pane.sel_snap); pane.nsel_snap = @intCast(got.n); pane.sel_snap_pri = @intCast(got.pri); pane.sel_snap_expl = (pane.vsel.active and pane.vsel.explicit) or pane.msel.active; p.startPrompt(pane, .{ .search = if (split) config.split_marker else config.select_marker }); } fn selRegexArmed(pane: *Pane) ?struct { pat: []const u8, split: bool } { const prompt_at = switch (pane.prompt) { .search => |at| at, else => return null, }; if (pane.nsel_snap == 0) return null; const tail = pane.tagSlice(); const armed = tail[@min(prompt_at, tail.len)..]; const split = std.mem.startsWith(u8, armed, config.split_marker); if (!split and !std.mem.startsWith(u8, armed, config.select_marker)) return null; const slash = std.mem.indexOfScalar(u8, armed, '/') orelse return null; return .{ .pat = armed[slash + 1 ..], .split = split }; } fn applySelRegex(p: *Pardes, pane: *Pane, pat: []const u8, split: bool) void { const text = p.flatSurface(pane) catch return; const snap = pane.sel_snap[0..pane.nsel_snap]; var out: [Pane.max_selections]modal.Selection = undefined; var m: usize = 0; if (pat.len > 0) if (mvzr.compile(pat)) |re| { var hay_all = text; if (for (pat) |c| { if (std.ascii.isUpper(c)) break false; } else true) { const low = p.scratch.allocator().dupe(u8, text) catch return; for (low) |*c| c.* = std.ascii.toLower(c.*); hay_all = low; } for (snap) |r| { const from = @min(r.anchor, r.head); const to = @min(@max(r.anchor, r.head), text.len); if (from >= to) continue; const hay = hay_all[from..to]; var at: usize = 0; var piece = from; // split: where the next piece begins while (at < hay.len and m < Pane.max_selections) { const hit_at = re.matchPos(at, hay) orelse break; if (split) { out[m] = .{ .anchor = piece, .head = from + hit_at.start }; m += 1; piece = from + hit_at.end; } else if (from + hit_at.start != to) { out[m] = .{ .anchor = from + hit_at.start, .head = from + hit_at.end }; m += 1; } // an empty match would otherwise never advance at = if (hit_at.end > hit_at.start) hit_at.end else hit_at.end + 1; } if (split and piece < to and m < Pane.max_selections) { out[m] = .{ .anchor = piece, .head = to }; m += 1; } } }; if (m == 0) { // the text CAN move under an armed prompt (a tag chord runs a // builtin), and these are raw offsets into the surface as it was for (snap) |*r| { r.anchor = @min(r.anchor, text.len); r.head = @min(r.head, text.len); } return pane.setRanges(text, snap, &.{}, pane.sel_snap_pri, pane.sel_snap_expl); } pane.setRanges(text, out[0..m], &.{}, 0, true); // helix keeps primary 0 (its own TODO) } const KeyState = struct { mode: Pane.Mode, select: bool, normal: modal.Normal.State, find_op: u8, find_ch: u21, append_at: @FieldType(Pane, "append_at"), fn of(pane: *Pane) KeyState { return .{ .mode = pane.mode, .select = pane.select, .normal = pane.normal, .find_op = pane.find_op, .find_ch = pane.find_ch, .append_at = pane.append_at, }; } fn into(s: KeyState, pane: *Pane) void { pane.mode = s.mode; pane.select = s.select; pane.normal = s.normal; pane.find_op = s.find_op; pane.find_ch = s.find_ch; pane.append_at = s.append_at; } }; /// what a replayed key does at each cursor const Replay = union(enum) { normal: modal.Normal.Action, insert: Key, }; fn replaySels(p: *Pardes, pane: *Pane, what: Replay) void { const id = p.active; const serial = pane.serial; // the whole selection in pane coordinates, document order. pane.sels // is already ordered, so this only slots the primary into place. var list: [Pane.max_selections]Pane.SelRange = undefined; var n: usize = 0; var pri: usize = 0; const prim: Pane.SelRange = .{ .row = pane.cur_row, .col = pane.cur_col, .arow = if (pane.vsel.active) pane.vsel.row else pane.cur_row, .acol = if (pane.vsel.active) pane.vsel.col else pane.cur_col, .sticky = pane.sticky_col, }; const pr = Pane.selStart(prim); var placed = false; for (pane.sels[0..pane.nsel]) |s| { const sr = Pane.selStart(s); if (!placed and (pr.row < sr.row or (pr.row == sr.row and pr.col <= sr.col))) { pri = n; list[n] = prim; n += 1; placed = true; } list[n] = s; n += 1; } if (!placed) { pri = n; list[n] = prim; n += 1; } const saved = KeyState.of(pane); const explicit = pane.vsel.explicit; var after = saved; var after_expl = explicit; // each pass's result: cursor and anchor cells as distances from the // end of the surface text, plus the range's own j/k goal column var res: [Pane.max_selections]struct { cur: usize, anc: usize, sticky: i32 } = undefined; p.multi_on = true; p.multi_stop = false; var passes: usize = 0; var i: usize = n; while (i > 0) { i -= 1; p.multi_first = passes == 0; passes += 1; saved.into(pane); pane.nsel = 0; // the handler underneath sees ONE selection pane.cur_row = list[i].row; pane.cur_col = list[i].col; pane.vsel = .{ .active = list[i].arow != list[i].row or list[i].acol != list[i].col or pane.select, .row = list[i].arow, .col = list[i].acol, .explicit = explicit, }; pane.msel.active = false; pane.sticky_col = list[i].sticky; switch (what) { .normal => |normal_action| p.executeNormalAction(pane, normal_action), .insert => |insert_key| p.insertKey(pane, insert_key), } if (p.multi_stop) break; if (i == pri) { after = KeyState.of(pane); after_expl = pane.vsel.explicit; } const text = p.flatSurface(pane) catch { p.multi_stop = true; break; }; const co = modal.offsetAt(text, .{ .row = @intCast(@max(0, pane.cur_row)), .col = @intCast(@max(0, pane.cur_col)) }); const ao = if (pane.vsel.active) modal.offsetAt(text, .{ .row = @intCast(@max(0, pane.vsel.row)), .col = @intCast(@max(0, pane.vsel.col)) }) else co; res[i] = .{ .cur = text.len - @min(co, text.len), .anc = text.len - @min(ao, text.len), .sticky = pane.sticky_col }; } p.multi_on = false; p.multi_first = false; if (p.multi_stop) { p.multi_stop = false; const pn = p.panes[id] orelse return; if (pn.serial == serial) pn.nsel = 0; return; } const text = p.flatSurface(pane) catch return; var rs: [Pane.max_selections]modal.Selection = undefined; var st: [Pane.max_selections]i32 = undefined; for (res[0..n], 0..) |r, k| { rs[k] = Pane.cellOffRange(text, text.len - @min(r.anc, text.len), text.len - @min(r.cur, text.len)); st[k] = r.sticky; } pane.setRanges(text, rs[0..n], st[0..n], pri, after_expl); after.into(pane); pane.ensureCursorVisible(); // the view follows the PRIMARY, not the last pass } fn paneWrapWidth(p: *const Pardes, pane: *const Pane) usize { if (pane.file == null) return 0; return panes.File.wrapWidth(pane, p.settings.wrap); } fn handleNormal(p: *Pardes, pane: *Pane, key: Key) void { const parsed = modal.Normal.parse(&pane.normal, normalInput(key)); const semantic = switch (parsed) { .pending, .ignored, .unbound => return, .action => |value| value, }; if (pane.nsel == 0 or semantic.scope() == .once) return p.executeNormalAction(pane, semantic); p.replaySels(pane, .{ .normal = semantic }); } fn executeNormalAction(p: *Pardes, pane: *Pane, semantic: modal.Normal.Action) void { const raw_selection = pane.rawPointerText(pane.sel[sel_slot]) != null; const before = .{ pane.cur_row, pane.cur_col, pane.vsel, pane.msel, pane.select, pane.nsel, pane.mode }; defer if (raw_selection and !std.meta.eql(before, .{ pane.cur_row, pane.cur_col, pane.vsel, pane.msel, pane.select, pane.nsel, pane.mode })) { pane.clearPointerSelection(sel_slot); pane.sel[sel_slot].state = .none; }; if (raw_selection and semantic == .edit and semantic.edit.kind == .yank) return p.normalYank(pane); p.pinPaneCursor(pane); const text = p.flatSurface(pane) catch return; const range = pane.primaryRange(text, 0); const cur = modal.selectionCursor(text, range); switch (semantic) { .escape => { pane.select = false; return p.runBuiltin(.Last, p.active, "", null); }, .goto => |go| switch (go.target) { .file_start => { const line = if (go.explicit_count) @min(@as(usize, go.count) - 1, Pane.maxLine(text)) else 0; return pane.pointMove(text, range, modal.lineStartOffset(text, line)); }, .last_line => return pane.pointMove(text, range, modal.lineStartOffset(text, Pane.maxLine(text))), .line_start => return pane.pointMove(text, range, modal.lineStartOffset(text, modal.lineAtOffset(text, cur))), .line_end => { const line = modal.lineAtOffset(text, cur); const ls = modal.lineStartOffset(text, line); return pane.pointMove(text, range, @max(ls, modal.prevGrapheme(text, modal.lineEndOffset(text, line)))); }, .first_nonws => { const line = modal.lineAtOffset(text, cur); const ls = modal.lineStartOffset(text, line); const slice = text[ls..modal.lineEndOffset(text, line)]; const nw = modal.firstNonWs(slice); if (nw == slice.len) return; return pane.pointMove(text, range, ls + nw); }, .line_down => return pane.visualMove(text, range, true, go.count, p.paneWrapWidth(pane)), .line_up => return pane.visualMove(text, range, false, go.count, p.paneWrapWidth(pane)), .column => { const line = modal.lineAtOffset(text, cur); const ls = modal.lineStartOffset(text, line); const slice = text[ls..modal.lineEndOffset(text, line)]; return pane.pointMove(text, range, ls + modal.graphemeAtColumn(slice, @as(usize, go.count) - 1)); }, .view_top => return pane.gotoWindow(text, range, .top, go.count), .view_center => return pane.gotoWindow(text, range, .center, go.count), .view_bottom => return pane.gotoWindow(text, range, .bottom, go.count), }, .view => |view| switch (view) { .top => { pane.scrollBy(pane.cur_row - pane.scroll()); pane.ensureCursorVisible(); }, .center => { pane.scrollBy(pane.cur_row - (pane.scroll() + @divTrunc(@as(i32, pane.rows), 2) - 1)); pane.ensureCursorVisible(); }, .bottom => { pane.scrollBy(pane.cur_row - (pane.scroll() + @as(i32, pane.rows) - 1)); pane.ensureCursorVisible(); }, .scroll_down => return pane.scrollViewMove(text, range, 1), .scroll_up => return pane.scrollViewMove(text, range, -1), }, .find => |find| { const op: u8 = switch (find.kind) { .forward => @intCast(config.find_char_fwd), .backward => @intCast(config.find_char_back), .till_forward => @intCast(config.till_char_fwd), .till_backward => @intCast(config.till_char_back), }; pane.find_op = op; pane.find_ch = find.char; return pane.findMove( text, range, find.char, find.kind == .forward or find.kind == .till_forward, find.kind == .till_forward or find.kind == .till_backward, find.count, ); }, .replace_char => |char| return p.normalReplaceChar(pane, char), .match_bracket => { const lines = p.paneCursorLines(pane) catch return; const mc = modal.matchBracket(lines, modal.positionAt(text, cur)) orelse return; return pane.pointMove(text, range, modal.offsetAt(text, mc)); }, .textobject => |object| return p.textobjectSelect(pane, text, object.char, object.around), .surround_add => |char| return p.surroundAdd(pane, char), .surround_delete => |char| return p.surroundDelete(pane, char), .surround_replace => |replace| return p.surroundReplace(pane, replace.from, replace.to), .paragraph => |paragraph| { const r2 = modal.moveParagraph(text, range, paragraph.count, paragraph.direction == .forward, pane.select); return pane.setRange(text, 0, r2, false); }, .add_newline => |newline| return p.addNewline(pane, newline.direction == .forward, newline.count), .diagnostic => |diagnostic| { const fwd = diagnostic.direction == .forward; if (!diagnostic.endpoint) { if (panes.Output.resultsFrom(p, pane, .{ .query = .diagnostics }) and p.searchStep(p.active, if (fwd) 1 else -1)) return; return p.lspRequest(p.active, .diagnostics, ""); } if (!panes.Output.resultsFrom(p, pane, .{ .query = .diagnostics })) return p.lspRequest(p.active, .diagnostics, ""); if (!fwd) { pane.search_row = null; _ = p.searchStep(p.active, 1); return; } const rp = p.panes[pane.search_pane orelse return] orelse return; const rf = rp.file orelse return; pane.search_row = std.mem.count(u8, rf.content, "\n"); _ = p.searchStep(p.active, -1); }, .move => |move| switch (move.motion) { .left => { var target = cur; for (0..move.count) |_| target = modal.prevGrapheme(text, target); return pane.pointMove(text, range, target); }, .right => { var target = cur; for (0..move.count) |_| target = modal.nextGrapheme(text, target); return pane.pointMove(text, range, target); }, .down => return pane.verticalMove(text, range, true, move.count), .up => return pane.verticalMove(text, range, false, move.count), .next_word_start => return pane.wordMove(text, range, move.count, .next_word_start), .prev_word_start => return pane.wordMove(text, range, move.count, .prev_word_start), .next_word_end => return pane.wordMove(text, range, move.count, .next_word_end), .next_long_word_start => return pane.wordMove(text, range, move.count, .next_long_word_start), .prev_long_word_start => return pane.wordMove(text, range, move.count, .prev_long_word_start), .next_long_word_end => return pane.wordMove(text, range, move.count, .next_long_word_end), }, .repeat_find => |count| { if (pane.find_op == 0) return; const fwd = pane.find_op == config.find_char_fwd or pane.find_op == config.till_char_fwd; const till = pane.find_op == config.till_char_fwd or pane.find_op == config.till_char_back; var repeated = range; var moved = false; for (0..count) |_| { const cc = modal.selectionCursor(text, repeated); const target = modal.findTarget(text, cc, pane.find_ch, fwd, till, 1) orelse break; repeated = if (pane.select) modal.moveSelectionCursor(text, repeated, target, true) else modal.moveSelectionCursor(text, .{ .anchor = cc, .head = cc }, target, true); moved = true; } if (!moved) return; return pane.setRange(text, 0, repeated, false); }, .line => |line_kind| switch (line_kind) { .start => return pane.pointMove(text, range, modal.lineStartOffset(text, modal.lineAtOffset(text, cur))), .end => { const line = modal.lineAtOffset(text, cur); const ls = modal.lineStartOffset(text, line); return pane.pointMove(text, range, @max(ls, modal.prevGrapheme(text, modal.lineEndOffset(text, line)))); }, .first_nonws => { const line = modal.lineAtOffset(text, cur); const ls = modal.lineStartOffset(text, line); const slice = text[ls..modal.lineEndOffset(text, line)]; const nw = modal.firstNonWs(slice); if (nw == slice.len) return; return pane.pointMove(text, range, ls + nw); }, }, .goto_line => |go| { if (!go.explicit) return; const line = @min(@as(usize, go.count) - 1, Pane.maxLine(text)); return pane.pointMove(text, range, modal.lineStartOffset(text, line)); }, .page => |page| switch (page.kind) { // Counts were parsed historically but these four text view // actions intentionally move exactly one viewport unit. .half_down => return pane.halfPageMove(text, range, true), .half_up => return pane.halfPageMove(text, range, false), .down => return pane.scrollViewMove(text, range, @as(i32, pane.rows)), .up => return pane.scrollViewMove(text, range, -@as(i32, pane.rows)), }, .insert => |insert| { const where: InsertAt = switch (insert.kind) { .at => .at, .append => .append, .line_start => .line_start, .line_end => .line_end, .open_below => .open_below, .open_above => .open_above, }; return p.enterInsert(pane, where, insert.count); }, .select => |select| switch (select.kind) { .mode => { if (pane.select) { pane.select = false; } else { pane.select = true; if (pane.msel.active) { pane.setRange(text, 0, range, true); } else if (!pane.vsel.active) { pane.vsel = .{ .active = true, .row = pane.cur_row, .col = pane.cur_col, .explicit = true }; } else { pane.vsel.explicit = true; } } pane.cur_pinned = true; }, .line => return pane.lineSelect(text, range, select.count), .line_bounds => return pane.lineBoundsSelect(text, range), .shrink_to_line_bounds => return pane.shrinkSelToLineBounds(text, range), .collapse => return pane.setRange(text, 0, .{ .anchor = cur, .head = cur }, false), .flip => { const explicit = (pane.vsel.active and pane.vsel.explicit) or pane.msel.active; return pane.setRange(text, 0, .{ .anchor = range.head, .head = range.anchor }, explicit); }, .all => return pane.setRange(text, 0, .{ .anchor = 0, .head = text.len }, false), }, .multi => |multi| { if (multi.kind == .keep_primary) { pane.nsel = 0; return; } return pane.multiSelAction(text, multi.kind, multi.count); }, .select_regex => |split| return p.startSelRegex(pane, split), .edit => |edit| switch (edit.kind) { .delete => return p.normalDelete(pane, true), .delete_noyank => return p.normalDelete(pane, false), .change => return p.normalChange(pane), .yank => return p.normalYank(pane), .replace_with_yank => return p.normalReplaceYank(pane), .paste_after => return p.pasteText(pane, p.yank orelse return, false, edit.count), .paste_before => return p.pasteText(pane, p.yank orelse return, true, edit.count), .switch_case => return p.normalCase(pane, .toggle), .lowercase => return p.normalCase(pane, .lower), .uppercase => return p.normalCase(pane, .upper), .join_lines => return p.normalJoin(pane), .indent => return p.normalIndent(pane, edit.count, true), .unindent => return p.normalIndent(pane, edit.count, false), .comment_toggle => return p.normalToggleComment(pane), .undo => return p.doUndo(pane), .redo => return p.doRedo(pane), }, .lsp => |request| return p.lspRequest(p.active, switch (request) { .definition => .definition, .declaration => .declaration, .type_definition => .type_definition, .implementation => .implementation, .references => .references, .format => .format, }, ""), .adjust_number => |delta| return p.normalAdjustNumber(pane, delta), .leader => { p.leader_on = true; p.leader_n = 0; }, .command_line => { p.enterTagEdit(pane, -1); if (pane.tag_edit) pane.mode = .normal; }, .pipe_selection => |how| return p.startPrompt(pane, .{ .pipe = how }), .search => return p.startPrompt(pane, .{ .search = config.search_marker }), .search_step => |direction| return p.lookWalk( if (direction == .forward) @as(i32, 1) else -1, ), } } // ---- selection pipe (`|`): visible prompt, async shell, atomic edit ---- fn submitPipe(p: *Pardes, id: usize) void { const pane = p.panes[id] orelse return; const f = pane.file orelse return; if (!panes.Output.fileTraits(f.output).saves) return; const prompt = switch (pane.prompt) { .pipe => |pipe| pipe, else => return, }; const tail = pane.tagSlice(); const armed = tail[@min(prompt.at, tail.len)..]; const marker = pipeMarker(prompt.how); if (!std.mem.startsWith(u8, armed, marker)) return; const command = armed[marker.len..]; if (command.len == 0) return; var ranges: [Pane.max_selections]modal.Selection = undefined; const got = pane.ranges(f.content, 0, &ranges); // `!`/`A-!` take no stdin and run ONCE — see `PendingPipe.nranges`. const ninputs = if (prompt.how.pipes()) got.n else 1; const inputs = p.gpa.alloc(selection_pipe.Input, ninputs) catch return; var made: usize = 0; for (ranges[0..ninputs], 0..) |range, i| { const bytes = if (prompt.how.pipes()) bytes: { const lo = @min(range.anchor, range.head); const hi = @max(range.anchor, range.head); if (hi > f.content.len) break; break :bytes f.content[lo..hi]; } else ""; const copy = p.gpa.dupe(u8, bytes) catch break; inputs[i] = .{ .bytes = copy }; made += 1; } if (made != ninputs) { for (inputs[0..made]) |input| p.gpa.free(@constCast(input.bytes)); p.gpa.free(inputs); return; } const command_copy = p.gpa.dupe(u8, command) catch { for (inputs) |input| p.gpa.free(@constCast(input.bytes)); p.gpa.free(inputs); return; }; const cwd_copy = p.gpa.dupe(u8, paneDir(pane)) catch { p.gpa.free(command_copy); for (inputs) |input| p.gpa.free(@constCast(input.bytes)); p.gpa.free(inputs); return; }; p.pipe_seq +%= 1; if (p.pipe_wait) |*old| old.deinit(p.gpa); p.pipe_wait = .{ .id = p.pipe_seq, .pane = id, .serial = pane.serial, .revision = f.revision, .command = command_copy, .cwd = cwd_copy, .inputs = inputs, .ranges = ranges, .primary = @intCast(got.pri), .explicit = (pane.vsel.active and pane.vsel.explicit) or pane.msel.active, .how = prompt.how, .nranges = @intCast(got.n), }; p.emit(.{ .pipe = .{ .id = p.pipe_seq } }); } /// Frontends call this while draining a .pipe effect, then immediately /// copy the borrowed view into a worker-owned selection_pipe.Job. pub fn pipeRequest(p: *const Pardes, id: u32) ?selection_pipe.Request { if (p.pipe_wait) |*wait| { if (wait.id == id) return wait.request(); } return null; } fn pipeFailed(p: *Pardes, wait: *const PendingPipe, failure: ?selection_pipe.Failure) void { var out: std.Io.Writer.Allocating = .init(p.gpa); defer out.deinit(); const w = &out.writer; w.print("| {s}\n\n", .{wait.command}) catch return; if (failure) |fail| { if (wait.inputs.len > 1) w.print("selection {d} of {d}: ", .{ fail.index + 1, wait.inputs.len }) catch return; switch (fail.kind) { .exit => w.print("exit status {d}\n", .{fail.code}) catch return, .signal => w.writeAll("killed by a signal\n") catch return, .timeout => w.print( "still running after {d} seconds, and stopped\n", .{selection_pipe.command_timeout_seconds}, ) catch return, .too_large => w.writeAll("produced more output than a filter may return\n") catch return, .spawn => w.writeAll("could not be started\n") catch return, .io => w.writeAll("could not be read\n") catch return, } if (fail.stderr.len > 0) w.print("\n{s}", .{fail.stderr}) catch return; } else { // No diagnosis at all: nobody ran it. The detached daemon, the // browser and the board all leave `pipe` null on purpose. w.writeAll("this session cannot run filters\n") catch return; } const content = out.toOwnedSlice() catch return; const was = p.active; panes.Output.openErrors(p, wait.pane, content) catch |err| { p.gpa.free(content); // Nowhere to put the report is itself worth one line. p.reportError(wait.pane, "pipe", err); return; }; if (p.panes[was] != null) p.active = was; } fn pipeCut(wait: *const PendingPipe, content: []const u8, i: usize) ?struct { lo: usize, hi: usize } { const range = wait.ranges[i]; const lo = @min(range.anchor, range.head); const hi = @max(range.anchor, range.head); if (hi > content.len) return null; return switch (wait.how) { .replace, .ignore => .{ .lo = lo, .hi = hi }, .insert => .{ .lo = lo, .hi = lo }, .append => .{ .lo = hi, .hi = hi }, }; } fn pipeOutput( wait: *const PendingPipe, outputs: []const []const u8, content: []const u8, i: usize, ) []const u8 { var out = if (wait.how.pipes()) outputs[i] else outputs[0]; const range = wait.ranges[i]; const lo = @min(range.anchor, range.head); const hi = @max(range.anchor, range.head); const selected = if (hi <= content.len) content[lo..hi] else ""; if (!std.mem.endsWith(u8, selected, "\n") and std.mem.endsWith(u8, out, "\n")) { out = out[0 .. out.len - 1]; if (std.mem.endsWith(u8, out, "\r")) out = out[0 .. out.len - 1]; } return out; } fn pipeResponse( p: *Pardes, id: u32, success: bool, outputs: []const []const u8, failure: ?selection_pipe.Failure, ) void { // A SUPERSEDED OR UNKNOWN id is the one silence worth keeping: it is // the answer to a question nobody is still asking. if (p.pipe_wait == null or p.pipe_wait.?.id != id) return; var wait = p.pipe_wait.?; p.pipe_wait = null; defer wait.deinit(p.gpa); if (!success or outputs.len != wait.inputs.len) return p.pipeFailed(&wait, failure); const pane = p.panes[wait.pane] orelse return; if (pane.serial != wait.serial) return; const f = if (pane.file) |*file| file else return; if (!panes.Output.fileTraits(f.output).saves) return; if (f.revision != wait.revision) return p.reportError(wait.pane, "pipe", error.FileChangedWhileFiltering); if (wait.how == .ignore) return; const n = wait.nranges; var total_output: usize = 0; var removed: usize = 0; var previous_end: usize = 0; for (0..n) |i| { const cut = pipeCut(&wait, f.content, i) orelse return; const output = pipeOutput(&wait, outputs, f.content, i); if (output.len > selection_pipe.max_stdout_bytes) return; total_output = std.math.add(usize, total_output, output.len) catch return; if (total_output > selection_pipe.max_total_stdout_bytes) return; if (cut.lo < previous_end) return; removed = std.math.add(usize, removed, cut.hi - cut.lo) catch return; previous_end = cut.hi; } const kept = f.content.len - removed; const final_len = std.math.add(usize, kept, total_output) catch return; const replacement = p.gpa.alloc(u8, final_len) catch return; var new_ranges: [Pane.max_selections]modal.Selection = undefined; var read_at: usize = 0; var write_at: usize = 0; for (0..n) |i| { const range = wait.ranges[i]; const cut = pipeCut(&wait, f.content, i).?; const output = pipeOutput(&wait, outputs, f.content, i); @memcpy(replacement[write_at .. write_at + (cut.lo - read_at)], f.content[read_at..cut.lo]); write_at += cut.lo - read_at; const out_start = write_at; @memcpy(replacement[write_at .. write_at + output.len], output); write_at += output.len; const out_end = write_at; new_ranges[i] = if (range.anchor > range.head) .{ .anchor = out_end, .head = out_start } else .{ .anchor = out_start, .head = out_end }; read_at = cut.hi; } @memcpy(replacement[write_at..], f.content[read_at..]); // One async request is one history transaction, even at 64 cursors. p.pushUndo(pane); panes.File.setContent(p, f, replacement); pane.setRanges(f.content, new_ranges[0..n], &.{}, wait.primary, wait.explicit); pane.select = false; pane.ensureCursorVisible(); } // ---- search (`/`, Find, n/N): results into an output buffer, look the rows ---- const Search = enum { text, find, grep }; const SearchStart = enum { top, cursor }; pub fn paneDir(pane: *const Pane) []const u8 { switch (pane.cwd) { .inherited => |src| return paneDir(src), .owned => |cwd| return cwd, .none => {}, } if (pane.file) |f| return std.fs.path.dirname(f.path) orelse "/"; if (comptime pdf_enabled) if (pane.pdf) |pv| return std.fs.path.dirname(pv.path) orelse "/"; if (pane.image) |iv| return std.fs.path.dirname(iv.path) orelse "/"; return pane.cwdSlice(); } pub fn startPrompt(p: *Pardes, pane: *Pane, request: union(enum) { search: []const u8, pipe: modal.Normal.PipeBehavior, save, }) void { const marker = switch (request) { .search => |marker| marker, .save => config.save_marker, .pipe => |how| pipe: { const file = pane.file orelse return; if (!panes.Output.fileTraits(file.output).saves) return; break :pipe pipeMarker(how); }, }; p.seedTail(pane); if (!pane.tag_init) return; const dir = if (request == .save) paneDir(pane) else ""; const slash = request == .save and (dir.len == 0 or dir[dir.len - 1] != '/'); var room = pane.tag_tail.len - pane.tag_tail_len; if (marker.len > room) return; room -= marker.len; if (dir.len > room) return; room -= dir.len; if (slash and room == 0) return; const prefix_len = (p.tagPrefix(pane) catch return).len; const prompt_at: u16 = @intCast(pane.tag_tail_len); _ = pane.appendTag(marker); if (request == .save) { _ = pane.appendTag(dir); if (slash) _ = pane.appendTag("/"); } pane.prompt = switch (request) { .search => .{ .search = prompt_at }, .pipe => |how| .{ .pipe = .{ .at = prompt_at, .how = how } }, .save => .{ .save = prompt_at }, }; pane.tag_edit = true; pane.tag_sel = false; pane.mode = .insert; pane.normal.clear(); pane.tag_col = @intCast(prefix_len + pane.tag_tail_len); } /// Enter on a save input: the path is everything past the marker. fn submitSave(p: *Pardes, id: usize) void { const pane = p.panes[id] orelse return; const at = switch (pane.prompt) { .save => |a| a, else => return, }; const armed = pane.tag_tail[at..pane.tag_tail_len]; pane.tag_tail_len = at; // drop the prompt back to the seeded tail if (!std.mem.startsWith(u8, armed, config.save_marker)) return; const path = std.mem.trim(u8, armed[config.save_marker.len..], " \t\r\n"); if (path.len == 0) return; p.saveTo(id, path); } /// Write the file this pane already has: acme's Put, and the one Save that /// needs no argument because the path was never a question. pub fn saveFile(p: *Pardes, id: usize) void { const pane = p.panes[id] orelse return; const f = if (pane.file) |*file| file else return; if (f.output != null) return; // nothing behind it yet: saveTo, with a path p.emit(.{ .save_file = .{ .pane = @intCast(id) } }); } pub fn saveTo(p: *Pardes, id: usize, path: []const u8) void { const pane = p.panes[id] orelse return; if (path.len == 0 or path[path.len - 1] == '/' or std.mem.indexOfScalar(u8, path, '\n') != null) return p.reportError(id, "save", error.NoFilename); const full = std.fs.path.resolvePosix( p.scratch.allocator(), &.{ paneDir(pane), path }, ) catch return; if (!std.fs.path.isAbsolute(full)) return p.reportError(id, "save", error.NoDirectory); if (full.len > SavePath.cap) return p.reportError(id, "save", error.PathTooLong); const f = if (pane.file) |*file| file else { // a terminal writes its scrollback; an image and a PDF have nothing // of their own that is unwritten, so they have nothing to write if (pane.isTerminal()) p.askWrite(id, pane.serial, full); return; }; if (f.output != null and panes.Output.fileTraits(f.output).saves) { const owned = p.gpa.dupe(u8, full) catch return; p.gpa.free(f.path); f.path = owned; f.output = null; // an ordinary file pane from here on f.watch_after_save = true; pane.clearCwd(); pane.tag_init = false; // re-derive the tag as a plain file pane.tag_tail_len = 0; p.emit(.{ .save_file = .{ .pane = @intCast(id) } }); return; } // its own path, spelled out: the in-place write, so the pane comes clean if (f.output == null and std.mem.eql(u8, f.path, full)) return p.saveFile(id); p.askWrite(id, pane.serial, full); } const SavePath = Effect.Buf(effect_path_cap); fn askWrite(p: *Pardes, id: usize, serial: u32, path: []const u8) void { p.emit(.{ .save_text = .{ .pane = @intCast(id), .serial = serial, .path = SavePath.from(path), } }); } fn submitSearch(p: *Pardes, id: usize) void { const pane = p.panes[id] orelse return; if (selRegexArmed(pane)) |a| return p.applySelRegex(pane, a.pat, a.split); const tail = pane.tagSlice(); const armed = tail[@min(pane.promptAt() orelse return, tail.len)..]; const slash = std.mem.indexOfScalar(u8, armed, '/') orelse return; if (std.mem.startsWith(u8, armed, config.rename_marker)) return p.lspRequest(id, .rename, armed[slash + 1 ..]); if (std.mem.startsWith(u8, armed, config.symbol_marker)) return p.lspRequest(id, .workspace_symbols, armed[slash + 1 ..]); const kind: Search = if (std.mem.startsWith(u8, armed, config.find_marker)) .find else if (std.mem.startsWith(u8, armed, config.grep_marker)) .grep else .text; p.runSearch(id, armed[slash + 1 ..], kind, .top) catch |err| return p.reportError(id, "search", err); if (kind == .text) p.lookFirstHit(id); } fn lookFirstHit(p: *Pardes, id: usize) void { const pane = p.panes[id] orelse return; const rid = pane.search_pane orelse return; const rp = p.panes[rid] orelse return; if ((rp.file orelse return).content.len == 0) return; p.active = rid; p.lookWalk(1); if (p.active != rid) return; // walked out of the list; leave it there const txt = p.currentSelText(rp) orelse return; // spent, exactly as the look chord spends it (handleKey) rp.vsel.active = false; rp.msel.active = false; rp.select = false; p.runBuiltin(config.look_cmd, rid, "", txt); } pub fn runSearch(p: *Pardes, id: usize, pat_raw: []const u8, kind: Search, start: SearchStart) !void { const pane = p.panes[id] orelse return; const pat = std.mem.trim(u8, pat_raw, " \t\r\n"); if (pat.len == 0) return; const arena = p.scratch.allocator(); // where the pane lives: a file's directory, a shell's cwd — the walk // root, and the directory the results buffer is named in. const dir = paneDir(pane); const out = try arena.alloc(u8, filesystem.search_max_output_bytes); var out_len: usize = 0; var nrows: usize = 0; var anchor: ?usize = null; if (kind == .grep) { var roots: [MAX_PANES][]const u8 = undefined; var nroots: usize = 0; for (p.panes) |slot| { const pp = slot orelse continue; const d = paneDir(pp); var covered = false; var k: usize = 0; while (k < nroots) { if (pathUnder(d, roots[k])) { covered = true; break; } if (pathUnder(roots[k], d)) { // this one swallows a root already kept nroots -= 1; roots[k] = roots[nroots]; continue; } k += 1; } if (covered) continue; roots[nroots] = d; nroots += 1; } for (roots[0..nroots]) |r| out_len += try filesystem.grep(arena, p.gpa, r, dir, pat, out[out_len..]); } else if (kind == .find) { out_len = try filesystem.find(arena, dir, pat, out); } else if (pane.hasPdf()) { if (comptime pdf_enabled) { const found = try panes.Pdf.searchRows( &pane.pdf.?, p.pdf_gpa, arena, pat, start == .cursor, out, ); out_len = found.bytes; nrows = found.rows; anchor = found.anchor; } } else { const lines = try p.paneCursorLines(pane); // a real file names itself; a terminal or an output buffer has no path const has_path = if (pane.file) |f| f.output == null else false; var idbuf: [16]u8 = undefined; // relative to `dir`, like Find's rows — and `dir` IS this file's // own directory, so that is exactly its basename const loc: []const u8 = if (has_path) std.fs.path.basename(pane.file.?.path) else std.fmt.bufPrint(&idbuf, config.pane_addr ++ "{d}", .{id}) catch return error.PathTooLong; const cl: usize = @intCast(@max(0, pane.cur_row)); const cc: usize = @intCast(@max(0, pane.cur_col)); for (lines, 0..) |ln, i| { const at = std.ascii.indexOfIgnoreCase(ln, pat) orelse continue; const row = try std.fmt.allocPrint(arena, "{s}:{d}:{d}{c}{d} {s}\n", .{ loc, i + 1, at + 1, config.range_sep, at + pat.len, std.mem.trimEnd(u8, ln, " \t"), }); if (row.len > out.len - out_len) break; if (start == .cursor and (i < cl or (i == cl and at <= cc))) anchor = nrows; @memcpy(out[out_len..][0..row.len], row); out_len += row.len; nrows += 1; } } const content = try p.gpa.dupe(u8, out[0..out_len]); // the buffer records WHICH search filled it, pattern and all: Find and // Grep are builtins (words you can execute), the bare `/` is a key const from: panes.Output.Origin = switch (kind) { .text => .search, .find => .{ .cmd = .Find }, .grep => .{ .cmd = .Grep }, }; try panes.Output.fillResults(p, id, dir, from, pat, content, anchor); } fn searchStep(p: *Pardes, id: usize, delta: i32) bool { const pane = p.panes[id] orelse return false; const rid = pane.search_pane orelse return false; const rp = p.panes[rid] orelse return false; const rf = if (rp.file) |*f| f else return false; // one question covers both hazards: a freed slot can be reused by an // unrelated pane, and a buffer of PROSE has nowhere to step to const tr = panes.Output.fileTraits(rf.output); if (!tr.steps) return false; // fresh results: n starts at the first row, N has nothing behind it const nrows: i64 = @intCast(std.mem.count(u8, rf.content, "\n") + @intFromBool(rf.content.len > 0 and rf.content[rf.content.len - 1] != '\n')); var step: i64 = if (pane.search_row) |c| @as(i64, @intCast(c)) + delta else if (delta > 0) 0 else -1; if (delta == 0) return true; const direction: i64 = if (delta > 0) 1 else -1; while (step >= 0 and step < nrows) : (step += direction) { if (!panes.Output.isResultRow(rf, @intCast(step))) continue; _ = p.jumpResult(id, @intCast(step)); p.active = id; break; } return true; } fn jumpResult(p: *Pardes, id: usize, row: usize) bool { const pane = p.panes[id] orelse return false; const rid = pane.search_pane orelse return false; const rp = p.panes[rid] orelse return false; const rf = if (rp.file) |*f| f else return false; const tr = panes.Output.fileTraits(rf.output); const ln = modal.lineSlice(rf.content, row); if (ln.len == 0) return false; const r: i32 = @intCast(row); pane.search_row = row; rp.msel = .{ .active = true, .r0 = r, .r1 = r }; rp.vsel.active = false; rp.nsel = 0; rp.cur_row = r; rp.cur_pinned = true; const off = rp.scroll(); const last = off + @as(i32, rp.rows) - 1; if (r < off) rp.scrollBy(r - off) else if (r > last) rp.scrollBy(r - last); var realbuf: [4096]u8 = undefined; const span = if (tr.commands) wholeRowSpan(ln) else look.lineSpan(p, ln, paneDir(rp), &realbuf) orelse blk: { const target = panes.Output.location(ln); break :blk if (target.at.line > 0) look.Span{ .start = 0, .end = target.end } else null; }; if (span) |selected| { rp.cur_col = @intCast(selected.start); rp.look_at = .{ .row = r, .col0 = @intCast(selected.start), .col1 = @intCast(selected.end - 1), }; } else { rp.cur_col = 0; rp.look_at = null; } if (tr.commands) { p.runBuiltin(config.exec_cmd, rid, "", std.mem.trim(u8, ln, " \t\r")); } else if (span) |selected| { p.lookAt(rid, ln[selected.start..selected.end]); } p.armLookWalk(rid); return true; } pub fn lspRequest(p: *Pardes, id: usize, kind: lsp.Kind, arg: []const u8) void { if (!p.multiOnce()) return; // one question per keystroke, from the primary if (!lsp.supports.contains(kind)) return; const pane = p.panes[id] orelse return; if (kind != .status) { const f = pane.file orelse return; if (f.output != null) return; } if (arg.len > 128) return; // the effect's arg is a Buf(128) if (kind == .rename) { const zig_buf = if (pane.file) |f| std.mem.endsWith(u8, f.path, ".zig") else true; if (zig_buf and (!std.zig.isValidId(arg) or std.zig.isUnderscore(arg))) return; if (!zig_buf and std.mem.indexOfAny(u8, arg, " \t\r\n\"\\") != null) return; } const off = if (pane.file) |f| modal.offsetAt(f.content, .{ .row = @intCast(@max(0, pane.cur_row)), .col = @intCast(@max(0, pane.cur_col)), }) else 0; p.lsp_seq +%= 1; p.lsp_wait = .{ .id = p.lsp_seq, .kind = kind, .pane = id, .serial = pane.serial, .revision = if (pane.file) |f| f.revision else 0, .arg = .from(arg), .row = pane.cur_row, .col = pane.cur_col, }; p.emit(.{ .lsp = .{ .id = p.lsp_seq, .kind = kind, .pane = @intCast(id), .offset = @intCast(off), .arg = .from(arg), } }); } const LspEdit = struct { start: usize, end: usize, text: []const u8 }; fn parseLspEdits(p: *Pardes, bytes: []const u8, fallback: ?[]const u8) ?[]LspEdit { if (bytes.len == 0 or bytes[bytes.len - 1] != '\n') return null; const a = p.scratch.allocator(); const edits = a.alloc(LspEdit, std.mem.count(u8, bytes, "\n")) catch return null; var lines = std.mem.splitScalar(u8, bytes, '\n'); var n: usize = 0; var form: enum { undecided, edit, put } = .undecided; while (lines.next()) |line| { if (line.len == 0) { if (lines.peek() == null) break; return null; } var fields = std.mem.tokenizeScalar(u8, line, ' '); const head = fields.next() orelse return null; const want: @TypeOf(form) = if (std.mem.eql(u8, head, "@put")) .put else if (std.mem.eql(u8, head, "@edit")) .edit else return null; if (form == .undecided) form = want; if (form != want) return null; const start = std.fmt.parseInt(usize, fields.next() orelse return null, 10) catch return null; const end = std.fmt.parseInt(usize, fields.next() orelse return null, 10) catch return null; var text: []const u8 = if (want == .edit) (fallback orelse return null) else ""; if (want == .put) { if (fields.next()) |encoded| text = decodePct(a, encoded) orelse return null; } if (fields.next() != null) return null; edits[n] = .{ .start = start, .end = end, .text = text }; n += 1; } return if (n == 0) null else edits[0..n]; } /// The `@put` text codec's read half (lsp.put writes it): `%XX` bytes, /// everything else literal. An invalid escape fails the whole record. fn decodePct(a: std.mem.Allocator, encoded: []const u8) ?[]const u8 { const out = a.alloc(u8, encoded.len) catch return null; var n: usize = 0; var i: usize = 0; while (i < encoded.len) { if (encoded[i] == '%') { if (i + 3 > encoded.len) return null; out[n] = std.fmt.parseInt(u8, encoded[i + 1 .. i + 3], 16) catch return null; i += 3; } else { out[n] = encoded[i]; i += 1; } n += 1; } return out[0..n]; } fn mapLspEditOffset(edits: []const LspEdit, old: usize) usize { var old_at: usize = 0; var new_at: usize = 0; for (edits) |e| { if (old < e.start) return new_at + (old - old_at); new_at += e.start - old_at; if (old < e.end) return new_at + @min(old - e.start, e.text.len -| 1); new_at += e.text.len; if (old == e.end) return new_at; old_at = e.end; } return new_at + (old - old_at); } fn applyLspEdits(p: *Pardes, pane: *Pane, revision: u32, edits: []const LspEdit) bool { const f = if (pane.file) |*file| file else return false; if (f.revision != revision) return false; var removed: usize = 0; var added: usize = 0; var previous_end: usize = 0; var same = true; for (edits) |e| { if (e.start < previous_end or e.start > e.end or e.end > f.content.len) return false; if (e.start == e.end and e.text.len == 0) return false; // an empty insert is a malformed record removed = std.math.add(usize, removed, e.end - e.start) catch return false; added = std.math.add(usize, added, e.text.len) catch return false; previous_end = e.end; if (same and !std.mem.eql(u8, f.content[e.start..e.end], e.text)) same = false; } if (same) return false; // a no-op response earns no undo entry const final_len = std.math.add(usize, f.content.len - removed, added) catch return false; const replacement = p.gpa.alloc(u8, final_len) catch return false; const old_cursor = modal.offsetAt(f.content, .{ .row = @intCast(@max(0, pane.cur_row)), .col = @intCast(@max(0, pane.cur_col)), }); const mapped_cursor = mapLspEditOffset(edits, old_cursor); var read_at: usize = 0; var write_at: usize = 0; for (edits) |e| { @memcpy(replacement[write_at .. write_at + (e.start - read_at)], f.content[read_at..e.start]); write_at += e.start - read_at; @memcpy(replacement[write_at .. write_at + e.text.len], e.text); write_at += e.text.len; read_at = e.end; } @memcpy(replacement[write_at..], f.content[read_at..]); p.pushUndo(pane); panes.File.setContent(p, f, replacement); const cursor = modal.positionAt(f.content, mapped_cursor); pane.cur_row = @intCast(cursor.row); pane.cur_col = @intCast(cursor.col); pane.vsel.active = false; pane.msel.active = false; pane.select = false; pane.sticky_col = -1; pane.ensureCursorVisible(); return true; } pub fn lspResponse(p: *Pardes, id: u32, response: ?[]const u8) void { const w = p.lsp_wait orelse return; if (w.id != id) return; // superseded by a newer press, or the pane died p.lsp_wait = null; const rows = response orelse return; const pane = p.panes[w.pane] orelse return; if (pane.serial != w.serial) return; if (w.kind == .rename or w.kind == .format) { const fallback: ?[]const u8 = if (w.kind == .rename) w.arg.slice() else null; if (p.parseLspEdits(rows, fallback)) |edits| { if (p.applyLspEdits(pane, w.revision, edits)) { var buf: [64]u8 = undefined; const verb: []const u8 = if (w.kind == .rename) "renamed" else "formatted"; p.setMessage(w.pane, std.fmt.bufPrint(&buf, "{s} {d} range(s)", .{ verb, edits.len }) catch verb); } return; } if (w.kind == .rename and rows.len > 0) { // fall through: a multi-file rename previews as rows } else if (w.kind == .format and rows.len > 0) { // fall through: "cannot format" and friends are prose } else return; } if (rows.len == 0) { if (w.kind == .completion and pane.mode == .insert and pane.nsel == 0 and pane.cur_row == w.row and pane.cur_col == w.col) p.insertTab(pane); return; } const from: panes.Output.Origin = .{ .query = w.kind }; const nrows = std.mem.count(u8, rows, "\n"); if (panes.Output.traits(from).jumps and nrows == 1) { const ln = std.mem.trimEnd(u8, rows, "\n"); return p.lookAt(w.pane, ln[0..panes.Output.location(ln).end]); } const dir = if (pane.file) |f| (std.fs.path.dirname(f.path) orelse "/") else pane.cwdSlice(); const content = p.gpa.dupe(u8, rows) catch return; panes.Output.fillResults(p, w.pane, dir, from, w.arg.slice(), content, null) catch |err| { p.reportError(w.pane, "language response", err); return; }; if (panes.Output.traits(from).jumps) { const result = p.panes[pane.search_pane orelse return] orelse return; const file = pane.file orelse return; const row = panes.Output.nextResult(result.file.?.content, result.file.?.location_rows, lsp.rel(dir, file.path), .{ .line = @as(usize, @intCast(@max(0, w.row))) + 1, .col = @as(usize, @intCast(@max(0, w.col))) + 1, }); _ = p.jumpResult(w.pane, row); } } // ---- n/N: the walk over look-able text ---- const max_look_rows = 8192; const LookFrom = struct { row: i32, col: ?i32, strict: bool = false }; fn lookEdge(delta: i32) LookFrom { return .{ .row = if (delta > 0) 0 else std.math.maxInt(i32), .col = null }; } fn lookStand(pane: *Pane) LookFrom { if (pane.look_at) |s| if (s.row == pane.cur_row and s.col0 == pane.cur_col) return .{ .row = s.row, .col = s.col0, .strict = true }; return .{ .row = pane.cur_row, .col = pane.cur_col }; } fn lookWalkPanes(p: *Pardes, out: *[MAX_PANES]usize) []const usize { var n: usize = 0; var i = p.n_look_src; while (i > 0) { i -= 1; const id = p.paneBySerial(p.look_src[i]) orelse continue; out[n] = id; n += 1; } const looked = n; for (p.panes, 0..) |slot, id| { const pane = slot orelse continue; const f = pane.file orelse continue; if (f.output == null) continue; for (out[0..looked]) |k| { if (k == id) break; } else { // insertion by serial descending — at most MAX_PANES // comparisons, which is not a sort worth naming var at = n; while (at > looked and p.panes[out[at - 1]].?.serial < pane.serial) : (at -= 1) out[at] = out[at - 1]; out[at] = id; n += 1; } } if (n == 0) { if (p.panes[p.active] == null) return out[0..0]; out[0] = p.active; n = 1; } return out[0..n]; } fn lookPast(col0: i32, from: LookFrom, on_start_row: bool, delta: i32) bool { if (!on_start_row) return true; const c = from.col orelse return true; if (delta > 0) return if (from.strict) col0 > c else col0 >= c; return if (from.strict) col0 < c else col0 <= c; } fn wholeRowSpan(ln: []const u8) ?look.Span { var lo: usize = 0; while (lo < ln.len and (ln[lo] == ' ' or ln[lo] == '\t')) lo += 1; const hi = std.mem.trimEnd(u8, ln, " \t\r").len; return if (hi > lo) .{ .start = lo, .end = hi } else null; } fn lookSpanIn(p: *Pardes, pane: *Pane, from: LookFrom, delta: i32, budget: *usize) ?Pane.LookSpot { const lines = p.paneCursorLines(pane) catch return null; const nrows: i32 = @intCast(lines.len); if (nrows == 0) return null; const grain: panes.Output.Grain = if (pane.file) |*f| panes.Output.grain(f.output) else .word; const dir = paneDir(pane); var realbuf: [4096]u8 = undefined; const start = std.math.clamp(from.row, 0, nrows - 1); var r = start; while (r >= 0 and r < nrows) : (r += delta) { if (budget.* == 0) return null; budget.* -= 1; const ln = lines[@intCast(r)]; if (pane.file) |*f| if (!panes.Output.isWalkRow(f, @intCast(r), ln)) continue; const on_start = r == start; switch (grain) { .word => { var best: ?Pane.LookSpot = null; var i: usize = 0; while (i < ln.len) { while (i < ln.len and (ln[i] == ' ' or ln[i] == '\t')) i += 1; const t0 = i; while (i < ln.len and ln[i] != ' ' and ln[i] != '\t') i += 1; if (i == t0) break; const sp = look.wordSpan(p, ln[t0..i], dir, &realbuf) orelse continue; const col0: i32 = @intCast(t0 + sp.start); if (!lookPast(col0, from, on_start, delta)) continue; best = .{ .row = r, .col0 = col0, .col1 = @intCast(t0 + sp.end - 1) }; if (delta > 0) break; // first one forward; keep the last one back } if (best) |b| return b; }, // one span per row, so there is nothing to keep and nothing to // scan past: the row either offers it or it does not .line, .whole => { const sp = span: { if (grain == .whole) break :span wholeRowSpan(ln); if (pane.file) |f| if (@as(usize, @intCast(r)) < f.location_rows.len) { const end = f.location_rows[@intCast(r)].location_end; if (end > 0) break :span @as(?look.Span, .{ .start = 0, .end = end }); }; break :span look.lineSpan(p, ln, dir, &realbuf); } orelse continue; const col0: i32 = @intCast(sp.start); if (lookPast(col0, from, on_start, delta)) return .{ .row = r, .col0 = col0, .col1 = @intCast(sp.end - 1) }; }, } } return null; } fn lookWalk(p: *Pardes, delta: i32) void { var buf: [MAX_PANES]usize = undefined; const order = p.lookWalkPanes(&buf); if (order.len == 0) return; const owner = if (p.look_walk_owner) |serial| p.paneBySerial(serial) else null; const active_at = std.mem.indexOfScalar(usize, order, p.active) orelse 0; const at = if (owner) |wanted| std.mem.indexOfScalar(usize, order, wanted) orelse active_at else active_at; var from = lookStand(p.panes[order[at]] orelse return); var budget: usize = max_look_rows; var k: usize = 0; while (k <= order.len) : (k += 1) { // `+ 2 * len` only so the backward subtraction stays unsigned const idx = (if (delta > 0) at + k else at + 2 * order.len - k) % order.len; const pane = p.panes[order[idx]] orelse continue; if (p.lookSpanIn(pane, from, delta, &budget)) |spot| return p.landLookSpot(order[idx], pane, spot); if (budget == 0) return; // gave up mid-pane: stay put, stay reversible from = lookEdge(delta); } } fn landLookSpot(p: *Pardes, id: usize, pane: *Pane, spot: Pane.LookSpot) void { p.pinPaneCursor(pane); // fresh out of tty mode the cursor still tracks the shell pane.vsel = .{ .active = true, .row = spot.row, .col = spot.col1, .explicit = true }; pane.msel.active = false; pane.nsel = 0; pane.cur_row = spot.row; pane.cur_col = spot.col0; pane.cur_pinned = true; pane.look_at = spot; p.look_walk_owner = pane.serial; p.active = id; pane.ensureCursorVisible(); } /// Route shared edit operations to a file's content or a terminal overlay. fn editText(p: *Pardes, pane: *Pane, lo: i32, hi: i32, col: i32) ?panes.EditText { if (pane.file) |f| return .{ .text = f.content, .row0 = 0 }; if (pane.image != null or pane.hasPdf()) return null; return panes.Terminal.editText(p, pane, lo, hi, col); } fn editTextEol(p: *Pardes, pane: *Pane, b: Bounds) ?panes.EditText { const eb = p.editText(pane, b.lo_row, b.hi_row, -1) orelse return null; const r: usize = @intCast(@max(0, b.hi_row - eb.row0)); if (r + 1 < modal.lineCount(eb.text)) return eb; if (b.hi_col < @as(i32, @intCast(modal.lineSlice(eb.text, r).len))) return eb; return p.editText(pane, b.lo_row, b.hi_row + 1, -1); } /// install a rewritten editable text (frees the old one) fn setEditText(p: *Pardes, pane: *Pane, new: []u8) void { if (pane.file) |*f| return panes.File.setContent(p, f, new); panes.Terminal.setEditText(p, pane, new); } const InsertAt = enum { at, append, line_start, line_end, open_below, open_above }; /// Files insert only inside existing rows and at grapheme boundaries. /// Terminal overlays can materialize virtual rows/columns, so keep those. fn clampFileCursor(pane: *Pane) bool { const file = if (pane.file) |*file| file else return false; const old_row = pane.cur_row; const old_col = pane.cur_col; const last = panes.File.nlines(pane.gpa, file) -| 1; pane.cur_row = @intCast(@min(last, @as(usize, @intCast(@max(0, pane.cur_row))))); const line = panes.File.sourceLine(pane, pane.cur_row); pane.cur_col = @intCast(modal.graphemeStart(line, @min(line.len, @as(usize, @intCast(@max(0, pane.cur_col)))))); return old_row != pane.cur_row or old_col != pane.cur_col; } fn enterInsert(p: *Pardes, pane: *Pane, where: InsertAt, cnt: usize) void { if (pane.hasPdf()) return; p.pinPaneCursor(pane); _ = clampFileCursor(pane); // snapshot once per insert session (WITH the pre-insert selection) so // `u` undoes the whole session and restores what was selected p.pushUndo(pane); pane.select = false; pane.append_at = null; pane.sticky_col = -1; const text = p.flatSurface(pane) catch { pane.mode = .insert; pane.msel.active = false; pane.vsel.active = false; pane.normal.clear(); return; }; const cur = pane.toModalCursor(); const line = panes.File.textLine(pane, text, cur.row); const b: ?Bounds = if (pane.vsel.active) vselBounds(pane) else null; switch (where) { .at => { if (b) |bb| { pane.cur_row = bb.lo_row; pane.cur_col = bb.lo_col; pane.vsel = .{ .active = true, .row = bb.hi_row, .col = bb.hi_col, .explicit = false }; pane.cur_pinned = true; } }, .append => { const lo_row = if (b) |bb| bb.lo_row else pane.cur_row; const lo_col = if (b) |bb| bb.lo_col else pane.cur_col; const hi_row = if (b) |bb| bb.hi_row else pane.cur_row; const hi_col = if (b) |bb| bb.hi_col else pane.cur_col; pane.append_at = .{ .row = lo_row, .col = lo_col }; const gap = modal.nextGrapheme(text, modal.offsetAt(text, .{ .row = @intCast(@max(0, hi_row)), .col = @intCast(@max(0, hi_col)) })); const gc = modal.positionAt(text, gap); pane.cur_row = @intCast(gc.row); pane.cur_col = @intCast(gc.col); pane.vsel = .{ .active = b != null, .row = lo_row, .col = lo_col, .explicit = false }; pane.cur_pinned = true; }, .line_start => { pane.fromModalCursor(.{ .row = cur.row, .col = modal.firstNonWs(line) }); pane.vsel.active = false; }, .line_end => { pane.cur_col = @intCast(line.len); pane.cur_pinned = true; pane.vsel.active = false; }, .open_below, .open_above => { pane.vsel.active = false; const below = where == .open_below; const abs: i32 = if (b) |bb| (if (below) bb.hi_row else bb.lo_row) else pane.cur_row; const eb = p.editText(pane, abs, abs, -1) orelse return; const row: usize = @intCast(@max(0, abs - eb.row0)); const ind = modal.indentText(modal.lineSlice(eb.text, row)); const arena = p.scratch.allocator(); const block_len = std.math.mul(usize, cnt, ind.len + 1) catch return; const block = arena.alloc(u8, block_len) catch return; var block_at: usize = 0; for (0..cnt) |_| { if (below) { block[block_at] = '\n'; block_at += 1; } @memcpy(block[block_at..][0..ind.len], ind); block_at += ind.len; if (!below) { block[block_at] = '\n'; block_at += 1; } } const at: modal.Cursor = if (below) .{ .row = row, .col = modal.lineSlice(eb.text, row).len } else .{ .row = row, .col = 0 }; const new = modal.insertAt(p.gpa, eb.text, at, block) catch return; p.setEditText(pane, new); pane.cur_row = @as(i32, @intCast(if (below) row + 1 else row)) + eb.row0; pane.cur_col = @intCast(ind.len); pane.cur_pinned = true; if (cnt > 1 and !p.multi_on) opened: { const t2 = p.flatSurface(pane) catch break :opened; var rs: [Pane.max_selections]modal.Selection = undefined; const m = @min(cnt, Pane.max_selections); for (0..m) |k| { const o = modal.offsetAt(t2, .{ .row = @intCast(@max(0, pane.cur_row) + @as(i32, @intCast(k))), .col = ind.len }); rs[k] = .{ .anchor = o, .head = o }; } pane.setRanges(t2, rs[0..m], &.{}, 0, false); } }, } pane.mode = .insert; pane.msel.active = false; pane.normal.clear(); pane.ensureCursorVisible(); } fn handleInsert(p: *Pardes, pane: *Pane, key: Key) void { if (pane.nsel == 0) return p.insertKey(pane, key); p.replaySels(pane, .{ .insert = key }); } fn insertKey(p: *Pardes, pane: *Pane, key: Key) void { // helix aliases: normalize to the base key and re-dispatch if (hit(key, config.insert_backspace_alias)) return p.insertKey(pane, .{ .cp = Key.backspace }); if (hit(key, config.insert_enter_alias)) return p.insertKey(pane, .{ .cp = Key.enter }); if (hit(key, config.insert_delete_alias)) return p.insertKey(pane, .{ .cp = Key.delete }); pane.vsel.active = false; if (!pane.cur_pinned) p.pinPaneCursor(pane); _ = clampFileCursor(pane); switch (key.cp) { Key.left, Key.right, Key.up, Key.down => { const lines = p.paneCursorLines(pane) catch return; const cur0 = pane.toModalCursor(); const nc = switch (key.cp) { Key.left => modal.charLeft(lines, cur0), Key.right => modal.charRight(lines, cur0), Key.up => Pane.insertVerticalCursor(lines, cur0, false), Key.down => Pane.insertVerticalCursor(lines, cur0, true), else => cur0, }; pane.fromModalCursor(nc); pane.ensureCursorVisible(); return; }, Key.page_up, Key.page_down => { // helix binds insert pageup/pagedown to the same view // scroll + cursor snap as normal mode const flat = p.flatSurface(pane) catch return; const range = pane.primaryRange(flat, 0); const step: i32 = @intCast(@max(1, pane.rows)); pane.scrollViewMove(flat, range, if (key.cp == Key.page_down) step else -step); return; }, Key.home => { pane.cur_col = 0; pane.cur_pinned = true; pane.ensureCursorVisible(); return; }, Key.end => { // helix insert End: past the last char (goto_line_end_newline) const text = p.flatSurface(pane) catch return; const cur0 = pane.toModalCursor(); pane.cur_col = @intCast(panes.File.textLine(pane, text, cur0.row).len); pane.cur_pinned = true; pane.ensureCursorVisible(); return; }, else => {}, } const eb = p.editText(pane, pane.cur_row, pane.cur_row, pane.cur_col) orelse return; const text = eb.text; const c = modal.Cursor{ .row = @intCast(@max(0, pane.cur_row - eb.row0)), .col = @intCast(@max(0, pane.cur_col)) }; if (key.text.len > 0 and !key.ctrl and !key.alt) { const new = modal.insertAt(p.gpa, text, c, key.text) catch return; p.setEditText(pane, new); for (key.text) |ch| { if (ch == '\n') { pane.cur_row += 1; pane.cur_col = 0; } else pane.cur_col += 1; } pane.cur_pinned = true; pane.ensureCursorVisible(); return; } // helix insert-mode kills (word/line; deleteSpan is exclusive) if (hit(key, config.delete_word_backward)) { const e2 = if (c.row == 0 and c.col == 0) (p.editText(pane, pane.cur_row - 1, pane.cur_row, 0) orelse return) else eb; const c2 = modal.Cursor{ .row = @intCast(@max(0, pane.cur_row - e2.row0)), .col = c.col }; const g = modal.offsetAt(e2.text, c2); if (g == 0) return; const wr = modal.moveWord(e2.text, .{ .anchor = g, .head = g }, 1, .prev_word_start); const from = @min(wr.anchor, wr.head); const fc = modal.positionAt(e2.text, from); const new = modal.deleteSpan(p.gpa, e2.text, fc, c2) catch return; p.setEditText(pane, new); pane.cur_row = @as(i32, @intCast(fc.row)) + e2.row0; pane.cur_col = @intCast(fc.col); pane.cur_pinned = true; pane.ensureCursorVisible(); return; } if (hit(key, config.delete_word_forward)) { const e2 = if (c.col >= modal.lineSlice(text, c.row).len and c.row + 1 >= modal.lineCount(text)) (p.editText(pane, pane.cur_row, pane.cur_row + 1, pane.cur_col) orelse return) else eb; const c2 = modal.Cursor{ .row = @intCast(@max(0, pane.cur_row - e2.row0)), .col = c.col }; const g = modal.offsetAt(e2.text, c2); const wr = modal.moveWord(e2.text, .{ .anchor = g, .head = g }, 1, .next_word_end); const to = @max(wr.anchor, wr.head); if (to <= g) return; const new = modal.deleteSpan(p.gpa, e2.text, c2, modal.positionAt(e2.text, to)) catch return; p.setEditText(pane, new); pane.cur_pinned = true; return; } if (hit(key, config.kill_to_line_start)) { const new = modal.deleteSpan(p.gpa, text, .{ .row = c.row, .col = 0 }, c) catch return; p.setEditText(pane, new); pane.cur_col = 0; pane.cur_pinned = true; pane.ensureCursorVisible(); return; } if (hit(key, config.kill_to_line_end)) { const llen = modal.lineSlice(text, c.row).len; const new = modal.deleteSpan(p.gpa, text, c, .{ .row = c.row, .col = llen }) catch return; p.setEditText(pane, new); pane.cur_pinned = true; return; } switch (key.cp) { Key.enter => { const line = modal.lineSlice(text, c.row); const indent = modal.newlineIndentWidth(line, c.col); const arena = p.scratch.allocator(); const block = arena.alloc(u8, 1 + indent) catch return; block[0] = '\n'; @memset(block[1..], ' '); const new = modal.insertAt(p.gpa, text, c, block) catch return; p.setEditText(pane, new); pane.cur_row += 1; pane.cur_col = @intCast(indent); pane.cur_pinned = true; pane.ensureCursorVisible(); }, Key.backspace => { if (pane.cur_col > 0) { const line = modal.lineSlice(text, c.row); const prev = modal.prevGrapheme(line, c.col); const new = modal.deleteChar(p.gpa, text, .{ .row = c.row, .col = prev }) catch return; p.setEditText(pane, new); pane.cur_col = @intCast(prev); pane.cur_pinned = true; pane.ensureCursorVisible(); return; } // at col 0: eat the newline above (join up) — a terminal grows // its buffer over the row it is joining with if (pane.cur_row <= 0) return; const e2 = p.editText(pane, pane.cur_row - 1, pane.cur_row, 0) orelse return; const row: usize = @intCast(@max(0, pane.cur_row - e2.row0)); if (row == 0) return; const prevlen = modal.lineSlice(e2.text, row - 1).len; const new = modal.deleteSpan(p.gpa, e2.text, .{ .row = row - 1, .col = prevlen }, .{ .row = row, .col = 0 }) catch return; p.setEditText(pane, new); pane.cur_row -= 1; pane.cur_col = @intCast(prevlen); pane.cur_pinned = true; pane.ensureCursorVisible(); }, Key.delete => { // delete forward; at line end eat the newline (join), which on // a terminal means the buffer swallowing the row below const line = modal.lineSlice(text, c.row); if (c.col < line.len) { const new = modal.deleteChar(p.gpa, text, c) catch return; p.setEditText(pane, new); } else { const e2 = p.editText(pane, pane.cur_row, pane.cur_row + 1, pane.cur_col) orelse return; const row: usize = @intCast(@max(0, pane.cur_row - e2.row0)); if (row + 1 >= modal.lineCount(e2.text)) return; const new = modal.deleteSpan(p.gpa, e2.text, .{ .row = row, .col = c.col }, .{ .row = row + 1, .col = 0 }) catch return; p.setEditText(pane, new); } pane.cur_pinned = true; }, Key.tab => { const ln = modal.lineSlice(text, c.row); if (!p.multi_on and c.col > 0 and c.col <= ln.len and ln[c.col - 1] == '.') dot: { const f = pane.file orelse break :dot; if (f.output != null or !lsp.speaks(f.path)) break :dot; const seq = p.lsp_seq; p.lspRequest(p.active, .completion, ""); if (p.lsp_seq != seq) return; } p.insertTab(pane); }, else => {}, } } fn insertTab(p: *Pardes, pane: *Pane) void { const eb = p.editText(pane, pane.cur_row, pane.cur_row, pane.cur_col) orelse return; const c: modal.Cursor = .{ .row = @intCast(@max(0, pane.cur_row - eb.row0)), .col = @intCast(@max(0, pane.cur_col)), }; const pad = modal.INDENT_W - (c.col % modal.INDENT_W); const new = modal.insertAt(p.gpa, eb.text, c, " "[0..pad]) catch return; p.setEditText(pane, new); pane.cur_col += @intCast(pad); pane.cur_pinned = true; pane.ensureCursorVisible(); } const Bounds = struct { lo_row: i32, lo_col: i32, hi_row: i32, hi_col: i32 }; /// a range's two cells, normalized to document order fn cellBounds(s: Pane.SelRange) Bounds { if (s.row < s.arow or (s.row == s.arow and s.col < s.acol)) return .{ .lo_row = s.row, .lo_col = s.col, .hi_row = s.arow, .hi_col = s.acol }; return .{ .lo_row = s.arow, .lo_col = s.acol, .hi_row = s.row, .hi_col = s.col }; } /// the char selection [anchor, cursor] normalized to document order fn vselBounds(pane: *Pane) Bounds { return cellBounds(.{ .row = pane.cur_row, .col = pane.cur_col, .arow = pane.vsel.row, .acol = pane.vsel.col }); } fn vselText(p: *Pardes, pane: *Pane) []const u8 { const arena = p.scratch.allocator(); const b = vselBounds(pane); const text = p.flatSurface(pane) catch return ""; return modal.rangeText(arena, text, .{ .row = @intCast(@max(0, b.lo_row)), .col = @intCast(@max(0, b.lo_col)) }, .{ .row = @intCast(@max(0, b.hi_row)), .col = @intCast(@max(0, b.hi_col)) }) catch ""; } /// join surface rows [r0, r1] (absolute) with '\n'; scratch-owned fn yankRows(p: *Pardes, pane: *Pane, r0: i32, r1: i32) []const u8 { const arena = p.scratch.allocator(); const lines = p.paneCursorLines(pane) catch return ""; const rows_count: usize = @intCast(@max(0, r1 - r0 + 1)); var total: usize = rows_count -| 1; var i = r0; while (i <= r1) : (i += 1) { if (i >= 0 and @as(usize, @intCast(i)) < lines.len) total += lines[@intCast(i)].len; } const out = arena.alloc(u8, total) catch return ""; var at: usize = 0; i = r0; while (i <= r1) : (i += 1) { if (i > r0) { out[at] = '\n'; at += 1; } if (i >= 0 and @as(usize, @intCast(i)) < lines.len) { const line = lines[@intCast(i)]; @memcpy(out[at..][0..line.len], line); at += line.len; } } return out; } // ---- edit operations (d / c / y / p) ---- fn normalDelete(p: *Pardes, pane: *Pane, yank: bool) void { pane.select = false; // helix delete exits select mode pane.sticky_col = -1; if (pane.vsel.active) { const b = vselBounds(pane); if (yank) p.setYank(p.vselText(pane)); // read the range before the move p.pushUndo(pane); const eb = p.editTextEol(pane, b) orelse { pane.vsel.active = false; return; }; const d = modal.deleteRange(p.gpa, eb.text, .{ .row = @intCast(@max(0, b.lo_row - eb.row0)), .col = @intCast(@max(0, b.lo_col)) }, .{ .row = @intCast(@max(0, b.hi_row - eb.row0)), .col = @intCast(@max(0, b.hi_col)) }) catch { pane.vsel.active = false; return; }; p.setEditText(pane, d.content); p.gpa.free(d.deleted); pane.cur_row = b.lo_row; pane.cur_col = b.lo_col; pane.clampCursor(d.content, eb.row0); return; } if (pane.msel.active) { const r0 = @min(pane.msel.r0, pane.msel.r1); const r1 = @max(pane.msel.r0, pane.msel.r1); p.pushUndo(pane); // linewise: a line's newline lives on the row below it const eb = p.editText(pane, r0, r1 + 1, -1) orelse { pane.msel.active = false; return; }; const d = modal.deleteLines(p.gpa, eb.text, @intCast(@max(0, r0 - eb.row0)), @intCast(@max(0, r1 - eb.row0))) catch { pane.msel.active = false; return; }; p.setEditText(pane, d.content); if (yank) p.setYank(d.deleted); p.gpa.free(d.deleted); const n = modal.lineCount(d.content); pane.cur_row = @as(i32, @intCast(@min(@as(usize, @intCast(@max(0, r0 - eb.row0))), if (n == 0) 0 else n - 1))) + eb.row0; pane.cur_col = 0; pane.msel.active = false; pane.cur_pinned = true; pane.ensureCursorVisible(); } else { p.pushUndo(pane); const eb = p.editTextEol(pane, .{ .lo_row = pane.cur_row, .lo_col = pane.cur_col, .hi_row = pane.cur_row, .hi_col = pane.cur_col }) orelse return; const c = modal.Cursor{ .row = @intCast(@max(0, pane.cur_row - eb.row0)), .col = @intCast(@max(0, pane.cur_col)) }; if (yank) { const arena = p.scratch.allocator(); p.setYank(modal.rangeText(arena, eb.text, c, c) catch return); } // deleteRange (not deleteChar): the cursor may sit ON a '\n' // cell, where a delete joins the lines (helix 1-wide selection) const d = modal.deleteRange(p.gpa, eb.text, c, c) catch return; p.setEditText(pane, d.content); p.gpa.free(d.deleted); const llen = modal.lineSlice(d.content, c.row).len; pane.cur_col = @min(pane.cur_col, @as(i32, @intCast(llen))); } } fn normalYank(p: *Pardes, pane: *Pane) void { pane.select = false; if (pane.rawPointerText(pane.sel[sel_slot])) |text| { p.setYank(text); return; } if (pane.vsel.active) { p.setYank(p.vselText(pane)); return; } if (pane.msel.active) { p.setYank(p.yankRows(pane, @min(pane.msel.r0, pane.msel.r1), @max(pane.msel.r0, pane.msel.r1))); } else { const save = pane.vsel; pane.vsel = .{ .active = true, .row = pane.cur_row, .col = pane.cur_col, .explicit = false }; p.setYank(p.vselText(pane)); pane.vsel = save; } } fn pasteText(p: *Pardes, pane: *Pane, y0: []const u8, before: bool, count: usize) void { if (y0.len == 0) return; p.pushUndo(pane); pane.select = false; pane.sticky_col = -1; const arena = p.scratch.allocator(); var y: []const u8 = y0; if (count > 1) { const total = std.math.mul(usize, count, y0.len) catch return; const buf = arena.alloc(u8, total) catch return; for (0..count) |i| @memcpy(buf[i * y0.len ..][0..y0.len], y0); y = buf; } const b: Bounds = if (pane.vsel.active) vselBounds(pane) else .{ .lo_row = pane.cur_row, .lo_col = pane.cur_col, .hi_row = pane.cur_row, .hi_col = pane.cur_col }; const eb = p.editText(pane, b.lo_row, b.hi_row, -1) orelse return; const row0 = eb.row0; if (y[y.len - 1] == '\n') { const block_text = y[0 .. y.len - 1]; const n = @max(1, modal.lineCount(block_text)); var out: []u8 = undefined; if (before) { const row: usize = @intCast(@max(0, b.lo_row - row0)); const block = std.fmt.allocPrint(arena, "{s}\n", .{block_text}) catch return; out = modal.insertAt(p.gpa, eb.text, .{ .row = row, .col = 0 }, block) catch return; pane.cur_row = @as(i32, @intCast(row + n - 1)) + row0; } else { const row: usize = @intCast(@max(0, b.hi_row - row0)); out = modal.pasteLineBelow(p.gpa, eb.text, row, block_text) catch return; pane.cur_row = @as(i32, @intCast(row + n)) + row0; } p.setEditText(pane, out); pane.vsel = .{ .active = true, .row = pane.cur_row - @as(i32, @intCast(n - 1)), .col = 0, .explicit = false }; // cursor ON the last pasted line's '\n' (helix) const llen = modal.lineSlice(out, @intCast(@max(0, pane.cur_row - row0))).len; pane.cur_col = @intCast(llen); } else { const at: modal.Cursor = if (before) .{ .row = @intCast(@max(0, b.lo_row - row0)), .col = @intCast(@max(0, b.lo_col)) } else blk: { const hrow: usize = @intCast(@max(0, b.hi_row - row0)); const hi_col: usize = @intCast(@max(0, b.hi_col)); const high_line = modal.lineSlice(eb.text, hrow); if (hi_col >= high_line.len) break :blk .{ .row = hrow + 1, .col = 0 }; const gcol = modal.nextGrapheme(high_line, hi_col); break :blk .{ .row = hrow, .col = gcol }; }; const out = modal.insertAt(p.gpa, eb.text, at, y) catch return; p.setEditText(pane, out); pane.vsel = .{ .active = modal.nextGrapheme(y, 0) < y.len, .row = @as(i32, @intCast(at.row)) + row0, .col = @intCast(at.col), .explicit = false }; const end = modal.advanceBy(at, y); if (end.col > 0) { pane.cur_row = @as(i32, @intCast(end.row)) + row0; pane.cur_col = @intCast(modal.prevGrapheme(modal.lineSlice(out, end.row), end.col)); } else { pane.cur_row = @as(i32, @intCast(end.row -| 1)) + row0; pane.cur_col = @intCast(modal.lineSlice(out, end.row -| 1).len); } } pane.msel.active = false; pane.cur_pinned = true; pane.ensureCursorVisible(); } fn normalChange(p: *Pardes, pane: *Pane) void { pane.select = false; pane.sticky_col = -1; if (pane.vsel.active) { const b = vselBounds(pane); const eb = p.editTextEol(pane, b) orelse return; const hi_len = modal.lineSlice(eb.text, @intCast(@max(0, b.hi_row - eb.row0))).len; if (b.lo_col == 0 and @as(usize, @intCast(@max(0, b.hi_col))) >= hi_len) { p.setYank(p.vselText(pane)); p.pushUndo(pane); const d = modal.deleteLines(p.gpa, eb.text, @intCast(@max(0, b.lo_row - eb.row0)), @intCast(@max(0, b.hi_row - eb.row0))) catch return; p.setEditText(pane, d.content); p.gpa.free(d.deleted); const n = modal.lineCount(d.content); const row: usize = @min(@as(usize, @intCast(@max(0, b.lo_row - eb.row0))), if (n == 0) 0 else n - 1); const ind = modal.indentText(modal.lineSlice(d.content, row)); const arena = p.scratch.allocator(); const block = std.fmt.allocPrint(arena, "{s}\n", .{ind}) catch return; const new = modal.insertAt(p.gpa, d.content, .{ .row = row, .col = 0 }, block) catch return; p.setEditText(pane, new); pane.cur_row = @as(i32, @intCast(row)) + eb.row0; pane.cur_col = @intCast(ind.len); pane.vsel.active = false; pane.msel.active = false; pane.cur_pinned = true; pane.mode = .insert; pane.normal.clear(); pane.ensureCursorVisible(); return; } p.setYank(p.vselText(pane)); p.pushUndo(pane); const d = modal.deleteRange(p.gpa, eb.text, .{ .row = @intCast(@max(0, b.lo_row - eb.row0)), .col = @intCast(@max(0, b.lo_col)) }, .{ .row = @intCast(@max(0, b.hi_row - eb.row0)), .col = @intCast(@max(0, b.hi_col)) }) catch { pane.vsel.active = false; return; }; p.setEditText(pane, d.content); p.gpa.free(d.deleted); pane.cur_row = b.lo_row; pane.cur_col = b.lo_col; pane.clampCursor(d.content, eb.row0); pane.mode = .insert; pane.normal.clear(); return; } if (pane.msel.active) { const r0 = @min(pane.msel.r0, pane.msel.r1); const r1 = @max(pane.msel.r0, pane.msel.r1); p.pushUndo(pane); const eb = p.editText(pane, r0, r1 + 1, -1) orelse return; const arena = p.scratch.allocator(); const lo: usize = @intCast(@max(0, r0 - eb.row0)); const hi: usize = @intCast(@max(0, r1 - eb.row0)); var total: usize = hi - lo; for (lo..hi + 1) |r| total += modal.lineSlice(eb.text, r).len; const buf = arena.alloc(u8, total) catch return; var at: usize = 0; for (lo..hi + 1) |r| { if (r > lo) { buf[at] = '\n'; at += 1; } const line = modal.lineSlice(eb.text, r); @memcpy(buf[at..][0..line.len], line); at += line.len; } p.setYank(buf); var body: []u8 = eb.text; if (hi > lo) { const d = modal.deleteLines(p.gpa, body, lo + 1, hi) catch { pane.msel.active = false; return; }; p.setEditText(pane, d.content); p.gpa.free(d.deleted); body = d.content; } const cl = modal.clearLine(p.gpa, body, lo) catch { pane.msel.active = false; return; }; p.setEditText(pane, cl); pane.cur_row = r0; pane.cur_col = 0; pane.msel.active = false; pane.cur_pinned = true; pane.mode = .insert; pane.normal.clear(); pane.ensureCursorVisible(); } else { p.pushUndo(pane); const eb = p.editTextEol(pane, .{ .lo_row = pane.cur_row, .lo_col = pane.cur_col, .hi_row = pane.cur_row, .hi_col = pane.cur_col }) orelse return; const c = modal.Cursor{ .row = @intCast(@max(0, pane.cur_row - eb.row0)), .col = @intCast(@max(0, pane.cur_col)) }; const arena = p.scratch.allocator(); p.setYank(modal.rangeText(arena, eb.text, c, c) catch return); const d = modal.deleteRange(p.gpa, eb.text, c, c) catch return; p.setEditText(pane, d.content); p.gpa.free(d.deleted); const llen = modal.lineSlice(d.content, c.row).len; pane.cur_col = @min(pane.cur_col, @as(i32, @intCast(llen))); pane.mode = .insert; pane.normal.clear(); } } fn selRange(pane: *Pane, content: []const u8, row0: i32) modal.Range { if (pane.vsel.active) { const b = vselBounds(pane); return .{ .a = .{ .row = @intCast(@max(0, b.lo_row - row0)), .col = @intCast(@max(0, b.lo_col)) }, .b = .{ .row = @intCast(@max(0, b.hi_row - row0)), .col = @intCast(@max(0, b.hi_col)) }, }; } if (pane.msel.active) { const r0: usize = @intCast(@max(0, @min(pane.msel.r0, pane.msel.r1) - row0)); const r1: usize = @intCast(@max(0, @max(pane.msel.r0, pane.msel.r1) - row0)); const llen = modal.lineSlice(content, r1).len; return .{ .a = .{ .row = r0, .col = 0 }, .b = .{ .row = r1, .col = if (llen == 0) 0 else modal.prevGrapheme(modal.lineSlice(content, r1), llen) } }; } const c = modal.Cursor{ .row = @intCast(@max(0, pane.cur_row - row0)), .col = @intCast(@max(0, pane.cur_col)) }; return .{ .a = c, .b = c }; } /// the rows an op's selection spans, absolute (what editText must cover) fn selRows(pane: *Pane) Bounds { if (pane.vsel.active) return vselBounds(pane); if (pane.msel.active) return .{ .lo_row = @min(pane.msel.r0, pane.msel.r1), .lo_col = 0, .hi_row = @max(pane.msel.r0, pane.msel.r1), .hi_col = 0, }; return .{ .lo_row = pane.cur_row, .lo_col = pane.cur_col, .hi_row = pane.cur_row, .hi_col = pane.cur_col }; } /// `r`: overwrite the selection (or the cursor char) with ch — /// newlines included (helix), so `xrz` joins the selected lines fn normalReplaceChar(p: *Pardes, pane: *Pane, ch: u21) void { pane.select = false; const eb = p.editTextEol(pane, selRows(pane)) orelse return; const before = pane.primaryRange(eb.text, eb.row0); const lo = @min(before.anchor, before.head); const hi = @max(before.anchor, before.head); var graphemes: usize = 0; var at = lo; while (at < hi) : (graphemes += 1) at = modal.nextGrapheme(eb.text, at); var encoded: [4]u8 = undefined; const encoded_len = std.unicode.utf8Encode(ch, &encoded) catch return; const r = selRange(pane, eb.text, eb.row0); p.pushUndo(pane); const new = modal.replaceChars(p.gpa, eb.text, r.a, r.b, ch) catch return; p.setEditText(pane, new); const end = lo + graphemes * encoded_len; const mapped: modal.Selection = if (before.head < before.anchor) .{ .anchor = end, .head = lo } else .{ .anchor = lo, .head = end }; pane.setRange(new, eb.row0, mapped, pane.vsel.explicit); } /// `R`: replace the selection (or the cursor char) with the DEFAULT /// register. `SPC R` is the same verb over the system clipboard. fn normalReplaceYank(p: *Pardes, pane: *Pane) void { p.replaceWithText(pane, p.yank orelse return); } /// The pasted text becomes the selection, head on its last char (which for /// a trailing-newline `y` is the '\n' cell of the last full line). fn replaceWithText(p: *Pardes, pane: *Pane, y: []const u8) void { if (y.len == 0) return; pane.select = false; const eb = p.editTextEol(pane, selRows(pane)) orelse return; const r = selRange(pane, eb.text, eb.row0); p.pushUndo(pane); const new = modal.replaceRange(p.gpa, eb.text, r.a, r.b, y) catch return; p.setEditText(pane, new); pane.msel.active = false; pane.vsel = .{ .active = modal.nextGrapheme(y, 0) < y.len, .row = @as(i32, @intCast(r.a.row)) + eb.row0, .col = @intCast(r.a.col), .explicit = false }; const end = modal.advanceBy(r.a, y); if (end.col > 0) { pane.cur_row = @as(i32, @intCast(end.row)) + eb.row0; pane.cur_col = @intCast(modal.prevGrapheme(modal.lineSlice(new, end.row), end.col)); } else { // the yank ended in '\n': the cursor lands ON that newline pane.cur_row = @as(i32, @intCast(end.row -| 1)) + eb.row0; pane.cur_col = @intCast(modal.lineSlice(new, end.row -| 1).len); } pane.cur_pinned = true; pane.sticky_col = -1; pane.ensureCursorVisible(); } /// `~` / `` ` `` / ``Alt-` ``: case-map the selection (or the cursor char), /// keeping the selection (helix) fn normalCase(p: *Pardes, pane: *Pane, op: modal.CaseOp) void { pane.select = false; const eb = p.editTextEol(pane, selRows(pane)) orelse return; const r = selRange(pane, eb.text, eb.row0); p.pushUndo(pane); const new = modal.changeCase(p.gpa, eb.text, r.a, r.b, op) catch return; p.setEditText(pane, new); } /// a change to apply to file content: delete [from, to), insert `ins` at /// `from`. Ascending and disjoint. const TextChange = struct { from: usize, to: usize, ins: []const u8 }; fn mapThroughChanges(chs: []const TextChange, pos: usize) usize { var delta: i64 = 0; for (chs) |ch| { if (pos < ch.from) break; if (ch.from == ch.to) { delta += @intCast(ch.ins.len); continue; } if (pos == ch.from) break; if (pos < ch.to) return @intCast(@as(i64, @intCast(ch.from)) + delta); delta += @as(i64, @intCast(ch.ins.len)) - @as(i64, @intCast(ch.to - ch.from)); } return @intCast(@as(i64, @intCast(pos)) + delta); } /// apply a change list. gpa-owned result. fn applyChanges(p: *Pardes, text: []const u8, chs: []const TextChange) ![]u8 { var total = text.len; for (chs) |ch| total = total - (ch.to - ch.from) + ch.ins.len; const out = try p.gpa.alloc(u8, total); errdefer p.gpa.free(out); var copied: usize = 0; var at: usize = 0; for (chs) |ch| { const unchanged = text[copied..ch.from]; @memcpy(out[at..][0..unchanged.len], unchanged); at += unchanged.len; @memcpy(out[at..][0..ch.ins.len], ch.ins); at += ch.ins.len; copied = ch.to; } @memcpy(out[at..], text[copied..]); return out; } fn normalJoin(p: *Pardes, pane: *Pane) void { // a join always eats the newline of its last line, so the buffer has // to reach one row PAST the selection const sr = selRows(pane); const eb = p.editText(pane, sr.lo_row, sr.hi_row + 1, -1) orelse return; const text = eb.text; const range = pane.primaryRange(text, eb.row0); const span = Pane.rangeLineSpan(text, range); const nlines = modal.cursorLineCount(text); var end = span.end; if (span.start == end) end = @min(end + 1, nlines - 1); if (end <= span.start) return; const arena = p.scratch.allocator(); const chs = arena.alloc(TextChange, end - span.start) catch return; var chs_len: usize = 0; var l = span.start; while (l < end) : (l += 1) { const from = modal.lineEndOffset(text, l); var to = if (l + 1 >= nlines) text.len else modal.lineStartOffset(text, l + 1); while (to < text.len and (text[to] == ' ' or text[to] == '\t')) to += 1; const sep: []const u8 = if (to == modal.lineEndOffset(text, @min(l + 1, nlines - 1))) "" else " "; chs[chs_len] = .{ .from = from, .to = to, .ins = sep }; chs_len += 1; } if (chs_len == 0) return; p.pushUndo(pane); const cur_off = modal.offsetAt(text, .{ .row = @intCast(@max(0, pane.cur_row - eb.row0)), .col = @intCast(@max(0, pane.cur_col)) }); const anc_off = if (pane.vsel.active) modal.offsetAt(text, .{ .row = @intCast(@max(0, pane.vsel.row - eb.row0)), .col = @intCast(@max(0, pane.vsel.col)) }) else cur_off; const new = p.applyChanges(text, chs[0..chs_len]) catch return; const nc = mapThroughChanges(chs[0..chs_len], cur_off); const na = mapThroughChanges(chs[0..chs_len], anc_off); p.setEditText(pane, new); const cc = modal.positionAt(new, nc); const ac = modal.positionAt(new, na); pane.cur_row = @as(i32, @intCast(cc.row)) + eb.row0; pane.cur_col = @intCast(cc.col); if (pane.vsel.active) { pane.vsel.row = @as(i32, @intCast(ac.row)) + eb.row0; pane.vsel.col = @intCast(ac.col); } pane.msel.active = false; pane.cur_pinned = true; pane.sticky_col = -1; pane.ensureCursorVisible(); } fn normalIndent(p: *Pardes, pane: *Pane, cnt: usize, add: bool) void { pane.select = false; const sr = selRows(pane); const eb = p.editText(pane, sr.lo_row, sr.hi_row, -1) orelse return; const text = eb.text; const range = pane.primaryRange(text, eb.row0); const span = Pane.rangeLineSpan(text, range); const arena = p.scratch.allocator(); // one run of spaces, sliced per line: `>` never inserts more than this const pad = arena.alloc(u8, modal.INDENT_W * cnt) catch return; @memset(pad, ' '); const chs = arena.alloc(TextChange, span.end - span.start + 1) catch return; var chs_len: usize = 0; var l = span.start; while (l <= span.end) : (l += 1) { const ls = modal.lineStartOffset(text, l); const le = modal.lineEndOffset(text, l); const line = text[ls..le]; const nw = modal.firstNonWs(line); if (nw == line.len) continue; // blank lines stay blank (helix) if (add) { const ins = modal.INDENT_W * cnt - (nw % modal.INDENT_W); chs[chs_len] = .{ .from = ls, .to = ls, .ins = pad[0..ins] }; chs_len += 1; } else { const want = modal.INDENT_W * cnt; var w: usize = 0; var pos: usize = 0; while (pos < line.len) { if (line[pos] == ' ') { w += 1; } else if (line[pos] == '\t') { w = (w / modal.INDENT_W + 1) * modal.INDENT_W; } else break; pos += 1; if (w >= want) break; } if (pos > 0) { chs[chs_len] = .{ .from = ls, .to = ls + pos, .ins = "" }; chs_len += 1; } } } if (chs_len == 0) return; p.pushUndo(pane); const cur_off = modal.offsetAt(text, .{ .row = @intCast(@max(0, pane.cur_row - eb.row0)), .col = @intCast(@max(0, pane.cur_col)) }); const anc_off = if (pane.vsel.active) modal.offsetAt(text, .{ .row = @intCast(@max(0, pane.vsel.row - eb.row0)), .col = @intCast(@max(0, pane.vsel.col)) }) else cur_off; const new = p.applyChanges(text, chs[0..chs_len]) catch return; const nc = mapThroughChanges(chs[0..chs_len], cur_off); const na = mapThroughChanges(chs[0..chs_len], anc_off); p.setEditText(pane, new); const cc = modal.positionAt(new, nc); const ac = modal.positionAt(new, na); pane.cur_row = @as(i32, @intCast(cc.row)) + eb.row0; pane.cur_col = @intCast(cc.col); if (pane.vsel.active) { pane.vsel.row = @as(i32, @intCast(ac.row)) + eb.row0; pane.vsel.col = @intCast(ac.col); } pane.msel.active = false; pane.cur_pinned = true; pane.sticky_col = -1; pane.ensureCursorVisible(); } fn normalToggleComment(p: *Pardes, pane: *Pane) void { const expl = (pane.vsel.active and pane.vsel.explicit) or pane.msel.active; pane.select = false; // helix exit_select_mode // the buffer must cover every cursor's rows, not just the primary's var b = selRows(pane); for (pane.sels[0..pane.nsel]) |s| { b.lo_row = @min(b.lo_row, @min(s.row, s.arow)); b.hi_row = @max(b.hi_row, @max(s.row, s.arow)); } const surface = p.flatSurface(pane) catch return; const last_row = @as(i32, @intCast(panes.File.textLineCount(pane, surface))) - 1; const eb = p.editText(pane, b.lo_row, @min(b.hi_row + 1, last_row), -1) orelse return; const text = eb.text; var rs: [Pane.max_selections]modal.Selection = undefined; const got = pane.ranges(text, eb.row0, &rs); const nlines = modal.cursorLineCount(text); const arena = p.scratch.allocator(); // the lines the ranges cover, each ONE ONCE and in order (helix's // min_next_line: two cursors on one line comment it once) const lines = arena.alloc(usize, nlines) catch return; var lines_len: usize = 0; var next: usize = 0; for (rs[0..got.n]) |r| { const span = Pane.rangeLineSpan(text, r); var l = @max(span.start, next); const end = @min(span.end + 1, nlines); while (l < end) : (l += 1) { lines[lines_len] = l; lines_len += 1; } next = @max(next, end); } const ext = if (pane.file) |f| std.fs.path.extension(f.path) else ""; var token: []const u8 = config.comment_token_default; lang: for (config.comment_tokens) |row| { for (row.exts) |e| if (std.ascii.eqlIgnoreCase(ext, e)) { token = row.token; break :lang; }; } var commented = true; var indent: usize = std.math.maxInt(usize); var margin: usize = 1; var live: usize = 0; for (lines[0..lines_len]) |l| { const line = text[modal.lineStartOffset(text, l)..modal.lineEndOffset(text, l)]; const nw = modal.firstNonWs(line); if (nw == line.len) continue; indent = @min(indent, nw); if (!std.mem.startsWith(u8, line[nw..], token)) commented = false; if (nw + token.len >= line.len or line[nw + token.len] != ' ') margin = 0; live += 1; } if (live == 0) return; // nothing but blank lines const ins = std.fmt.allocPrint(arena, "{s} ", .{token}) catch return; const chs = arena.alloc(TextChange, lines_len) catch return; var chs_len: usize = 0; for (lines[0..lines_len]) |l| { const ls = modal.lineStartOffset(text, l); const le = modal.lineEndOffset(text, l); const line = text[ls..le]; if (modal.firstNonWs(line) == line.len) continue; // blank lines untouched const at = ls + indent; // the @min can never bind: margin is 1 only when every line has a // space after its own token, which is a byte past `at + token.len` chs[chs_len] = if (commented) .{ .from = at, .to = @min(at + token.len + margin, le), .ins = "" } else .{ .from = at, .to = at, .ins = ins }; chs_len += 1; } p.pushUndo(pane); // one edit, and the WHOLE selection rides through it (helix maps the // selection with the transaction) var cells: [Pane.max_selections]Pane.SelRange = undefined; const new = p.applyChanges(text, chs[0..chs_len]) catch return; for (rs[0..got.n], 0..) |r, i| { const c = Pane.rangeCells(new, .{ .anchor = mapThroughChanges(chs[0..chs_len], r.anchor), .head = mapThroughChanges(chs[0..chs_len], r.head), }); const cc = modal.positionAt(new, c.cur); const ac = modal.positionAt(new, c.anc); cells[i] = .{ .row = @as(i32, @intCast(cc.row)) + eb.row0, .col = @intCast(cc.col), .arow = @as(i32, @intCast(ac.row)) + eb.row0, .acol = @intCast(ac.col), }; } p.setEditText(pane, new); const t2 = p.flatSurface(pane) catch return; for (cells[0..got.n], 0..) |s, i| rs[i] = Pane.cellRange(t2, s.arow, s.acol, s.row, s.col); pane.setRanges(t2, rs[0..got.n], &.{}, got.pri, expl); } fn normalAdjustNumber(p: *Pardes, pane: *Pane, delta: i64) void { const eb = p.editTextEol(pane, selRows(pane)) orelse return; const r = selRange(pane, eb.text, eb.row0); const arena = p.scratch.allocator(); const frag = modal.rangeText(arena, eb.text, r.a, r.b) catch return; const rep = (modal.incrementDecimal(arena, frag, delta) catch null) orelse return; p.pushUndo(pane); pane.select = false; const new = modal.replaceRange(p.gpa, eb.text, r.a, r.b, rep) catch return; p.setEditText(pane, new); const start = modal.lineStartOffset(new, r.a.row) + r.a.col; const cc = modal.positionAt(new, modal.prevGrapheme(new, start + rep.len)); pane.vsel = .{ .active = modal.nextGrapheme(rep, 0) < rep.len, .row = @as(i32, @intCast(r.a.row)) + eb.row0, .col = @intCast(r.a.col), .explicit = false }; pane.msel.active = false; pane.cur_row = @as(i32, @intCast(cc.row)) + eb.row0; pane.cur_col = @intCast(cc.col); pane.cur_pinned = true; pane.sticky_col = -1; pane.ensureCursorVisible(); } /// `]Space` / `[Space`: add blank lines below/above, the cursor staying /// on its text line (the selection rides along) fn addNewline(p: *Pardes, pane: *Pane, below: bool, cnt: usize) void { const eb = p.editText(pane, pane.cur_row, pane.cur_row, -1) orelse return; p.pushUndo(pane); const row: usize = @intCast(@max(0, pane.cur_row - eb.row0)); const arena = p.scratch.allocator(); const nl = arena.alloc(u8, cnt) catch return; @memset(nl, '\n'); const llen = modal.lineSlice(eb.text, row).len; const new = modal.insertAt(p.gpa, eb.text, .{ .row = row, .col = if (below) llen else 0 }, nl) catch return; p.setEditText(pane, new); if (!below) { pane.cur_row += @intCast(cnt); // the line moved down; stay on it if (pane.vsel.active and pane.vsel.row >= @as(i32, @intCast(row))) pane.vsel.row += @intCast(cnt); } else if (pane.vsel.active and pane.vsel.row > @as(i32, @intCast(row))) { pane.vsel.row += @intCast(cnt); } pane.cur_pinned = true; pane.ensureCursorVisible(); } /// pair mapping for m-mode: either bracket names the pair; anything else /// surrounds with itself (quotes) fn pairFor(ch: u21) ?struct { o: u8, c: u8 } { return switch (ch) { '(', ')' => .{ .o = '(', .c = ')' }, '[', ']' => .{ .o = '[', .c = ']' }, '{', '}' => .{ .o = '{', .c = '}' }, '<', '>' => .{ .o = '<', .c = '>' }, else => if (ch <= 0x7f) .{ .o = @intCast(ch), .c = @intCast(ch) } else null, }; } fn textobjectSelect(p: *Pardes, pane: *Pane, text: []const u8, obj: u21, around: bool) void { const range = pane.primaryRange(text, 0); switch (obj) { 'w', 'W' => { const r = modal.selectWord(text, range, around, obj == 'W'); return pane.setRange(text, 0, r, false); }, 'p' => { const r = modal.selectParagraph(text, range, around, 1); return pane.setRange(text, 0, r, false); }, else => {}, } const lines = p.paneCursorLines(pane) catch return; const cur = modal.positionAt(text, modal.selectionCursor(text, range)); const pair: ?modal.Range = switch (obj) { '\'', '"', '`' => modal.enclosingQuote(lines, cur, @intCast(obj)), '(', ')' => modal.enclosingPair(lines, cur, '(', ')'), '[', ']' => modal.enclosingPair(lines, cur, '[', ']'), '{', '}' => modal.enclosingPair(lines, cur, '{', '}'), '<', '>' => modal.enclosingPair(lines, cur, '<', '>'), else => null, }; const pr = pair orelse return; var a = modal.offsetAt(text, pr.a); var head = modal.nextGrapheme(text, modal.offsetAt(text, pr.b)); if (!around) { // inside: shrink off the delimiters; an EMPTY pair collapses to // a 1-wide cursor on the closing char (helix) a = modal.nextGrapheme(text, a); head = modal.prevGrapheme(text, head); } pane.setRange(text, 0, .{ .anchor = a, .head = head }, false); } /// `ms`: wrap the selection (or the cursor char) in a pair; the wrap /// including the pair becomes the selection fn surroundAdd(p: *Pardes, pane: *Pane, ch: u21) void { const pr = pairFor(ch) orelse return; pane.select = false; const eb = p.editTextEol(pane, selRows(pane)) orelse return; const r = selRange(pane, eb.text, eb.row0); p.pushUndo(pane); const close_col = modal.nextGrapheme(modal.lineSlice(eb.text, r.b.row), r.b.col); var new = modal.insertAt(p.gpa, eb.text, .{ .row = r.b.row, .col = close_col }, &[1]u8{pr.c}) catch return; p.setEditText(pane, new); new = modal.insertAt(p.gpa, new, .{ .row = r.a.row, .col = r.a.col }, &[1]u8{pr.o}) catch return; p.setEditText(pane, new); pane.msel.active = false; pane.vsel = .{ .active = true, .row = @as(i32, @intCast(r.a.row)) + eb.row0, .col = @intCast(r.a.col), .explicit = false }; pane.cur_row = @as(i32, @intCast(r.b.row)) + eb.row0; pane.cur_col = @intCast(close_col + @as(usize, if (r.a.row == r.b.row) 1 else 0)); pane.cur_pinned = true; pane.sticky_col = -1; pane.ensureCursorVisible(); } /// `md`: delete the enclosing pair's chars; the cursor maps through fn surroundDelete(p: *Pardes, pane: *Pane, ch: u21) void { const pr = pairFor(ch) orelse return; const lines = p.paneCursorLines(pane) catch return; const cur = pane.toModalCursor(); const r = (if (pr.o == pr.c) modal.enclosingQuote(lines, cur, pr.o) else modal.enclosingPair(lines, cur, pr.o, pr.c)) orelse return; const lo: i32 = @intCast(@min(r.a.row, cur.row)); const hi: i32 = @intCast(@max(r.b.row, cur.row)); const eb = p.editText(pane, lo, hi, -1) orelse return; const text = eb.text; const drow = -eb.row0; // surface row -> buffer row const ra: modal.Cursor = .{ .row = @intCast(@as(i32, @intCast(r.a.row)) + drow), .col = r.a.col }; const rb: modal.Cursor = .{ .row = @intCast(@as(i32, @intCast(r.b.row)) + drow), .col = r.b.col }; p.pushUndo(pane); const a_off = modal.offsetAt(text, ra); const b_off = modal.offsetAt(text, rb); const cur_off = modal.offsetAt(text, .{ .row = @intCast(@as(i32, @intCast(cur.row)) + drow), .col = cur.col }); // the close first, so the open's position stays valid var new = modal.deleteChar(p.gpa, text, rb) catch return; p.setEditText(pane, new); new = modal.deleteChar(p.gpa, new, ra) catch return; p.setEditText(pane, new); var nc = cur_off; if (nc > b_off) nc -= 1; if (nc > a_off) nc -= 1; const cc = modal.positionAt(new, nc); pane.cur_row = @as(i32, @intCast(cc.row)) + eb.row0; pane.cur_col = @intCast(cc.col); pane.vsel.active = false; pane.msel.active = false; pane.cur_pinned = true; pane.ensureCursorVisible(); } /// `mr`: swap the enclosing pair's chars for 's fn surroundReplace(p: *Pardes, pane: *Pane, from: u21, to: u21) void { const fp = pairFor(from) orelse return; const tp = pairFor(to) orelse return; const lines = p.paneCursorLines(pane) catch return; const cur = pane.toModalCursor(); const r = (if (fp.o == fp.c) modal.enclosingQuote(lines, cur, fp.o) else modal.enclosingPair(lines, cur, fp.o, fp.c)) orelse return; const eb = p.editText(pane, @as(i32, @intCast(r.a.row)), @as(i32, @intCast(r.b.row)), -1) orelse return; const drow = -eb.row0; // surface row -> buffer row const ar: usize = @intCast(@as(i32, @intCast(r.a.row)) + drow); const br: usize = @intCast(@as(i32, @intCast(r.b.row)) + drow); p.pushUndo(pane); const out = p.gpa.dupe(u8, eb.text) catch return; out[modal.lineStartOffset(out, ar) + r.a.col] = tp.o; out[modal.lineStartOffset(out, br) + r.b.col] = tp.c; p.setEditText(pane, out); } // ---- dumb undo/redo: whole-state snapshots, one per edit op ---- fn pushUndo(p: *Pardes, pane: *Pane) void { // one keystroke, one undo step — even when it edited at ten cursors. if (p.multi_on and !p.multi_first) return; if (pane.file != null) return panes.File.pushUndo(p, pane); panes.Terminal.pushUndo(p, pane); } fn doUndo(p: *Pardes, pane: *Pane) void { if (pane.file != null) return panes.File.undo(p, pane); panes.Terminal.undo(p, pane); } fn doRedo(p: *Pardes, pane: *Pane) void { if (pane.file != null) return panes.File.redo(p, pane); panes.Terminal.redo(p, pane); } pub const ChromeTarget = struct { col: u16, row: u16 }; pub fn chromeTarget(p: *const Pardes, col: u16, row: u16) ?ChromeTarget { if (row < TOPBAR_H + p.columnBarHeight()) return .{ .col = col, .row = row }; for (p.panes, 0..) |slot, id| { if (slot == null) continue; const rect = p.rects[id]; const tag = if (p.settings.tag_bottom) rect.y + rect.h -| BOX_H else rect.y; if (row == tag and col >= rect.x and col < rect.x + rect.w and col < rect.x + config.GUTTER) return .{ .col = col, .row = tag }; } for (0..p.ncol -| 1) |column| { const handle = p.col_x[column] + p.col_w[column] -| 1; if (col == handle) return .{ .col = handle, .row = row }; } for (0..p.ncol) |column| { if (col < p.col_x[column] or col >= p.col_x[column] + p.col_w[column]) continue; for (0..p.col_n[column] -| 1) |index| { const rect = p.rects[p.col_panes[column][index]]; const handle = if (p.settings.tag_bottom) rect.y +| rect.h else rect.y + rect.h -| 1; if (row == handle) return .{ .col = col, .row = handle }; } } for (p.panes, 0..) |slot, id| { if (slot == null) continue; const rect = p.rects[id]; const tag = if (p.settings.tag_bottom) rect.y + rect.h -| BOX_H else rect.y; if (row == tag and col >= rect.x and col < rect.x + rect.w) return .{ .col = col, .row = tag }; } for (0..p.ncol -| 1) |column| { const handle = p.col_x[column] + p.col_w[column] -| 1; if (@max(col, handle) - @min(col, handle) == 1) return .{ .col = handle, .row = row }; } for (0..p.ncol) |column| { if (col < p.col_x[column] or col >= p.col_x[column] + p.col_w[column]) continue; for (0..p.col_n[column] -| 1) |index| { const rect = p.rects[p.col_panes[column][index]]; const handle = if (p.settings.tag_bottom) rect.y +| rect.h else rect.y + rect.h -| 1; if (@max(row, handle) - @min(row, handle) == 1) return .{ .col = col, .row = handle }; } } return null; } fn paneAt(p: *const Pardes, col: u16, row: u16) ?usize { for (p.panes, 0..) |slot, id| { if (slot == null) continue; const r = p.rects[id]; if (col >= r.x and col < r.x + r.w and row >= r.y and row < r.y + r.h) return id; } return null; } const PointerTextSelection = struct { sel: Pane.Sel, on_tag: bool, }; fn bodyHitForPane(p: *const Pardes, id: usize, supplied: Mouse.BodyHit, clamp: bool) ?Mouse.BodyHit { const pane = p.panes[id] orelse return null; if (pane.file == null or (!clamp and supplied.serial != pane.serial)) return null; if (!std.math.isFinite(supplied.pixel_x) or !std.math.isFinite(supplied.pixel_y) or supplied.metrics.body_w == 0 or supplied.metrics.body_h == 0 or supplied.metrics.tagline_w == 0 or supplied.metrics.tagline_h == 0) return null; var mapped_hit = supplied; if (clamp) { mapped_hit.serial = pane.serial; const layer = for (&p.surface.body_layers) |*layer| { if (layer.rows > 0 and layer.serial == pane.serial) break layer; } else return null; const bw: f32 = @floatFromInt(mapped_hit.metrics.body_w); const bh: f32 = @floatFromInt(mapped_hit.metrics.body_h); const x0 = @as(f32, @floatFromInt(layer.viewport.x)) * bw; const y0 = @as(f32, @floatFromInt(layer.viewport.y)) * bh; const x1 = x0 + @as(f32, @floatFromInt(layer.viewport.w)) * bw; const y1 = y0 + @as(f32, @floatFromInt(layer.viewport.h)) * bh; mapped_hit.pixel_x = std.math.clamp(mapped_hit.pixel_x, x0, @max(x0, x1 - 0.001)); mapped_hit.pixel_y = std.math.clamp(mapped_hit.pixel_y, y0, @max(y0, y1 - 0.001)); } return p.reprojectBodyHit(mapped_hit); } fn pointerTextSelection(p: *const Pardes, id: usize, col: u16, row: u16, body_hit: ?Mouse.BodyHit, tag_hit: ?TagHit) ?PointerTextSelection { if (p.panes[id] == null) return null; const r = p.rects[id]; if (col < r.x + config.GUTTER or col >= r.x + r.w or row < r.y or row >= r.y + r.h) return null; const tag_y = if (p.settings.tag_bottom) r.y + r.h -| BOX_H else r.y; const body_y = if (p.settings.tag_bottom) r.y else r.y + BOX_H; const on_tag = row >= tag_y and row < tag_y + BOX_H; if (!on_tag) if (body_hit) |supplied| { const mapped_hit = p.bodyHitForPane(id, supplied, false) orelse return null; const c = @as(i32, mapped_hit.col) - config.GUTTER; const v = @as(i32, mapped_hit.row) + BOX_H; return .{ .sel = .{ .state = .dragging, .c0 = c, .c1 = c, .r0 = v, .r1 = v }, .on_tag = false }; }; const tag_col = if (on_tag) p.tagColumn(.pane, id, tag_hit, false) else null; const c: i32 = (if (tag_col) |value| @as(i32, value) else @as(i32, col) - @as(i32, r.x + config.GUTTER)) + (if (on_tag) @as(i32, p.panes[id].?.tag_scroll) else 0); const v: i32 = if (on_tag) 0 else @as(i32, row) - @as(i32, body_y) + @as(i32, BOX_H); return .{ .sel = .{ .state = .dragging, .c0 = c, .c1 = c, .r0 = v, .r1 = v }, .on_tag = on_tag, }; } fn cancelLookHover(p: *Pardes) void { if (comptime pdf_enabled) if (p.pdf_hover_preview) |*preview| { if (preview.pane < p.panes.len) if (p.panes[preview.pane]) |pane| { if (pane.serial == preview.serial) if (pane.pdf) |*pv| pv.invalidateRaster(preview.probe.page); }; preview.deinit(p.pdf_gpa); p.pdf_hover_preview = null; }; p.look_hover_wait = null; p.look_hover_preview = null; } fn lookHoverPane(p: *const Pardes) ?usize { if (comptime pdf_enabled) if (p.pdf_hover_preview) |preview| return preview.pane; if (p.look_hover_preview) |preview| return preview.pane; if (p.look_hover_wait) |waiting| return waiting.pane; return null; } fn sameTagCell(a: ?TagHit, b: ?TagHit) bool { const first = a orelse return b == null; const second = b orelse return false; return first.kind == second.kind and first.id == second.id and first.serial == second.serial and first.col == second.col; } fn tagColumn(p: *const Pardes, kind: TagKind, id: usize, supplied: ?TagHit, clamp: bool) ?u16 { var point = supplied orelse return null; if (point.kind != kind or point.id != id) return null; if (clamp) for (p.surface.tagLayers()) |*layer| { if (layer.cols == 0 or layer.kind != kind or layer.id != id or layer.serial != point.serial) continue; const bw: f32 = @floatFromInt(point.metrics.body_w); const bh: f32 = @floatFromInt(point.metrics.body_h); const left = @as(f32, @floatFromInt(layer.viewport.x)) * bw; const right = @as(f32, @floatFromInt(layer.viewport.x + layer.viewport.w)) * bw; point.pixel_x = std.math.clamp(point.pixel_x, left, @max(left, right - 0.001)); point.pixel_y = (@as(f32, @floatFromInt(layer.viewport.y)) + 0.5) * bh; break; }; return if (p.reprojectTagHit(point)) |mapped| mapped.col else null; } fn sameBodyCell(a: ?Mouse.BodyHit, b: ?Mouse.BodyHit) bool { const first = a orelse return b == null; const second = b orelse return false; return first.serial == second.serial and first.row == second.row and first.col == second.col; } fn noteLookHover(p: *Pardes, col: u16, row: u16, body_hit: ?Mouse.BodyHit, tag_hit: ?TagHit) void { const delay = config.look_preview_delay_frames orelse return; _ = delay; const id = p.paneAt(col, row) orelse return p.cancelLookHover(); const pane = p.panes[id] orelse return p.cancelLookHover(); _ = p.pointerTextSelection(id, col, row, body_hit, tag_hit) orelse return p.cancelLookHover(); if (comptime pdf_enabled) if (p.pdf_hover_preview) |shown| if (shown.col == col and shown.row == row and shown.pane == id and shown.serial == pane.serial) return; if (p.look_hover_preview) |shown| if (shown.col == col and shown.row == row and shown.pane == id and shown.serial == pane.serial and sameBodyCell(shown.body_hit, body_hit) and sameTagCell(shown.tag_hit, tag_hit)) return; if (p.look_hover_wait) |waiting| if (waiting.col == col and waiting.row == row and waiting.pane == id and waiting.serial == pane.serial and sameBodyCell(waiting.body_hit, body_hit) and sameTagCell(waiting.tag_hit, tag_hit)) return; p.cancelLookHover(); p.look_hover_wait = .{ .col = col, .row = row, .pane = id, .serial = pane.serial, .body_hit = body_hit, .tag_hit = tag_hit }; } fn refreshLookHoverFromRaw(p: *Pardes) void { if (!p.pointer_inside or !p.raw_hover_intent) return; const mapped = p.presentation.pointer(p.screen_w, p.screen_h, p.pointer_raw_col, p.pointer_raw_row) orelse return p.cancelLookHover(); p.hover_col = mapped.col; p.hover_row = mapped.row; const body_hit: ?Mouse.BodyHit = if (p.pointer_body_hit) |mapped_hit| p.bodyHitForPane(p.paneAt(mapped.col, mapped.row) orelse return p.cancelLookHover(), mapped_hit, false) orelse return p.cancelLookHover() else null; p.noteLookHover(mapped.col, mapped.row, body_hit, p.pointer_tag_hit); } fn advanceLookHover(p: *Pardes) void { const delay = config.look_preview_delay_frames orelse return; const waiting = if (p.look_hover_wait) |*w| w else return; waiting.frames +|= 1; if (waiting.frames < delay) return; const pane = p.panes[waiting.pane] orelse return p.cancelLookHover(); if (pane.serial != waiting.serial) return p.cancelLookHover(); const pointed = p.pointerTextSelection(waiting.pane, waiting.col, waiting.row, waiting.body_hit, waiting.tag_hit) orelse return p.cancelLookHover(); if (comptime pdf_enabled) if (!pointed.on_tag and panes.Pdf.paneNativeReady(p, pane)) { const probe = panes.Pdf.probeAt(p, pane, waiting.col, waiting.row) orelse return p.cancelLookHover(); const ready = waiting.*; p.look_hover_wait = null; p.pdf_hover_preview = .{ .col = ready.col, .row = ready.row, .pane = ready.pane, .serial = ready.serial, .probe = probe, }; pane.pdf.?.invalidateRaster(probe.page); return; }; const operand = p.pointerOperand(pane, pointed.sel); if (operand.text == null or operand.text.?.len == 0) return p.cancelLookHover(); var preview = operand.preview; if (preview) |*sel| sel.state = .done; p.look_hover_preview = .{ .col = waiting.col, .row = waiting.row, .pane = waiting.pane, .serial = waiting.serial, .sel = preview, .file_word = operand.file_word, .body_hit = waiting.body_hit, .tag_hit = waiting.tag_hit, }; p.look_hover_wait = null; } pub fn acknowledgePanelPresentation(p: *Pardes, tracks: []const layout.Track) void { p.presentation.acknowledge(p, tracks); if (p.pointer_inside and p.drag != .none) { if (p.presentation.pointer(p.screen_w, p.screen_h, p.pointer_raw_col, p.pointer_raw_row)) |mapped| { p.hover_col = mapped.col; p.hover_row = mapped.row; p.dragUpdate(mapped.col, mapped.row, p.pointer_body_hit); } } p.refreshLookHoverFromRaw(); } pub fn abandonPanelAnimations(p: *Pardes) void { p.presentation.cancel(); p.cancelLookHover(); } /// A native scene pass proved unavailable. Config reads these exact bits, /// and the idle-clock predicate stops immediately once they are clear. pub fn disableSceneEffects(p: *Pardes) void { p.settings.scene_effects = .{}; } fn handleMouse(p: *Pardes, m: Mouse) void { if (m.button == .back or m.button == .forward) { if (m.kind == .press) p.jumpBy(if (m.button == .back) -1 else 1); return; } const raw_col = @min(m.col, p.screen_w -| 1); const raw_row = @min(m.row, p.screen_h -| 1); p.pointer_raw_col = raw_col; p.pointer_raw_row = raw_row; const previous_hit = p.pointer_body_hit; p.pointer_body_hit = m.body_hit; const previous_tag_hit = p.pointer_tag_hit; p.pointer_tag_hit = if (m.tag_hit) |point| p.reprojectTagHit(point) else null; if (m.tag_hit != null and p.pointer_tag_hit == null and m.kind == .press) return; if (m.tag_hit == null and (m.kind == .drag or m.kind == .release) and (p.drag == .tag or p.drag == .select or p.header_drag)) { if (previous_tag_hit) |previous| { var continued = previous; continued.pixel_x = (@as(f32, @floatFromInt(raw_col)) + 0.5) * @as(f32, @floatFromInt(previous.metrics.body_w)); continued.pixel_y = (@as(f32, @floatFromInt(raw_row)) + 0.5) * @as(f32, @floatFromInt(previous.metrics.body_h)); p.pointer_tag_hit = continued; } } if ((m.kind == .drag or m.kind == .release) and (p.drag == .tag or p.drag == .select or p.header_drag)) { if (previous_tag_hit) |previous| if (p.pointer_tag_hit) |*current| { current.kind = previous.kind; current.id = previous.id; current.serial = previous.serial; }; } // A drag can leave the compact text viewport. Preserve its metrics so // the endpoint still clamps to that body's last visible logical row. if (m.body_hit == null and (m.kind == .drag or m.kind == .release) and p.drag == .select) { if (previous_hit) |previous| { var continued = previous; continued.pixel_x = (@as(f32, @floatFromInt(raw_col)) + 0.5) * @as(f32, @floatFromInt(previous.metrics.body_w)); continued.pixel_y = (@as(f32, @floatFromInt(raw_row)) + 0.5) * @as(f32, @floatFromInt(previous.metrics.body_h)); p.pointer_body_hit = continued; } } p.pointer_inside = true; if (m.button == .none and m.kind == .motion) { p.raw_hover_intent = true; p.refreshLookHoverFromRaw(); return; } p.raw_hover_intent = false; const mapped = p.presentation.pointer(p.screen_w, p.screen_h, raw_col, raw_row) orelse { p.cancelLookHover(); if (m.kind == .release) p.dragRelease(m.button); return; }; const mcol = mapped.col; const mrow = mapped.row; p.hover_col = mcol; p.hover_row = mrow; const hovered = p.paneAt(mcol, mrow); // any press that is NOT the execute+select chord abandons a pending // chord argument if (m.kind == .press and !(m.button == config.select_button and p.drag == .select and p.drag.select.button == config.exec_button)) { if (p.chord_arg) |a| { p.gpa.free(a); p.chord_arg = null; } } if (mrow >= TOPBAR_H and mrow < TOPBAR_H + p.columnBarHeight() and m.kind == .press) { for (0..p.ncol) |column| { if (mcol < p.col_x[column] or mcol >= p.col_x[column] + @min(config.GUTTER, p.col_w[column])) continue; if (m.button == config.select_button) { p.header_drag = false; p.drag = .{ .column_move = .{ .column = column, .serial = p.col_serial[column], .cur_x = mcol } }; } return; } } if (p.header_drag and m.button == config.select_button and (m.kind == .drag or m.kind == .release)) { const x = if (p.header_column) |c| p.col_x[c] + config.GUTTER else 0; const bar = p.headerText(); p.topbar_col = @intCast(@min(bar.len, panes.File.rawAtDisplay(bar, (p.tagColumn(if (p.header_column != null) .column else .workspace, p.header_column orelse 0, p.pointer_tag_hit, p.header_drag) orelse (mcol -| x)) + p.header_scroll))); if (m.kind == .release) p.header_drag = false; return; } if (mrow < TOPBAR_H + p.columnBarHeight() and m.kind == .press and (m.button == config.select_button or m.button == config.exec_button or m.button == config.look_button)) { if (m.button == config.select_button) if (p.panes[p.active]) |pane| { if (pane.tag_edit) exitTagEdit(pane); }; var column: ?usize = null; var x: u16 = 0; if (mrow >= TOPBAR_H) { for (0..p.ncol) |c| if (mcol >= p.col_x[c] and mcol < p.col_x[c] + p.col_w[c]) { column = c; x = p.col_x[c] + config.GUTTER; if (layout.findPane(p, p.active)) |active_col| { if (active_col.col != c and p.col_n[c] > 0) p.active = p.col_panes[c][0]; } break; }; if (column == null) return; } if (p.header_column != column) { p.rememberHeader(); p.topbar_col = null; p.header_anchor = null; p.header_scroll = 0; } p.header_column = column; const bar = p.headerText(); const at = @min(bar.len, panes.File.rawAtDisplay(bar, (p.tagColumn(if (p.header_column != null) .column else .workspace, p.header_column orelse 0, p.pointer_tag_hit, p.header_drag) orelse (mcol -| x)) + p.header_scroll)); if (m.button == config.select_button) { p.topbar_col = @intCast(at); p.header_anchor = @intCast(at); p.header_insert = true; p.header_drag = true; } else { const selected = if (p.topbar_col) |cur| blk: { const a = p.header_anchor orelse cur; break :blk if (at >= @min(a, cur) and at < @max(a, cur)) bar[@min(a, cur)..@max(a, cur)] else wordAtCol(bar, at); } else wordAtCol(bar, at); const word = p.scratch.allocator().dupe(u8, selected) catch return; p.exitHeader(); if (word.len > 0) { if (m.button == config.exec_button) { const named = std.meta.stringToEnum(Builtin, word); const held = if (named) |b| (if (builtins.registry.takesArg(b)) p.heldSelection(p.active) else null) else p.heldSelection(p.active); _ = p.execute(p.active, p.withArg(word, held)); } else p.runBuiltin(config.look_cmd, p.active, "", word); } } return; } if (m.kind == .press) { p.exitHeader(); } switch (m.button) { .none, .back, .forward => {}, // hover and navigation handled above .wheel_up, .wheel_down => { if (m.kind != .press) return; // re-arms the drift guard below; vertical always scrolls, so // the answer is never in doubt here _ = config.wheelTick(&p.wheel_guard, true); const id = hovered orelse return; const pane = p.panes[id].?; if (pane.hasPdf()) panes.Pdf.verticalWheel(p, pane, if (m.button == .wheel_up) -1 else 1) else pane.scrollBy(if (m.button == .wheel_up) -config.wheel_rows else config.wheel_rows); }, .wheel_left, .wheel_right => { if (m.kind != .press) return; if (!config.wheelTick(&p.wheel_guard, false)) return; const id = hovered orelse return; const pane = p.panes[id].?; if (pane.hasPdf()) { panes.Pdf.horizontalWheel(p, pane, if (m.button == .wheel_right) 1 else -1); // ponytail: no right clamp — overscroll shows blank and the // next cursor move or left wheel pulls it back } else if (pane.file != null and !p.settings.wrap) { // wrapped there is nothing off to the right to reach const line = panes.File.sourceLine(pane, pane.cur_row); const visual = panes.File.rawDisplayCol(line, @intCast(@max(0, pane.hscroll))); const next: usize = if (m.button == .wheel_right) visual +| @as(usize, @intCast(config.wheel_cols)) else visual -| @as(usize, @intCast(config.wheel_cols)); pane.hscroll = @intCast(panes.File.rawAtDisplay(line, next)); } }, config.select_button => switch (m.kind) { .press => { if (p.drag == .select and p.drag.select.button == config.exec_button) { if (p.heldSelection(p.drag.select.id)) |tx| { if (p.chord_arg) |old| p.gpa.free(old); p.chord_arg = p.gpa.dupe(u8, tx) catch null; } return; } // acme 3-1: a select tap during a LOOK drag cancels the // look; the rest of the gesture is inert if (p.drag == .select and p.drag.select.button == config.look_button) { if (p.panes[p.drag.select.id]) |t| { t.sel[@intFromEnum(config.look_button)].state = .none; if (comptime pdf_enabled) panes.Pdf.pointerCancel(t, p.drag.select.pdf); } p.drag = .none; return; } if (hovered) |mid| { const mr = p.rects[mid]; const mtag = if (p.settings.tag_bottom) mr.y + mr.h -| BOX_H else mr.y; if (mcol < mr.x + config.GUTTER and mrow >= mtag and mrow < mtag + BOX_H) { p.drag = .{ .move = .{ .id = mid, .cur_x = mcol, .cur_y = mrow } }; return; } } for (0..p.ncol -| 1) |c| { if (mcol == p.col_x[c] + p.col_w[c] -| 1) { const corner: @FieldType(@FieldType(Drag, "border_v"), "corner") = if (p.seamIdxAt(c, mrow)) |k| .{ .col = c, .idx = k } else if (p.seamIdxAt(c + 1, mrow)) |k| .{ .col = c + 1, .idx = k } else null; p.drag = .{ .border_v = .{ .left_col = c, .cur_x = mcol, .corner = corner, .cur_y = mrow } }; return; } } for (0..p.ncol) |cc| { if (mcol < p.col_x[cc] or mcol >= p.col_x[cc] + p.col_w[cc]) continue; if (p.seamIdxAt(cc, mrow)) |k| { p.drag = .{ .border_h = .{ .col = cc, .top_idx = k, .cur_y = mrow } }; return; } } const id = hovered orelse return; const r = p.rects[id]; const pane = p.panes[id] orelse return; if (id != p.active) if (p.panes[p.active]) |previous| { if (previous.tag_edit) exitTagEdit(previous); }; const tag_y = if (p.settings.tag_bottom) r.y + r.h -| BOX_H else r.y; const body_y = if (p.settings.tag_bottom) r.y else r.y + BOX_H; if (mcol < r.x + config.GUTTER) { // gutter scrollbar: a left click scrolls UP to that row p.active = id; pane.scrollBy(-(@as(i32, mrow) - @as(i32, body_y))); } else if (mrow >= tag_y and mrow < tag_y + BOX_H) { p.active = id; p.enterTagEdit(pane, if (p.tagColumn(.pane, id, p.pointer_tag_hit, false)) |value| @as(i32, value) else @as(i32, mcol) - @as(i32, r.x + config.GUTTER)); pane.tag_anchor = pane.tag_col; p.drag = .{ .tag = .{ .id = id } }; } else { p.active = id; if (pane.tag_edit) exitTagEdit(pane); const pointed = p.pointerTextSelection(id, mcol, mrow, m.body_hit, p.pointer_tag_hit) orelse return; pane.clearPointerSelection(sel_slot); pane.sel[sel_slot] = pointed.sel; p.drag = .{ .select = .{ .id = id, .button = config.select_button, .ctrl = m.ctrl, .pdf = panes.Pdf.pointerStart( p, pane, config.select_button, mcol, mrow, false, ), } }; } }, .drag => p.dragUpdate(mcol, mrow, p.pointer_body_hit), .release => { if ((p.pointer_body_hit != null or p.pointer_tag_hit != null or p.drag == .column_move) and p.drag.button() == m.button) p.dragUpdate(mcol, mrow, p.pointer_body_hit); p.dragRelease(config.select_button); }, .motion => {}, }, config.exec_button, config.look_button => switch (m.kind) { .press => { // acme 1-2 / 1-3: the execute (Cut) or look (Paste) button // tapped while the select button holds a selection drag if (p.drag == .select and p.drag.select.button == config.select_button) { p.chordCutPaste(m.button == config.exec_button); return; } // acme 2-3 / 3-2: tapping the OTHER of execute/look during // an execute or look drag cancels it (textselect23's mask) if (p.drag == .select) { if (p.panes[p.drag.select.id]) |t| { t.sel[@intFromEnum(p.drag.select.button)].state = .none; if (comptime pdf_enabled) panes.Pdf.pointerCancel(t, p.drag.select.pdf); } p.drag = .none; return; } const id = hovered orelse return; const r = p.rects[id]; const pane = p.panes[id] orelse return; // the tag row and the body's first, exactly as the select // arm above derives them const tag_y = if (p.settings.tag_bottom) r.y + r.h -| BOX_H else r.y; const body_y = if (p.settings.tag_bottom) r.y else r.y + BOX_H; const on_tag = mrow >= tag_y and mrow < tag_y + BOX_H; if (mcol < r.x + config.GUTTER and !on_tag) { const local = @as(i32, mrow) - @as(i32, body_y); if (m.button == config.look_button) p.active = id; pane.scrollBy(if (m.button == config.look_button) local else -local); } else if (p.pointerTextSelection(id, mcol, mrow, m.body_hit, p.pointer_tag_hit)) |pointed| { if (m.button == config.look_button) p.active = id; pane.clearPointerSelection(@intFromEnum(m.button)); pane.sel[@intFromEnum(m.button)] = pointed.sel; p.drag = .{ .select = .{ .id = id, .button = m.button, // A native body gesture stays native even when its // point later misses a letterbox or selection. .pdf = panes.Pdf.pointerStart( p, pane, m.button, mcol, mrow, pointed.on_tag, ), }, }; } }, .drag => p.dragUpdate(mcol, mrow, p.pointer_body_hit), .release => { if ((p.pointer_body_hit != null or p.pointer_tag_hit != null or p.drag == .column_move) and p.drag.button() == m.button) p.dragUpdate(mcol, mrow, p.pointer_body_hit); p.dragRelease(m.button); }, .motion => {}, }, } } /// Index of the pane pair in column `c` whose seam (see seamRowOf) is screen /// row `mrow`. Out-of-range columns simply have no seam. fn seamIdxAt(p: *const Pardes, c: usize, mrow: u16) ?usize { if (c >= p.ncol) return null; for (0..p.col_n[c] -| 1) |k| if (mrow == seamRowOf(p, c, k)) return k; return null; } fn dragUpdate(p: *Pardes, mcol: u16, mrow: u16, body_hit: ?Mouse.BodyHit) void { switch (p.drag) { .border_v => |*d| { const c = d.left_col; d.cur_x = if (c + 1 < p.ncol) clampBorderCol(p.col_x[c], p.col_w[c], p.col_w[c + 1], mcol) else mcol; if (d.corner) |k| if (k.idx + 1 < p.col_n[k.col]) { const a = p.rects[p.col_panes[k.col][k.idx]]; const b = p.rects[p.col_panes[k.col][k.idx + 1]]; d.cur_y = clampBorderRow(a.y, a.h, b.h, mrow, p.settings.tag_bottom); } else { d.cur_y = mrow; }; }, .border_h => |*d| { const cc = d.col; const k = d.top_idx; if (k + 1 < p.col_n[cc]) { const a = p.rects[p.col_panes[cc][k]]; const b = p.rects[p.col_panes[cc][k + 1]]; d.cur_y = clampBorderRow(a.y, a.h, b.h, mrow, p.settings.tag_bottom); } else d.cur_y = mrow; }, .column_move => |*d| d.cur_x = mcol, .move => |*d| { d.cur_x = mcol; d.cur_y = mrow; }, .select => |*s| { if (p.panes[s.id]) |pane| { const r = p.rects[s.id]; const b = @intFromEnum(s.button); if (pane.body_rows > 0 and pane.sel[b].r0 >= BOX_H) if (body_hit) |supplied| { const mapped_hit = p.bodyHitForPane(s.id, supplied, true) orelse return; pane.sel[b].c1 = @as(i32, mapped_hit.col) - config.GUTTER; pane.sel[b].r1 = @as(i32, mapped_hit.row) + BOX_H; return; }; if (pane.sel[b].r0 < BOX_H) if (p.tagColumn(.pane, s.id, p.pointer_tag_hit, true)) |value| { pane.sel[b].c1 = @as(i32, value) + pane.tag_scroll; pane.sel[b].r1 = 0; return; }; pane.sel[b].c1 = @as(i32, mcol) - @as(i32, r.x + config.GUTTER); if (p.settings.tag_bottom) { const body_h = r.h -| BOX_H; pane.sel[b].r1 = if (pane.sel[b].r0 < BOX_H or body_h == 0) 0 else @as(i32, @min(@max(mrow, r.y), r.y + body_h - 1)) - @as(i32, r.y) + @as(i32, BOX_H); } else pane.sel[b].r1 = if (pane.sel[b].r0 < BOX_H) 0 else @as(i32, mrow) - @as(i32, r.y); if (pane.sel[b].r1 < BOX_H) pane.sel[b].c1 += pane.tag_scroll; if (comptime pdf_enabled) panes.Pdf.pointerUpdate(&s.pdf, p, pane, mcol, mrow); } }, .tag => |d| { const pane = p.panes[d.id] orelse return; const c = if (p.tagColumn(.pane, d.id, p.pointer_tag_hit, true)) |value| @as(i32, value) else @as(i32, mcol) - @as(i32, p.rects[d.id].x + config.GUTTER); const text = p.tagText(p.scratch.allocator(), pane) catch return; pane.tag_col = @intCast(@min(text.len, panes.File.rawAtDisplay(text, @as(usize, @intCast(@max(0, c))) + pane.tag_scroll))); pane.tag_sel = pane.tag_col != pane.tag_anchor; }, .none => {}, } } fn reportGesture( p: *Pardes, id: usize, cmd: Builtin, text: []const u8, on_tag: bool, operand: PointerOperand, chorded: bool, ) bool { if (!p.fs.scripted(id)) return false; const is_look = cmd == config.look_cmd; const action: filesystem.Action = if (is_look) (if (on_tag) .tag_look else .body_look) else (if (on_tag) .tag_exec else .body_exec); const named = std.meta.stringToEnum(Builtin, commandText(text)) != null; const range = p.gestureRange(id, text, on_tag, operand); var flag: u32 = if (named) filesystem.flag_builtin else 0; if (range.q0 == range.q1 and text.len > 0) flag |= filesystem.flag_expansion; if (is_look) { if (!named and std.mem.indexOfAny(u8, text, "/.:") != null) flag |= filesystem.flag_filename; } else if (chorded) flag |= filesystem.flag_chorded; return filesystem.noteAction(p, id, action, range.q0, range.q1, flag, text); } fn gestureRange(p: *Pardes, id: usize, text: []const u8, on_tag: bool, operand: PointerOperand) filesystem.PaneState.Range { const pane = p.panes[id] orelse return .{}; if (on_tag) { const tag = p.tagText(p.scratch.allocator(), pane) catch return .{}; const sel = operand.expanded orelse operand.preview orelse return .{}; const lead = @min(sel.c0, sel.c1); const at = panes.File.rawAtDisplay(tag, @intCast(@max(0, lead))); const q0: u32 = @intCast(@min(at, tag.len)); return .{ .q0 = q0, .q1 = @intCast(@min(q0 + text.len, tag.len)) }; } const f = if (pane.file) |*file| file else return .{}; if (operand.preview) |selection| if (pane.pointerSelection(selection)) |rows| { if (rows.len == 0) return .{}; const first = rows[0]; const last = rows[rows.len - 1]; const q0 = panes.File.lineStart(p.gpa, f, @intCast(@max(0, first.row))) + @min(first.lo, panes.File.sourceLine(pane, first.row).len); const q1 = panes.File.lineStart(p.gpa, f, @intCast(@max(0, last.row))) + @min(last.hi, panes.File.sourceLine(pane, last.row).len); // A rectangle may contain disjoint pieces. Such an operand uses // the event's expansion text instead of inventing a source range. if (q1 < q0 or !std.mem.eql(u8, f.content[q0..q1], text)) return .{}; return .{ .q0 = @intCast(q0), .q1 = @intCast(q1) }; }; const start: ?modal.Cursor = if (operand.file_word) |w| .{ .row = @intCast(@max(0, w.row)), .col = @intCast(@max(0, w.lo)) } else if (operand.expanded orelse operand.preview) |sel| lead: { // A selection's leading corner in reading order, converted the way // `pointerOperand` converts the click itself. const top = @min(sel.r0, sel.r1) - @as(i32, BOX_H); if (top < 0) break :lead null; const w = pane.wrapAt(top); const col = p.paneByteAtDisplay(pane, w.line, w.at, @min(sel.c0, sel.c1) - panes.File.gutterWidth(pane)); break :lead .{ .row = @intCast(@max(0, w.line)), .col = @intCast(@max(0, col)), }; } else null; const cursor = start orelse return .{}; const q0: u32 = @intCast(@min(modal.offsetAt(f.content, cursor), f.content.len)); return .{ .q0 = q0, .q1 = @intCast(@min(q0 + text.len, f.content.len)) }; } fn dispatchPointerBuiltin( p: *Pardes, id: usize, cmd: Builtin, text: ?[]const u8, gesture: ?struct { on_tag: bool, operand: PointerOperand }, ) void { const arg = p.chord_arg; p.chord_arg = null; defer if (arg) |a| p.gpa.free(a); const operand = text orelse { if (gesture) |g| _ = p.reportGesture(id, cmd, "", g.on_tag, g.operand, arg != null); return; }; if (gesture) |g| if (p.reportGesture(id, cmd, operand, g.on_tag, g.operand, arg != null)) return; p.runBuiltin(cmd, id, "", p.withArg(operand, arg)); } fn mirrorTtySelection(p: *Pardes, pane: *Pane) void { const gesture = pane.sel[sel_slot]; const rows = pane.pointerSelection(gesture) orelse return; const text = pane.rawPointerText(gesture) orelse return; if (rows.len == 0) return; const endpoint = rows[if (gesture.r1 >= gesture.r0) rows.len - 1 else 0]; const body = p.bodyText(p.scratch.allocator(), pane) catch return; const line = modal.lineSlice(body, @intCast(@max(0, endpoint.row - panes.Terminal.gridOffset(pane)))); const byte = if (gesture.c1 >= gesture.c0) modal.prevGrapheme(line, @min(endpoint.hi, line.len)) else endpoint.lo; pane.cur_row = pane.surfRow(endpoint.row); var shown = std.mem.trimEnd(u8, line[@min(endpoint.prompt_bytes, line.len)..], " \t"); if (pane.ovl) |overlay| { const relative = pane.cur_row - overlay.row; if (relative >= 0 and relative < modal.lineCount(overlay.text)) shown = modal.lineSlice(overlay.text, @intCast(relative)); } pane.cur_col = @intCast(modal.graphemeStart(shown, @min(shown.len, byte -| endpoint.prompt_bytes))); pane.cur_pinned = true; pane.msel.active = false; pane.vsel.active = false; pane.nsel = 0; pane.select = false; pane.append_at = null; pane.sticky_col = -1; pane.normal.clear(); p.setYank(text); } fn dragRelease(p: *Pardes, button: Mouse.Button) void { // A different button's release belongs to a chord or another host // stream; only the button which began this gesture may end it. if (p.drag.button() != button) return; defer p.drag = .none; switch (p.drag) { .border_v => |d| { const c = d.left_col; const old_seam = p.col_x[c] + p.col_w[c] -| 1; if (c + 1 < p.ncol and d.cur_x != old_seam) { const combined: u64 = p.col_w[c] + p.col_w[c + 1]; // the handle is the left pane's LAST column (inclusive), so // the new width includes it — a no-drag click changes nothing var nl: u64 = (d.cur_x + 1) -| p.col_x[c]; nl = std.math.clamp(nl, config.MINW, @max(@as(u64, config.MINW), combined -| config.MINW)); const pair = p.col_weight[c] + p.col_weight[c + 1]; if (pair > 1 and combined > 0) { const numerator = @as(u128, pair) * nl + combined / 2; const left = std.math.clamp(@as(u64, @intCast(numerator / combined)), 1, pair - 1); p.col_weight[c] = left; p.col_weight[c + 1] = pair - left; } } if (d.corner) |k| { if (d.cur_y != seamRowOf(p, k.col, k.idx)) layout.applyRowSplit(p, k.col, k.idx, d.cur_y); } }, .border_h => |d| if (d.cur_y != seamRowOf(p, d.col, d.top_idx)) layout.applyRowSplit(p, d.col, d.top_idx, d.cur_y), .column_move => |d| { if (layout.columnBySerial(p, d.serial)) |column| layout.reorderColumn(p, column, layout.columnMoveTarget(p, column, d.cur_x)); }, .move => |d| { layout.movePane(p, d.id, d.cur_x, d.cur_y); // a file moved into the left column evicts a lone unused shell if (p.panes[d.id]) |mt| if (mt.file != null) { if (layout.findPane(p, d.id)) |f| if (f.col == 0) p.evictLonePristineTty(0, d.id); }; }, .select => |s| { const pane = p.panes[s.id] orelse return; if (comptime pdf_enabled) { if (s.pdf.native) { var release = panes.Pdf.pointerRelease( p, pane, s.pdf, s.button, s.chorded, ); defer release.deinit(p.pdf_gpa); if (release.probe) |*probe| if (probe.link != null) { p.followPdfLink(s.id, probe); return; }; // A native PDF page has no byte offsets of its own to // report, so a scripted pane cannot intercept this one. if (release.action) |action| p.dispatchPointerBuiltin( s.id, if (action == .look) config.look_cmd else config.exec_cmd, release.text, null, ); return; } } if (s.chorded) { // a cut/paste chord consumed this drag; the release is inert pane.sel[@intFromEnum(s.button)].state = .none; return; } const b = @intFromEnum(s.button); pane.sel[b].state = .done; if (s.button == config.select_button) { const sl = pane.sel[sel_slot]; const dragged = sl.c0 != sl.c1 or sl.r0 != sl.r1; if (!dragged) pane.sel[sel_slot].state = .none; if (dragged) p.capturePointerSelection(pane, sel_slot) catch {}; if (dragged and pane.mode == .tty) p.mirrorTtySelection(pane); const body_vis = sl.r1 - @as(i32, BOX_H); if (body_vis >= 0 and pane.mode != .tty) { const w = pane.wrapAt(body_vis); pane.cur_row = w.line; pane.cur_col = p.paneByteAtDisplay( pane, w.line, w.at, sl.c1 - (if (pane.file != null) @as(i32, panes.File.gutterWidth(pane)) else 0), ); pane.cur_pinned = true; if (clampFileCursor(pane)) pane.ensureCursorVisible(); pane.msel.active = false; pane.normal.clear(); pane.vsel.active = false; pane.nsel = 0; // a click says WHERE the one cursor is pane.select = false; pane.append_at = null; pane.sticky_col = -1; if (s.ctrl) p.lspRequest(s.id, .definition, ""); } } else { const clk = pane.sel[b]; var operand = p.pointerOperand(pane, clk); if (operand.expanded) |expanded| pane.sel[b] = expanded else if (operand.text == null) pane.sel[b].state = .none; if (s.button == config.look_button and clk.r0 >= BOX_H and pane.mode != .tty) { pane.cur_row = operand.row; pane.cur_col = operand.col; if (clampFileCursor(pane)) pane.ensureCursorVisible(); } const txt = operand.text; const cmd = if (s.button == config.look_button) config.look_cmd else config.exec_cmd; if (clk.r0 < BOX_H and s.button == config.exec_button and pane.tag_name_len != null) { const prefix_width = panes.File.displayWidth(p.tagPrefix(pane) catch return); const on_name = @as(usize, @intCast(@max(0, clk.c0))) < prefix_width; if (!p.commitTagName(pane)) return; // Executing a staged filename commits it; executing its // command tail uses the new identity (especially Save). if (on_name) return; const new_width = panes.File.displayWidth(p.tagPrefix(pane) catch return); const shift = @as(i32, @intCast(new_width)) - @as(i32, @intCast(prefix_width)); if (operand.expanded) |*sel| { sel.c0 += shift; sel.c1 += shift; } if (operand.preview) |*sel| { sel.c0 += shift; sel.c1 += shift; } pane.sel[b].c0 += shift; pane.sel[b].c1 += shift; } p.dispatchPointerBuiltin(s.id, cmd, txt, .{ .on_tag = clk.r0 < BOX_H, .operand = operand, }); } }, .tag => {}, // dragUpdate already left the tag cursor + selection set .none => {}, } } fn chordCutPaste(p: *Pardes, cut: bool) void { const s = &p.drag.select; const pane = p.panes[s.id] orelse return; if (comptime pdf_enabled) if (pane.pdf) |pv| { if (!s.chorded) { s.chorded = true; pane.sel[sel_slot].state = .none; } // A PDF is immutable: its Cut-side chord is Snarf (copy), while // Paste has no document mutation to perform. if (cut and pv.selection_text.len > 0) p.setYank(pv.selection_text); return; }; if (pane.mode == .tty) { if (!s.chorded) { s.chorded = true; pane.sel[sel_slot].state = .none; // drop the sweep highlight } if (cut) return; const y = p.yank orelse return; if (y.len == 0) return; if (panes.Terminal.reportsMouse(pane)) { const col: u16 = @intCast(std.math.clamp(pane.sel[sel_slot].c1 + 1, 1, 9999)); const row: u16 = @intCast(std.math.clamp(pane.sel[sel_slot].r1 - @as(i32, BOX_H) + 1, 1, 9999)); var mb: [32]u8 = undefined; if (panes.Terminal.mouseFormatSgr(pane)) { p.emitWrite(s.id, std.fmt.bufPrint(&mb, "\x1b[<0;{d};{d}M\x1b[<0;{d};{d}m", .{ col, row, col, row }) catch return); } else { // ponytail: legacy X10 bytes; add utf8/urxvt formats if an app ever wants them const cb: u8 = @intCast(32 + @min(col, 222)); const rb: u8 = @intCast(32 + @min(row, 222)); p.emitWrite(s.id, &[_]u8{ 0x1b, '[', 'M', 32, cb, rb, 0x1b, '[', 'M', 35, cb, rb }); } } p.typeToTty(s.id, pane, y); return; } if (!s.chorded) { s.chorded = true; const sl = pane.sel[sel_slot]; pane.sel[sel_slot].state = .none; const pfx: i32 = if (pane.file != null) panes.File.gutterWidth(pane) else 0; // both ends of the sweep through the same last-frame map, so a // chord over wrapped rows cuts the text that was under it const w0 = pane.wrapAt(@max(0, sl.r0 - @as(i32, BOX_H))); const w1 = pane.wrapAt(@max(0, sl.r1 - @as(i32, BOX_H))); const row0 = w0.line; const row1 = w1.line; const col0 = p.paneByteAtDisplay(pane, w0.line, w0.at, sl.c0 - pfx); const col1 = p.paneByteAtDisplay(pane, w1.line, w1.at, sl.c1 - pfx); pane.cur_row = row1; pane.cur_col = col1; pane.cur_pinned = true; pane.msel.active = false; pane.normal.clear(); if (!pane.isTerminal()) pane.mode = .normal; pane.vsel = .{ .active = row0 != row1 or col0 != col1, .row = row0, .col = col0, .explicit = false }; pane.nsel = 0; } if (cut) { if (pane.vsel.active) p.normalDelete(pane, true); } else if (pane.vsel.active) { p.normalReplaceYank(pane); } else if (pane.file) |*f| { // bare cursor: splice the register in literally (no linewise // magic) so a paste right after a cut restores the text exactly const y = p.yank orelse return; if (y.len == 0) return; const at = modal.Cursor{ .row = @intCast(@max(0, pane.cur_row)), .col = @intCast(@max(0, pane.cur_col)) }; p.pushUndo(pane); const new = modal.insertAt(p.gpa, f.content, at, y) catch return; panes.File.setContent(p, f, new); pane.vsel = .{ .active = modal.nextGrapheme(y, 0) < y.len, .row = @intCast(at.row), .col = @intCast(at.col), .explicit = false }; const end = modal.advanceBy(at, y); if (end.col > 0) { pane.cur_row = @intCast(end.row); pane.cur_col = @intCast(modal.prevGrapheme(modal.lineSlice(f.content, end.row), end.col)); } else { // the register ended in '\n': the cursor lands ON that newline pane.cur_row = @intCast(end.row -| 1); pane.cur_col = @intCast(modal.lineSlice(f.content, end.row -| 1).len); } pane.cur_pinned = true; pane.sticky_col = -1; pane.ensureCursorVisible(); } else p.pasteText(pane, p.yank orelse return, true, 1); } /// Tty: a shell in the caller's directory, raw from the first frame, /// stacked below the caller like Alt-n's new shell. pub fn spawnTty(p: *Pardes, from: usize) void { p.spawnTtyWithMount(from, false); } pub fn spawnV9fsTty(p: *Pardes, from: usize) void { if (comptime !hosted or @import("builtin").os.tag != .linux) return p.reportError(from, "Tty9p", error.LinuxRequired); if (p.fs.socket_path.len == 0) return p.reportError(from, "Tty9p", error.SessionSocketRequired); p.spawnTtyWithMount(from, true); } fn spawnTtyWithMount(p: *Pardes, from: usize, v9fs: bool) void { const src = p.panes[from] orelse return; const free = p.freeSlot() orelse return; const nt = p.newShell(free, paneDir(src)) catch return; nt.greet = !v9fs; nt.v9fs_on_spawn = v9fs; nt.mode = .tty; const parent = layout.splitParent(p, from); const f = layout.findPane(p, parent).?; layout.insert(p, f.col, f.idx + 1, free); layout.splitBelow(p, parent, nt); p.active = free; } /// when a doc lands in `col`, a lone pristine shell there is clutter — drop /// it; absorbVWeight hands its space to the doc fn evictLonePristineTty(p: *Pardes, col: usize, keep_id: usize) void { var n_tty: usize = 0; var tty_id: usize = 0; for (0..p.col_n[col]) |k| { const cid = p.col_panes[col][k]; if (p.panes[cid]) |ct| if (ct.isTerminal()) { n_tty += 1; tty_id = cid; }; } if (n_tty != 1) return; const tt = p.panes[tty_id] orelse return; // No typing, cursor on the first prompt line, no scrollback: this is // the throwaway boot placeholder a document may replace. if (tt.ovl != null or panes.Terminal.gridCursor(tt).y != 0 or panes.Terminal.scrollbar(tt).total > tt.rows) return; p.deinitPane(tt) catch |err| return p.reportError(tty_id, "close", err); layout.compute(p); // a just-stacked doc has no rect yet; absorb snaps to rows layout.absorbVWeight(p, tty_id); layout.removePane(p, tty_id); p.panes[tty_id] = null; if (p.active == tty_id) p.active = keep_id; } fn ttyForDir(p: *Pardes, dir: []const u8) ?usize { for (p.panes, 0..) |slot, i| if (slot) |tt| { if (!std.mem.eql(u8, tt.cwdSlice(), dir)) continue; if (p.takesCommandLine(i)) return i; }; const free = p.freeSlot() orelse return null; const nt = p.newShell(free, dir) catch return null; nt.greet = false; const rc = if (p.ncol > 0) p.ncol - 1 else 0; if (p.col_n[rc] > 0) { const src = layout.splitParent(p, p.col_panes[rc][p.col_n[rc] - 1]); const f = layout.findPane(p, src).?; layout.splitBelow(p, src, nt); layout.insert(p, f.col, f.idx + 1, free); } else layout.insert(p, rc, p.col_n[rc], free); return free; } // ---- doc panes ---- pub fn openTutorView(p: *Pardes, id: usize) !*Pane { const content = try p.gpa.dupe(u8, tutor_text); errdefer p.gpa.free(content); const path = try p.gpa.dupe(u8, "/Tutor"); errdefer p.gpa.free(path); const history = try panes.File.History.create(p.gpa); errdefer p.gpa.destroy(history); const pane = try p.newDocPane(id); pane.file = .{ .path = path, .content = content, .history = history }; pane.cur_pinned = true; return pane; } pub fn setTestFile(p: *Pardes, content: []const u8) !*Pane { const copy = try p.gpa.dupe(u8, content); errdefer p.gpa.free(copy); const path = try p.gpa.dupe(u8, "/test.txt"); errdefer p.gpa.free(path); const history = try panes.File.History.create(p.gpa); errdefer p.gpa.destroy(history); const pane = try panes.Terminal.createDoc(p.gpa, p.screen_w, p.screen_h); errdefer p.gpa.destroy(pane); try p.deinitPane(p.panes[0].?); p.panes[0] = null; pane.file = .{ .path = path, .content = copy, .history = history }; p.installPane(0, pane); pane.cur_pinned = true; p.active = 0; p.sync(); return pane; } fn handlePdfNormal(p: *Pardes, pane: *Pane, key: Key) void { if (comptime !pdf_enabled) return; if (pane.pdf == null) return; if (hit(key, config.leave_pane)) { pane.normal.clear(); pane.select = false; return p.runBuiltin(.Last, p.active, "", null); } if (pane.normal.prefix == .none and isPrefix(key, 'f')) { pane.normal.clear(); return p.runBuiltin(.PdfSections, p.active, "", null); } const parsed = modal.Normal.parse(&pane.normal, normalInput(key)); switch (parsed) { .pending, .ignored, .unbound => {}, .action => |semantic| { const result = panes.Pdf.applyNormal( &pane.pdf.?, p.pdf_gpa, semantic, p.native_images, .{ .w = p.cell_pixels.w, .h = p.cell_pixels.h }, panes.Pdf.paneViewport(p, pane), panes.Pdf.paneGeometry(p, pane), ); if (result.page_changed) panes.Pdf.resetPageChrome(pane); switch (result.host) { .none => {}, .leader => { p.leader_on = true; p.leader_n = 0; }, .command_line => { p.enterTagEdit(pane, -1); if (pane.tag_edit) pane.mode = .normal; }, .search => p.startPrompt(pane, .{ .search = config.search_marker }), .search_forward => p.lookWalk(1), .search_backward => p.lookWalk(-1), } }, } } // ---- the ONE dispatcher: look (right/Enter) and execute (middle/Tab) ---- pub fn focusPaneLine(p: *Pardes, id: usize, at: look.Spot, landing: enum { center, keep }) void { if (id >= MAX_PANES) return; const pane = p.panes[id] orelse return; p.active = id; pane.normal.clear(); if (pane.hasPdf()) { if (landing == .keep) return; if (comptime pdf_enabled) { const pv = &pane.pdf.?; if (pv.focusLocation(p.pdf_gpa, at.line, at.col)) panes.Pdf.resetPageChrome(pane); } return; } if (at.line == 0) return; if (pane.file) |*f| { if (at.line > panes.File.nlines(p.gpa, f)) return; if (landing == .center) { const next = (at.line - 1) -| pane.rows / 2; // center, clamp at top if (next != f.scroll) { f.scroll = next; f.syntax_dirty = true; } } } pane.cur_row = @intCast(at.line - 1); pane.cur_col = if (at.col > 0) @intCast(at.col - 1) else 0; pane.cur_pinned = true; if (at.end_line != 0) return p.selectSpan(pane, at); pane.ensureCursorVisible(); } fn selectSpan(p: *Pardes, pane: *Pane, at: look.Spot) void { const text = p.flatSurface(pane) catch return; // saturating, not `- 1`: `f.zig:0-5` is a legal thing to type and a // 1-based zero is the same nothing an absent number is const acol: i32 = @intCast(at.col -| 1); const ecol: i32 = if (at.end_col > 0) @intCast(at.end_col - 1) else std.math.maxInt(i32); const r = Pane.cellRange(text, @intCast(at.line -| 1), acol, @intCast(at.end_line -| 1), ecol); pane.setRange(text, 0, r, true); } fn openPaneTarget(p: *const Pardes, path: []const u8, at: look.Spot) ?look.Target { for (p.panes, 0..) |slot, i| { const tt = slot orelse continue; if (tt.image) |iv| if (std.mem.eql(u8, iv.path, path)) { return .{ .pane = .{ .id = i, .at = .{} } }; }; if (comptime pdf_enabled) if (tt.pdf) |pv| if (std.mem.eql(u8, pv.path, path)) { return .{ .pane = .{ .id = i, .at = at } }; }; const ff = if (tt.file) |*f| f else continue; if (!std.mem.eql(u8, ff.path, path)) continue; return .{ .pane = .{ .id = i, .at = at } }; } return null; } fn focusPaneByPath(p: *Pardes, path: []const u8, at: look.Spot) bool { const target = p.openPaneTarget(path, at) orelse return false; p.focusPaneLine(target.pane.id, target.pane.at, .center); return true; } /// Resolve without changing focus or falling back to search. PDF links use /// the same location rules as an ordinary Look before offering a choice. fn resolveLookTarget(p: *Pardes, id: usize, txt: []const u8, realbuf: *[4096]u8) look.Target { const trimmed = std.mem.trim(u8, txt, " \t\r\n"); const pl = look.parsePathLine(trimmed); if (pl.path.len > 0) if (p.openPaneTarget(pl.path, pl.at)) |target| return target; var found: look.Target = .none; var seen: [MAX_PANES][]const u8 = undefined; var nseen: usize = 0; var n: usize = 0; cand: while (n <= p.njumps) : (n += 1) { const k: usize = if (n == 0) id else p.jumps[p.njumps - n].pane; if (n > 0 and k == id) continue; // tried first, skip the rerun const op = p.panes[k] orelse continue; const dir = paneDir(op); for (seen[0..nseen]) |s| if (std.mem.eql(u8, s, dir)) continue :cand; seen[nseen] = dir; nseen += 1; if (pl.path.len > 0 and pl.path[0] != '/') { var joinbuf: [2048]u8 = undefined; if (std.fmt.bufPrint(&joinbuf, "{s}/{s}", .{ dir, pl.path }) catch null) |j| if (p.openPaneTarget(j, pl.at)) |target| return target; } found = look.resolve(p, txt, dir, realbuf); if (found != .none or pl.path.len == 0 or pl.path[0] == '/') break; } return found; } fn locationText(p: *Pardes, path: []const u8, at: look.Spot) ?[]const u8 { if (std.mem.indexOfAny(u8, path, "\r\n") != null) return null; const arena = p.scratch.allocator(); if (at.line == 0) return arena.dupe(u8, path) catch null; if (at.end_line != 0) { if (at.col == 0 and at.end_col == 0) return std.fmt.allocPrint(arena, "{s}:{d}-{d}", .{ path, at.line, at.end_line }) catch null; return std.fmt.allocPrint(arena, "{s}:{d}:{d}-{d}:{d}", .{ path, at.line, at.col, at.end_line, at.end_col }) catch null; } if (at.col != 0) return std.fmt.allocPrint(arena, "{s}:{d}:{d}", .{ path, at.line, at.col }) catch null; return std.fmt.allocPrint(arena, "{s}:{d}", .{ path, at.line }) catch null; } fn canonicalLookLocation(p: *Pardes, id: usize, text: []const u8) ?[]const u8 { const trimmed = std.mem.trim(u8, text, " \t\r\n"); if (trimmed.len == 0) return null; var realbuf: [4096]u8 = undefined; switch (p.resolveLookTarget(id, trimmed, &realbuf)) { .none => return null, .url => |url| { if (url.len > 256) return null; for (url) |c| if (c <= 0x20 or c == 0x7f) return null; return p.scratch.allocator().dupe(u8, url) catch null; }, .file => |file| return p.locationText(file.path, file.at), .image => |iv| return p.locationText(iv.path, .{}), .dir => |dir| return p.locationText(dir, .{}), .pane => |target| { if (target.id >= p.panes.len) return null; const pane = p.panes[target.id] orelse return null; if (pane.file) |file| return p.locationText(file.path, target.at); if (comptime pdf_enabled) if (pane.pdf) |doc| return p.locationText(doc.path, target.at); if (pane.image) |iv| return p.locationText(iv.path, target.at); var path: [32]u8 = undefined; return p.locationText(std.fmt.bufPrint(&path, config.pane_addr ++ "{d}", .{target.id}) catch return null, target.at); }, } } pub fn pdfLinkLocation(p: *Pardes, pane: *const Pane, link: if (pdf_enabled) pdf.Link else void) ?[]const u8 { if (comptime !pdf_enabled) return null; const state = pane.pdf orelse return null; const id = p.paneIdOf(pane) orelse return null; return switch (link.destination) { .none => null, .internal => |dest| if (dest.page < state.document.pages) p.locationText(state.path, .{ .line = dest.page + 1 }) else null, .external => |uri| p.canonicalLookLocation(id, uri), }; } fn followPdfLink(p: *Pardes, id: usize, probe: *const panes.Pdf.WordProbe) void { if (comptime !pdf_enabled) return; const pane = p.panes[id] orelse return; const link = probe.link orelse return; const target = p.pdfLinkLocation(pane, link) orelse return; if (p.canonicalLookLocation(id, probe.text)) |visible| { if (!std.mem.eql(u8, visible, target)) { const content = std.fmt.allocPrint(p.gpa, "{s}\n{s}\n", .{ visible, target }) catch |err| return p.reportError(id, "PDF link choices", err); panes.Output.fillResults(p, id, std.fs.path.dirname(pane.pdf.?.path) orelse ".", .pdf_links, "", content, null) catch |err| return p.reportError(id, "PDF link choices", err); return; } } switch (link.destination) { .internal => |dest| { p.noteHaptic(.look); p.noteLookSource(id); p.active = id; panes.Pdf.revealOutlineDestination(p, pane, dest); }, .external => p.lookAt(id, target), .none => {}, } } pub fn lookAt(p: *Pardes, id: usize, txt: []const u8) void { const pane = p.panes[id] orelse return; p.noteHaptic(.look); p.noteLookSource(id); const trimmed = std.mem.trim(u8, txt, " \t\r\n"); if (config.commandWord(trimmed)) |cmd| { if (p.execute(id, cmd)) |dst| p.active = dst; return; } const pl = look.parsePathLine(trimmed); if (comptime pdf_enabled) if (panes.Pdf.lookSection(p, id, pl.path, pl.at)) return; var realbuf: [4096]u8 = undefined; const found = p.resolveLookTarget(id, txt, &realbuf); switch (found) { .none => { const bmode = if (pane.tag_edit) pane.tag_mode else pane.mode; if (pane.image != null or bmode == .tty) return; p.runSearch(id, trimmed, .text, .cursor) catch |err| { p.reportError(id, "search", err); return; }; const at = pane.search_row; _ = p.searchStep(id, 1); if (at != null and pane.search_row == at) { pane.search_row = null; _ = p.searchStep(id, 1); } }, .pane => |t| p.focusPaneLine(t.id, t.at, .center), .url => |u| if (u.len <= 256) p.emit(.{ .open_link = .from(u) }), .dir => |dir| { for (p.panes, 0..) |slot, i| { if (slot) |tt| if (std.mem.eql(u8, tt.cwdSlice(), dir) and p.takesCommandLine(i)) { p.active = i; p.emitWrite(i, "ls\r"); return; }; } const free = p.freeSlot() orelse return p.reportError(id, "look", error.NoPaneSlots); const nt = p.newShell(free, dir) catch |err| return p.reportError(id, "look", err); nt.greet = true; const src = layout.splitParent(p, id); const f = layout.findPane(p, src).?; layout.insert(p, f.col, f.idx + 1, free); layout.splitBelow(p, src, nt); p.active = free; }, .file => |target| { if (comptime pdf_enabled) if (target.kind == .pdf) { if (p.focusPaneByPath(target.path, target.at)) return; const free = p.freeSlot() orelse return p.reportError(id, "look", error.NoPaneSlots); const nt = panes.Pdf.openPane(p, free, target.path, target.at.line) catch |err| return p.reportError(id, "look", err); p.placeDoc(id, free, nt); return; }; // focus an existing pane on this path (rescrolled), else open if (p.focusPaneByPath(target.path, target.at)) return; const free = p.freeSlot() orelse return p.reportError(id, "look", error.NoPaneSlots); const nt = panes.File.open(p, free, target.path, target.at.line) catch |err| return p.reportError(id, "look", err); if (target.at.col > 0) nt.cur_col = @intCast(target.at.col - 1); p.placeDoc(id, free, nt); layout.compute(p); nt.file.?.scroll -|= @max(1, p.rects[free].h -| BOX_H) / 2; if (target.at.end_line != 0) { const centered = nt.file.?.scroll; p.selectSpan(nt, target.at); nt.file.?.scroll = centered; } }, .image => |target| { if (p.focusPaneByPath(target.path, .{})) return; const free = p.freeSlot() orelse return p.reportError(id, "look", error.NoPaneSlots); const nt = panes.Image.create(p, free, target.path, &.{}) catch |err| return p.reportError(id, "look", err); p.placeDoc(id, free, nt); }, } } const max_exec_depth = 8; pub fn execute(p: *Pardes, id: usize, txt: []const u8) ?usize { const pane = p.panes[id] orelse return null; const cmd = commandText(txt); if (cmd.len == 0) return null; if (p.exec_depth == 0) p.noteHaptic(.exec); if (p.executeBuiltinLine(id, cmd)) return null; if (p.exec_depth >= max_exec_depth) return null; p.exec_depth += 1; defer p.exec_depth -= 1; const dst = (if (p.takesCommandLine(id)) id else p.ttyForDir(paneDir(pane))) orelse return null; panes.Terminal.padOutputBelowEdits(p, dst); if (panes.Terminal.queuePendingCommand(p.panes[dst].?, cmd) catch |err| { p.reportError(id, "queue command", err); return dst; }) return dst; p.emitWrite(dst, cmd); p.emitWrite(dst, "\r"); return dst; } /// Trim and unwrap clickable command-word notation once for every command /// consumer, so startup lines and interactive Exec speak the same syntax. fn commandText(txt: []const u8) []const u8 { var cmd = std.mem.trim(u8, txt, " \t\r\n"); while (config.commandWord(cmd)) |inner| cmd = std.mem.trim(u8, inner, " \t\r\n"); return cmd; } pub fn executeBuiltinLine(p: *Pardes, id: usize, txt: []const u8) bool { const cmd = commandText(txt); if (cmd.len == 0 or p.exec_depth >= max_exec_depth) return false; const sp = std.mem.indexOfAny(u8, cmd, " \t"); const bi: ?Builtin = std.meta.stringToEnum(Builtin, cmd) orelse blk: { const head = std.meta.stringToEnum(Builtin, cmd[0 .. sp orelse break :blk null]) orelse break :blk null; break :blk if (builtins.registry.takesArg(head)) head else null; }; const b = bi orelse return false; p.exec_depth += 1; defer p.exec_depth -= 1; const typed = if (sp) |s| std.mem.trim(u8, cmd[s + 1 ..], " \t") else ""; p.runBuiltin(b, id, cmd, if (typed.len > 0) typed else null); return true; } fn applyStartupConfig(p: *Pardes) void { var lines = std.mem.splitScalar(u8, p.opts.startup_config orelse return, '\n'); while (lines.next()) |line| _ = p.executeBuiltinLine(p.active, line); } fn runBuiltin(p: *Pardes, b: Builtin, id: usize, txt: []const u8, arg: ?[]const u8) void { if (!p.multiOnce()) return; // a builtin is per-keystroke, never per-cursor const pane = p.panes[id] orelse return; const c: builtins.Ctx = .{ .p = p, .pane = pane, .id = id, .txt = txt, .arg = arg }; builtins.registry.dispatch(b, c); } pub fn applySettingBuiltin(p: *Pardes, setting: config.Runtime.Setting, arg: ?[]const u8) void { const previous_colors = p.settings.colors; const previous_context_style = p.settings.tree_context_tag_style; const previous_transition = p.settings.panel_transition; const previous_tagline_percent = p.settings.font.tagline_percent; const previous_column_tags = p.settings.column_tags; switch (setting.action) { .theme => { const want = std.mem.trim(u8, arg orelse return, " \t\r\n"); for (themes, 0..) |t, i| if (std.mem.eql(u8, t.name, want)) { p.setThemeIndex(i); return; }; }, .font => { if (comptime !font_picker) return; const spec = config.Runtime.FontSpec.parse(arg orelse return) orelse return; const matches = fonts.list(p.scratch.allocator(), spec.name); if (matches.len == 0) return; if (!p.settings.requestFont(matches[0].path, matches[0].name, spec.size_hundredths)) return; p.font_request_taken = false; }, else => _ = p.settings.apply(setting, arg), } if (p.settings.colors != previous_colors) { for (p.panes) |slot| { const pane = slot orelse continue; if (pane.file) |*file| file.syntax_dirty = true; } } if (p.settings.tree_context_tag_style != previous_context_style) { for (p.panes, 0..) |slot, id| { p.surface.body_layers[id].rows = 0; const pane = slot orelse continue; pane.body_rows = 0; pane.body_visible_rows = 0; pane.context_row_limit = null; pane.wrap_n = 0; } p.abandonPanelAnimations(); } const transition_changed = setting.action == .transition and p.settings.panel_transition != previous_transition; const tagline_metrics_changed = setting.action == .tagline_size and p.settings.font.tagline_percent != previous_tagline_percent; if (p.settings.column_tags != previous_column_tags) { if (p.header_column != null) p.exitHeader(); } if (transition_changed or tagline_metrics_changed or p.settings.column_tags != previous_column_tags) { p.abandonPanelAnimations(); } } pub fn placeDoc(p: *Pardes, from_id: usize, free: usize, nt: *Pane) void { const doc = if (nt.file) |f| panes.Output.fileTraits(f.output).doc else true; // an image is a doc var src_id: ?usize = null; if (p.panes[from_id]) |src| if (if (src.file) |f| panes.Output.fileTraits(f.output).doc else src.image != null or src.hasPdf()) { src_id = from_id; }; if (doc and src_id == null) { var n = p.njumps; while (n > 0) : (n -= 1) { const j = p.jumps[n - 1]; const pp = p.panes[j.pane] orelse continue; if (pp.serial != j.serial) continue; if (if (pp.file) |f| panes.Output.fileTraits(f.output).doc else pp.image != null or pp.hasPdf()) { src_id = j.pane; break; } } } if (doc and src_id == null) for (p.panes, 0..) |sl, i| { if (sl) |pp| if (i != free and (if (pp.file) |f| panes.Output.fileTraits(f.output).doc else pp.image != null or pp.hasPdf())) { src_id = i; break; }; }; if (!doc) if (layout.findPane(p, from_id)) |sf| { layout.insert(p, sf.col, sf.idx + 1, free); layout.splitBelow(p, from_id, nt); // NOT splitParent: no bystander pays p.active = free; return; }; if (src_id == null and doc and p.ncol < MAX_COLS and layout.columnFitsHalves(p, from_id, 100) and layout.splitColumn(p, from_id, free, true)) { p.active = free; return; } // Without room for a first-document column, split the Look origin // just like Tty. Never displace (or evict) a top-left bystander. const src = layout.splitParent(p, src_id orelse from_id); const sf = layout.findPane(p, src).?; layout.insert(p, sf.col, sf.idx + 1, free); layout.splitBelow(p, src, nt); p.active = free; } // ---- dump / load (acme-style: `pardes -l state.zon` restores a session) ---- pub fn dumpState(p: *Pardes) !void { const arena = p.scratch.allocator(); var slot_to_pane: [MAX_PANES]?usize = @splat(null); var dump_panes: [MAX_PANES]dump.Pane = undefined; var panes_len: usize = 0; for (p.panes, 0..) |slot, id| { const pane = slot orelse continue; slot_to_pane[id] = panes_len; const tag = try p.tagText(arena, pane); const body = try p.bodyText(arena, pane); const scroll: usize = @intCast(@max(0, pane.scroll())); var dp: dump.Pane = if (pane.file) |*f| try panes.File.dumpPane( arena, pane, f, tag, body, scroll, if (f.output) |o| panes.Output.word(o.from) else "", if (f.output) |*o| o.arg() else "", ) else if (pane.hasPdf()) blk: { if (comptime !pdf_enabled) unreachable; const pv = &pane.pdf.?; break :blk try panes.Image.dumpPane(p, arena, pane, tag, body, pv.page, pv.path, &.{}); } else if (pane.image) |iv| try panes.Image.dumpPane(p, arena, pane, tag, body, scroll, iv.path, iv.raw) else try panes.Terminal.dumpPane(pane, arena, tag, body, scroll); dp.tag_tail = if (pane.tag_init) pane.tagSlice() else null; dump_panes[panes_len] = dp; panes_len += 1; } var columns: [MAX_COLS]dump.Column = undefined; var columns_len: usize = 0; var column_ids: [MAX_COLS][MAX_PANES]usize = undefined; for (0..p.ncol) |c| { var ids_len: usize = 0; for (0..p.col_n[c]) |k| if (slot_to_pane[p.col_panes[c][k]]) |compact| { column_ids[c][ids_len] = compact; ids_len += 1; }; if (ids_len > 0) { columns[columns_len] = .{ .tag = if (p.col_tags[c].custom) p.columnTag(c) else null, .weight = @as(f32, @floatFromInt(p.col_weight[c])) / @as(f32, @floatFromInt(column_weight_unit)), .panes = column_ids[c][0..ids_len], }; columns_len += 1; } } var mounts: [filesystem.max_mounts]dump.Mount = undefined; for (p.fs.mounts.items, 0..) |mount, i| mounts[i] = .{ .name = mount.name, .dial = mount.dial }; const state: dump.State = .{ .screen = .{ .cols = p.screen_w, .rows = p.screen_h }, .active = slot_to_pane[p.active] orelse 0, .topbar = config.topbar_str, .topbar_custom = if (p.global_tag.custom) p.global_tag.slice("") else null, .theme = p.theme().name, .locations_config = p.locations_config, .tree_context_tag_style = p.settings.tree_context_tag_style, .columns = columns[0..columns_len], .panes = dump_panes[0..panes_len], .mounts = mounts[0..p.fs.mounts.items.len], }; try dump.validate(state); var out: std.Io.Writer.Allocating = .init(p.gpa); errdefer out.deinit(); try std.zon.stringify.serialize(state, .{ .whitespace = true }, &out.writer); if (p.dump_out) |d| p.gpa.free(d); p.dump_out = try out.toOwnedSlice(); p.emit(.write_dump); } pub fn restore(p: *Pardes, zon_bytes: []const u8) !*Pardes { var opts = p.opts; opts.cols = p.screen_w; opts.rows = p.screen_h; return initDump(p.gpa, opts, zon_bytes, p); } pub fn initFromDump(gpa: std.mem.Allocator, opts: Options, zon_bytes: []const u8) !*Pardes { return initDump(gpa, opts, zon_bytes, null); } fn initDump(gpa: std.mem.Allocator, opts: Options, zon_bytes: []const u8, previous: ?*const Pardes) !*Pardes { const image_gpa = opts.image_allocator orelse gpa; const pdf_gpa = opts.pdf_allocator orelse gpa; const tree_sitter_gpa = opts.tree_sitter_allocator orelse gpa; const p = try gpa.create(Pardes); p.* = .{ .gpa = gpa, .image_gpa = image_gpa, .pdf_gpa = pdf_gpa, .tree_sitter_gpa = tree_sitter_gpa, .opts = opts, .fs = .{ .socket_path = opts.ninep_identity.socket_path, .tcp_address = opts.ninep_identity.tcp_address, .quic_address = opts.ninep_identity.quic_address, }, .screen_w = opts.cols, .screen_h = opts.rows, .scratch = .init(gpa), .frame_arena = .init(opts.frame_allocator orelse gpa), .fallback = .{ .gpa = gpa }, }; errdefer p.deinit(); if (previous) |old| { p.next_serial = old.next_serial; p.lsp_seq = old.lsp_seq; p.pipe_seq = old.pipe_seq; p.cell_pixels = old.cell_pixels; p.row_metrics = old.row_metrics; p.native_images = old.native_images; p.fs.socket_path = old.fs.socket_path; p.fs.tcp_address = old.fs.tcp_address; p.fs.quic_address = old.fs.quic_address; } for (opts.mounts) |mount| try p.fs.mount(gpa, mount.name, mount.dial); p.opts.mounts = &.{}; p.opts.ninep_identity = .{}; var parsed = try dump.readZon(gpa, zon_bytes, "load"); defer parsed.deinit(); const st = parsed.value; p.locations_config = st.locations_config; p.settings.tree_context_tag_style = st.tree_context_tag_style; for (st.mounts) |mount| { var already_mounted = false; for (p.fs.mounts.items) |existing| { if (!std.mem.eql(u8, mount.name, existing.name)) continue; if (!std.mem.eql(u8, mount.dial, existing.dial)) return error.MountConflict; already_mounted = true; break; } if (!already_mounted) try p.fs.mount(gpa, mount.name, mount.dial); } for (themes, 0..) |t, i| { if (std.mem.eql(u8, t.name, st.theme)) p.settings.theme = @intCast(i); } for (st.panes, 0..) |src, i| { if (i >= MAX_PANES) break; const pane: *Pane = switch (src.kind) { .terminal => terminal: { const t = src.terminal.?; const restored = try panes.Terminal.restore(p, src); p.installPane(i, restored); try restored.setOwnedCwd(t.cwd); if (std.mem.startsWith(u8, src.tag, "TTY ")) restored.mode = .tty; break :terminal restored; }, .file => try panes.File.restore(p, i, src), .image => restore_image: { const im = src.image.?; if (panes.Pdf.isPath(im.path)) { var raw: []u8 = if (im.bytes_b64.len > 0) try dump.decodeBytes(gpa, im.bytes_b64) else &.{}; errdefer if (raw.len > 0) gpa.free(raw); if (comptime pdf_enabled) { if (panes.Pdf.openPane(p, i, im.path, src.scroll + 1) catch null) |restored| { if (raw.len > 0) gpa.free(raw); raw = &.{}; restored.cols = @max(1, src.cols); restored.rows = @max(1, src.rows); break :restore_image restored; } } // If the source path is unavailable, retain its bytes // as an ordinary file rather than dropping the pane. const path = try gpa.dupe(u8, im.path); errdefer gpa.free(path); const content = if (raw.len > 0) raw else try gpa.dupe(u8, ""); raw = &.{}; errdefer if (content.len > 0) gpa.free(content); const history = try panes.File.History.create(gpa); errdefer gpa.destroy(history); const restored = try p.newDocPane(i); restored.file = .{ .path = path, .content = content, .history = history }; restored.cur_pinned = true; restored.cols = @max(1, src.cols); restored.rows = @max(1, src.rows); p.emit(.{ .watch = .{ .pane = @intCast(i), .on = true, .mode = .baseline_disk } }); break :restore_image restored; } break :restore_image try panes.Image.restore(p, i, src); }, }; p.restoreDumpTail(pane, src); pane.vweight = src.vweight; pane.collapsed = src.collapsed; } p.ncol = @min(st.columns.len, MAX_COLS); p.col_serial = @splat(0); if (st.topbar_custom) |bar| _ = p.global_tag.set(bar); for (st.columns[0..p.ncol], 0..) |col, c| { if (col.tag) |bar| _ = p.col_tags[c].set(bar); const scaled = @as(f64, @floatCast(col.weight)) * @as(f64, @floatFromInt(column_weight_unit)); const bounded = @min(scaled, @as(f64, @floatFromInt(max_column_weight))); p.col_weight[c] = @max(1, @as(u64, @intFromFloat(@round(bounded)))); p.col_n[c] = @min(col.panes.len, MAX_PANES); for (col.panes[0..p.col_n[c]], 0..) |pid, k| p.col_panes[c][k] = pid; } p.active = @min(st.active, MAX_PANES - 1); p.sync(); p.applyStartupConfig(); p.finishThemeInitialization(); p.sync(); p.presentation.enabled = true; _ = p.takeHaptic(); // see init: a restored session is not a gesture return p; } fn restoreDumpTail(p: *Pardes, pane: *Pane, src: dump.Pane) void { if (src.tag_tail) |tail| { if (!pane.appendTag(tail)) return; pane.tag_init = true; return; } const tail_class = tailClass(pane); if (src.kind == .image and panes.Pdf.isPath(src.image.?.path)) { if (panes.Pdf.legacySavedPrefix(src.image.?.path, src.tag)) |prefix| return p.restoreTailAt(pane, src.tag, prefix, .generic); } if (src.kind == .terminal and std.mem.startsWith(u8, src.tag, "TTY ")) { const legacy = std.fmt.allocPrint(p.scratch.allocator(), "TTY {s}", .{src.terminal.?.cwd}) catch return; if (std.mem.startsWith(u8, src.tag, legacy)) return p.restoreTailAt(pane, src.tag, legacy, .terminal); } const current = p.tagPrefix(pane) catch return; if (std.mem.startsWith(u8, src.tag, current)) return p.restoreTailAt(pane, src.tag, savedPrefix(src.tag, current, tail_class), tail_class); if (src.kind != .image) return; const saved = src.image.?; if (pane.image) |*state| if (panes.Image.legacySavedPrefix(state, src.tag)) |legacy| return p.restoreTailAt(pane, src.tag, legacy, .generic); if (panes.Pdf.isPath(saved.path)) if (panes.Pdf.legacySavedPrefix(saved.path, src.tag)) |legacy| p.restoreTailAt(pane, src.tag, legacy, .generic); } fn restoreTail(p: *Pardes, pane: *Pane, saved_tag: []const u8) void { const pfx = p.tagPrefix(pane) catch return; const class = tailClass(pane); p.restoreTailAt(pane, saved_tag, savedPrefix(saved_tag, pfx, class), class); } const TailClass = enum { generic, file, output, terminal }; fn tailClass(pane: *const Pane) TailClass { if (pane.file) |f| return if (f.output == null) .file else .output; if (pane.isTerminal()) return .terminal; return .generic; } fn savedPrefix(saved_tag: []const u8, live: []const u8, class: TailClass) []const u8 { if (class == .generic or class == .terminal) return live; if (!std.mem.startsWith(u8, saved_tag, live)) return live; const marked_len = live.len + dirty_marker.len; if (saved_tag.len >= marked_len and std.mem.eql(u8, saved_tag[live.len..marked_len], dirty_marker)) return saved_tag[0..marked_len]; return live; } fn restoreTailAt( _: *Pardes, pane: *Pane, saved_tag: []const u8, pfx: []const u8, class: TailClass, ) void { if (!std.mem.startsWith(u8, saved_tag, pfx)) return; const rest = saved_tag[pfx.len..]; const said = std.mem.trimStart(u8, rest, " "); const defaults: []const []const u8 = switch (class) { .generic => &.{ pdf_pane_tail, pane_tail, pre_collapse_pdf_pane_tail, pre_collapse_pane_tail, pre_column_pdf_pane_tail, pre_column_pane_tail, prev_pane_tail, legacy_pane_tail }, .file => &.{ context_pane_tail, file_pane_tail, pre_collapse_file_pane_tail, pre_column_file_pane_tail, prev_file_pane_tail, legacy_file_pane_tail }, .output => &.{ locations_pane_tail, file_pane_tail, prev_file_pane_tail, legacy_file_pane_tail, pane_tail, prev_pane_tail, legacy_pane_tail, pre_column_file_pane_tail, pre_column_pane_tail, pre_collapse_file_pane_tail, pre_collapse_pane_tail, }, .terminal => &.{ terminal_pane_tail, pre_tty_first_terminal_pane_tail, pre_collapse_terminal_pane_tail, pre_column_terminal_pane_tail, pre_tty_terminal_pane_tail, prev_terminal_pane_tail, legacy_terminal_pane_tail, pane_tail, pre_collapse_pane_tail, pre_column_pane_tail, prev_pane_tail, legacy_pane_tail }, }; for (defaults) |default| if (std.mem.eql(u8, said, std.mem.trimStart(u8, default, " "))) return; if (!pane.appendTag(rest)) return; pane.tag_init = true; } pub fn removePane(p: *Pardes, id: usize) !void { const pane = p.panes[id] orelse return; try p.deinitPane(pane); layout.absorbVWeight(p, id); layout.removePane(p, id); p.panes[id] = null; if (p.active == id) p.active = p.prevFocus(id) orelse { p.quit = true; p.emit(.quit); return; }; } pub fn removeColumn(p: *Pardes, id: usize) !void { const place = layout.findPane(p, id) orelse return; var ids: [MAX_PANES]usize = undefined; var parents: [MAX_PANES]*Pane = undefined; const count = p.col_n[place.col]; for (0..count) |i| { ids[i] = p.col_panes[place.col][i]; parents[i] = p.panes[ids[i]].?; } try p.detachCwds(parents[0..count]); for (ids[0..count], parents[0..count]) |closed, pane| { p.retirePane(pane); layout.removePane(p, closed); p.panes[closed] = null; } if (p.panes[p.active] == null) p.active = p.prevFocus(p.active) orelse { p.quit = true; p.emit(.quit); return; }; } pub fn prevFocus(p: *Pardes, closing: usize) ?usize { var i = p.njumps; while (i > 0) { i -= 1; const id = p.jumps[i].pane; if (id != closing and p.panes[id] != null) return id; } for (p.panes, 0..) |slot, k| { if (slot != null and k != closing) return k; } return null; } fn samePlace(p: *const Pardes, a: Loc, b: Loc) bool { if (a.pane != b.pane or a.serial != b.serial) return false; if (a.line == 0 or b.line == 0) return true; const body = @max(1, p.rects[b.pane].h -| BOX_H); return @max(a.line, b.line) - @min(a.line, b.line) <= body; } fn trackJump(p: *Pardes) void { var w: usize = 0; var cur: usize = 0; for (p.jumps[0..p.njumps], 0..) |j, i| { if (i == p.jcur) cur = w; // survives -> lands at w; dies -> the next one does const pane = p.panes[j.pane] orelse continue; if (pane.serial != j.serial) continue; p.jumps[w] = j; w += 1; } p.njumps = w; p.jcur = @min(cur, w -| 1); const pane = p.panes[p.active] orelse return; // After a history arrival, an unchanged selection head still belongs // to the selection, not to the location we just showed. if (p.jump_selection_cursor) |saved| { if (saved.pane == p.active and saved.serial == pane.serial and saved.row == pane.cur_row and saved.col == pane.cur_col and (pane.vsel.active or pane.msel.active or pane.nsel > 0)) return; p.jump_selection_cursor = null; } const now: Loc = .{ .pane = @intCast(p.active), .serial = pane.serial, .line = if (pane.pdfPage()) |page| @intCast(page + 1) else if (pane.cur_pinned) @intCast(@max(0, pane.cur_row) + 1) else 0, .col = if (pane.pdfPage() != null) 0 else if (pane.cur_pinned) @intCast(@max(0, pane.cur_col) + 1) else 0, }; if (p.njumps > 0 and p.samePlace(p.jumps[p.jcur], now)) { p.jumps[p.jcur] = now; // same visit; the entry IS where you are return; } // a new jump made from the middle of the list drops everything ahead of // it, the way vim's does: the future you did not take is not history. if (p.njumps > 0) p.njumps = p.jcur + 1; if (p.njumps >= 2 and p.samePlace(p.jumps[p.njumps - 2], now)) { p.jumps[p.njumps - 2] = p.jumps[p.njumps - 1]; p.jumps[p.njumps - 1] = now; p.jcur = p.njumps - 1; return; } if (p.njumps == MAX_JUMPS) { std.mem.copyForwards(Loc, p.jumps[0 .. MAX_JUMPS - 1], p.jumps[1..]); p.njumps -= 1; } p.jumps[p.njumps] = now; p.jcur = p.njumps; p.njumps += 1; } pub fn jumpBy(p: *Pardes, delta: i32) void { const next = @as(i64, @intCast(p.jcur)) + delta; if (p.njumps == 0 or next < 0 or next >= p.njumps) return; p.jcur = @intCast(next); const j = p.jumps[p.jcur]; const pane = p.panes[j.pane] orelse return; if (pane.serial != j.serial) return; // A modal selection stores its head in the cursor. History changes // the viewed location, but must not stretch that selection to it. for (0..pane.sel.len) |slot| p.capturePointerSelection(pane, slot) catch return; const preserve_cursor = pane.vsel.active or pane.msel.active or pane.nsel > 0; const row = pane.cur_row; const col = pane.cur_col; const pinned = pane.cur_pinned; p.focusPaneLine(j.pane, .{ .line = j.line, .col = j.col }, .center); p.jump_selection_cursor = null; if (preserve_cursor) { p.jump_selection_cursor = .{ .pane = j.pane, .serial = pane.serial, .row = row, .col = col }; pane.cur_row = row; pane.cur_col = col; pane.cur_pinned = pinned; } } /// Recompute geometry, push grid-size changes to each emulator + pty, fire /// deferred greetings. The mirror of the prototype's loop epilogue. fn sync(p: *Pardes) void { if (p.header_column != null and p.columnBarHeight() == 0) { p.exitHeader(); } p.reapPanes(); layout.compute(p); p.presentation.sync(p); p.trackJump(); for (&p.panes, 0..) |*slot, id| { const pane = slot.* orelse continue; if (pane.tag_edit and pane.prompt == .none and (id != p.active or p.topbar_col != null)) exitTagEdit(pane); if (comptime terminal_panes) if (pane.terminal) |state| { var off: u16 = 0; while (off < state.reply_len) { const n = @min(state.reply_len - off, 64); p.emit(.{ .write = .{ .pane = @intCast(id), .bytes = .from(state.reply[off .. off + n]) } }); off += n; } state.reply_len = 0; }; const r = p.rects[id]; const cols = @max(1, r.w -| config.GUTTER); const rows = @max(1, r.h -| BOX_H); // the tag steals the top row if ((cols != pane.cols or rows != pane.rows) and r.h > BOX_H) { // doc panes have no pty/emulator grid to reflow; just record // the size so bodyText renders the right number of rows if (pane.isTerminal()) { panes.Terminal.resizeGrid(pane, p.gpa, cols, rows); p.shell_rows.markStale(pane); // reflow moved every row p.emit(.{ .resize_pty = .{ .pane = @intCast(id), .cols = cols, .rows = rows } }); } pane.cols = cols; pane.rows = rows; if (pane.file) |*file| file.syntax_dirty = true; } panes.Terminal.releasePendingCommandIfReady(p, id, pane); if (pane.greet and pane.isTerminal() and p.resize_count > 0 and panes.Terminal.promptInputReady(pane)) { p.emit(.{ .resize_pty = .{ .pane = @intCast(id), .cols = pane.cols, .rows = pane.rows } }); p.emit(.{ .write = .{ .pane = @intCast(id), .bytes = .from("ls\r") } }); pane.greet = false; } } } pub fn theme(p: *const Pardes) *const Theme { if (p.custom_theme) |*custom| return custom; return &themes[p.settings.theme]; } /// Displayed colors for anchored UI chrome. Unlike `theme()`, this may be /// between themes while a live transition is active. pub fn chromeTheme(p: *const Pardes) *const ChromeTheme { return &p.chrome_animation.displayed; } /// Whether an idle frontend should schedule another tick. All frame-based /// core work belongs in this one predicate so TTY and GUI mature it alike. pub fn animationActive(p: *const Pardes) bool { if (p.chrome_animation.isActive() or p.look_hover_wait != null) return true; const scene = p.settings.scene_effects; if (scene.crt or scene.ripple or scene.glitch) return true; return p.presentation.animating(); } fn noteHaptic(p: *Pardes, pulse: Haptic) void { if (comptime platform != .macos) return; if (p.haptic == .look) return; p.haptic = pulse; } /// Take the armed pulse and disarm. The shell calls this once per pump, /// after the tick that may have set it. pub fn takeHaptic(p: *Pardes) Haptic { if (comptime platform != .macos) return .none; defer p.haptic = .none; return p.haptic; } fn finishThemeInitialization(p: *Pardes) void { p.chrome_animation.snap(ChromeTheme.fromTheme(p.theme())); p.animate_theme_changes = true; } fn invalidateThemeDependentRasters(p: *Pardes) void { if (comptime pdf_enabled) for (p.panes) |slot| { const pane = slot orelse continue; const pv = &(pane.pdf orelse continue); if (pv.tint != .disabled) pv.invalidateAllRasters(); }; } pub fn setThemeIndex(p: *Pardes, index: usize) void { if (index >= themes.len or (p.custom_theme == null and p.theme_file_path.get().len == 0 and index == @as(usize, p.settings.theme))) return; const target_chrome = ChromeTheme.fromTheme(&themes[index]); if (p.animate_theme_changes) p.chrome_animation.retarget(target_chrome) else p.chrome_animation.snap(target_chrome); if (p.custom_theme) |theme_value| { p.custom_theme = null; std.zon.parse.free(p.gpa, theme_value); } if (p.theme_file_path.get().len > 0) { p.theme_file_path.clear(); const generation = p.nextThemeFileGeneration(); p.emit(.{ .theme_file = .{ .generation = generation, .on = false } }); } p.settings.theme = @intCast(index); p.invalidateThemeDependentRasters(); } pub fn reprojectTagHit(p: *const Pardes, tag_hit: TagHit) ?TagHit { for (p.surface.tagLayers()) |*layer| { if (layer.cols == 0 or layer.kind != tag_hit.kind or layer.id != tag_hit.id or layer.serial != tag_hit.serial) continue; if (layer.kind == .pane) { if (layer.id >= p.panes.len) return null; const pane = p.panes[layer.id] orelse return null; if (pane.serial != layer.serial) return null; } if (layer.kind == .column and (layer.id >= p.ncol or p.col_serial[layer.id] != layer.serial)) return null; return layer.hitAt(tag_hit.pixel_x, tag_hit.pixel_y, @floatFromInt(tag_hit.metrics.body_w), @floatFromInt(tag_hit.metrics.body_h), @floatFromInt(tag_hit.metrics.tagline_w), @floatFromInt(tag_hit.metrics.tagline_h)); } return null; } fn tagCapacity(p: *const Pardes, physical_width: u16) u16 { const layer: TagLayer = .{ .viewport = .{ .x = 0, .y = 0, .w = physical_width, .h = 1 } }; return layer.capacity(p.row_metrics); } pub fn reprojectBodyHit(p: *const Pardes, body_hit: Mouse.BodyHit) ?Mouse.BodyHit { for (&p.surface.body_layers) |*layer| { if (layer.rows == 0 or layer.serial != body_hit.serial) continue; return layer.hitAt(body_hit.pixel_x, body_hit.pixel_y, @floatFromInt(body_hit.metrics.body_w), @floatFromInt(body_hit.metrics.body_h), @floatFromInt(body_hit.metrics.tagline_w), @floatFromInt(body_hit.metrics.tagline_h)); } return null; } // ---- render: build the canonical surface ---- pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { panes.File.refreshHighlights(p); const s = &p.surface; const ncells = @as(usize, p.screen_w) * p.screen_h; if (s.cells.len != ncells) { const cells = try p.gpa.alloc(Cell, ncells); p.gpa.free(s.cells); s.cells = cells; } s.cols = p.screen_w; s.rows = p.screen_h; s.cursor = null; s.pointer_shape = .arrow; if (comptime pdf_enabled) if (p.pdf_hover_preview) |preview| { if (preview.probe.link != null and preview.pane < p.panes.len) { if (p.panes[preview.pane]) |pane| { if (pane.serial == preview.serial and panes.Pdf.paneNativeReady(p, pane)) s.pointer_shape = .link; } } }; for (&s.body_layers) |*layer| layer.rows = 0; for (&s.tag_layers) |*layer| layer.cols = 0; s.nimages = 0; s.npanel_tracks = 0; s.previous_cells = &.{}; s.previous_body_layers = &.{}; s.previous_tag_layers = &.{}; s.cell_diffs = &.{}; const chrome = p.chromeTheme(); const th = p.theme(); // the message row paints in the editor's colours // Separators have their own role; a scrollbar need not frame the page. s.fill(0, 0, s.cols, s.rows, .{ .bg = .{ .rgb = chrome.border } }); for (&p.panes, 0..) |*slot, id| { const pane = slot.* orelse continue; pane.body_rows = 0; pane.body_visible_rows = 0; pane.context_row_limit = null; s.body_layers[id].rows = 0; try p.renderPane(arena, pane, p.rects[id], id, id == p.active); try p.renderBodyLayer(arena, pane, p.rects[id], id); } for (&p.panes, 0..) |*slot, id| { const pane = slot.* orelse continue; const r = p.rects[id]; if (r.w <= config.GUTTER or r.h <= BOX_H) continue; const prompt_at = pane.promptAt(); const text = if (prompt_at) |at| pane.tagSlice()[@min(at, pane.tag_tail_len)..] else pane.msg[0..pane.msg_len]; // The pending SPC path is the third thing that wants this row, and // it can want it while the row is otherwise empty. const leader_here = id == p.active and p.leader_on; if (text.len == 0 and !leader_here) continue; const tx = r.x + config.GUTTER; const tw = r.w - config.GUTTER; const row = if (p.settings.tag_bottom) r.y + r.h - 1 - BOX_H else r.y + r.h - 1; const msg_style: CellStyle = .{ .fg = if (th.fg) |c| .{ .rgb = c } else .default, .bg = if (th.bg) |c| .{ .rgb = c } else .default, }; s.fill(tx, row, tw, 1, .{ .bg = msg_style.bg }); if (text.len > 0) _ = s.print(tx, row, tw, text, msg_style); if (leader_here) { var ibuf: [16]u8 = @splat(' '); @memcpy(ibuf[1..4], "SPC"); var iw: usize = 4; for (p.leader_keys[0..p.leader_n]) |ch| { ibuf[iw + 1] = ch; iw += 2; } const w: u16 = @intCast(iw); if (w < tw) _ = s.print(tx + tw - w, row, w, ibuf[0..iw], msg_style); } if (id != p.active) continue; const at = prompt_at orelse continue; const prompt0 = (p.tagPrefix(pane) catch continue).len + at; const col = @as(usize, pane.tag_col); if (col >= prompt0) { const prompt_col = panes.File.displayWidth(text[0..@min(col - prompt0, text.len)]); if (prompt_col < tw) s.cursor = .{ .x = tx + @as(u16, @intCast(prompt_col)), .y = row, .bar = pane.mode == .insert }; } } // global tagbar: full width, top row p.revealHeader(); s.fill(0, 0, s.cols, TOPBAR_H, .{ .bg = .{ .rgb = chrome.tag_bg }, .font_role = .tagline, }); var tb_buf: [1200]u8 = undefined; const global_bar = p.topbar(&tb_buf); const global_scroll = if (p.topbar_col != null and p.header_column == null) p.header_scroll else 0; _ = s.print(0, 0, s.cols, global_bar[panes.File.rawAtDisplay(global_bar, global_scroll)..], .{ .fg = .{ .rgb = chrome.tag_fg }, .bg = .{ .rgb = chrome.tag_bg }, .font_role = .tagline, }); if (p.pointer_inside and p.hover_row < TOPBAR_H) { const bar = p.topbar(&tb_buf); if (wordBoundsAtCol(bar, panes.File.rawAtDisplay(bar, p.hover_col + global_scroll))) |bounds| { var col = panes.File.rawDisplayCol(bar, bounds.lo) -| global_scroll; const hi = panes.File.rawDisplayCol(bar, bounds.hi) -| global_scroll; while (col < hi and col < s.cols) : (col += 1) { const cell = s.at(@intCast(col), 0); cell.default = false; cell.style.bg = .{ .rgb = th.sel_bg }; cell.style.fg = .{ .rgb = th.sel_fg }; } } } if (p.topbar_col) |c| if (p.header_column == null) { const col = panes.File.rawDisplayCol(p.topbar(&tb_buf), c) -| global_scroll; if (col < s.cols) s.cursor = .{ .x = @intCast(col), .y = 0, .bar = p.header_insert }; }; if (p.columnBarHeight() > 0) for (0..p.ncol) |c| { const grip = @min(config.GUTTER, p.col_w[c]); const x = p.col_x[c] + grip; const w = p.col_w[c] - grip; if (p.col_w[c] == 0 or p.col_x[c] >= s.cols) continue; const active_col = if (layout.findPane(p, p.active)) |f| f.col == c else false; const bg = if (active_col and p.settings.focus_tint) chrome.tag_active_bg else chrome.tag_bg; const fg = if (active_col and p.settings.focus_tint) chrome.tag_active_fg else chrome.tag_fg; const grip_bg = if (active_col) chrome.column_box else chrome.column_box_dim; const lum = (@as(u16, grip_bg[0]) * 3 + @as(u16, grip_bg[1]) * 6 + @as(u16, grip_bg[2])) / 10; const ink: [3]u8 = if (lum > 140) .{ 0, 0, 0 } else .{ 255, 255, 255 }; const grip_style: CellStyle = .{ .bg = .{ .rgb = grip_bg }, .fg = .{ .rgb = ink }, .font_role = .tagline }; s.fill(p.col_x[c], TOPBAR_H, grip, 1, grip_style); s.fill(x, TOPBAR_H, w, 1, .{ .bg = .{ .rgb = bg }, .font_role = .tagline }); const bar_scroll = if (p.topbar_col != null and p.header_column == c) p.header_scroll else 0; const tag = p.columnTag(c); _ = s.print(x, TOPBAR_H, w, tag[panes.File.rawAtDisplay(tag, bar_scroll)..], .{ .fg = .{ .rgb = fg }, .bg = .{ .rgb = bg }, .font_role = .tagline, }); if (p.pointer_inside and p.hover_row == TOPBAR_H and p.hover_col >= x and p.hover_col < x + w) { const bar = p.columnTag(c); if (wordBoundsAtCol(bar, panes.File.rawAtDisplay(bar, p.hover_col - x + bar_scroll))) |bounds| { var col = panes.File.rawDisplayCol(bar, bounds.lo) -| bar_scroll; const hi = panes.File.rawDisplayCol(bar, bounds.hi) -| bar_scroll; while (col < hi and col < w) : (col += 1) { const cell = s.at(x + @as(u16, @intCast(col)), TOPBAR_H); cell.style.bg = .{ .rgb = th.sel_bg }; cell.style.fg = .{ .rgb = th.sel_fg }; } } } }; if (p.topbar_col) |cur| { const bar = p.headerText(); const x = if (p.header_column) |c| p.col_x[c] + config.GUTTER else 0; const w = if (p.header_column) |c| p.col_w[c] -| config.GUTTER else s.cols; const y: u16 = if (p.header_column != null) TOPBAR_H else 0; const anchor = p.header_anchor orelse cur; var col = panes.File.rawDisplayCol(bar, @min(cur, anchor)) -| p.header_scroll; const hi = panes.File.rawDisplayCol(bar, @max(cur, anchor)) -| p.header_scroll; while (col < hi and col < w) : (col += 1) { const cell = s.at(x + @as(u16, @intCast(col)), y); cell.style.fg = .{ .rgb = th.sel_fg }; cell.style.bg = .{ .rgb = th.sel_bg }; } const caret = panes.File.rawDisplayCol(bar, @min(cur, bar.len)) -| p.header_scroll; if (caret < w) s.cursor = .{ .x = x + @as(u16, @intCast(caret)), .y = y, .bar = p.header_insert }; } try p.renderTagLayers(arena); switch (p.drag) { .border_v => |d| { if (d.cur_x < s.cols) { var row: u16 = TOPBAR_H; while (row < s.rows) : (row += 1) s.overlayDash(d.cur_x, row, "╎"); } if (d.corner) |k| if (d.cur_y < s.rows) { var col = if (k.col == d.left_col) p.col_x[k.col] else d.cur_x +| 1; const end = if (k.col == d.left_col) d.cur_x else p.col_x[k.col] +| p.col_w[k.col] -| 1; while (col <= end and col < s.cols) : (col += 1) s.overlayDash(col, d.cur_y, "╌"); }; }, .border_h => |d| if (d.cur_y < s.rows) { var col = p.col_x[d.col]; while (col < p.col_x[d.col] + p.col_w[d.col]) : (col += 1) s.overlayDash(col, d.cur_y, "╌"); }, .column_move => |d| { const source = layout.columnBySerial(p, d.serial) orelse d.column; const target = layout.columnMoveTarget(p, source, d.cur_x); if (source < p.ncol and target != source) { const boundary = if (target < source) p.col_x[target] else p.col_x[target] + p.col_w[target]; const x = @min(boundary, s.cols -| 1); // Leave the outside insertion rail visible above compact // text layers. This clips presentation only, never reflows. if (boundary == s.cols) { for (&s.body_layers) |*layer| if (layer.rows > 0 and layer.viewport.x + layer.viewport.w == s.cols) { layer.viewport.w -|= 1; }; for (&s.tag_layers) |*layer| if (layer.cols > 0 and layer.viewport.x + layer.viewport.w == s.cols and layer.viewport.y >= TOPBAR_H) { layer.viewport.w -|= 1; }; } var row: u16 = TOPBAR_H; while (row < s.rows) : (row += 1) s.overlayDash(x, row, "╎"); } }, .move => |d| if (d.cur_x < s.cols) { if (layout.movePlacement(p, d.id, d.cur_x, d.cur_y)) |placement| { var col: u16 = p.col_x[placement.preview_col]; while (col < p.col_x[placement.preview_col] + p.col_w[placement.preview_col]) : (col += 1) { s.set(col, placement.row, "╌", .{ .fg = .{ .rgb = chrome.lineno } }); } s.set(d.cur_x, placement.row, "▌", .{ .fg = .{ .rgb = chrome.lineno } }); } }, .none, .select, .tag => if (p.pointer_inside) { // hover hints on a pane's own trailing edge var c: usize = 0; while (c + 1 < p.ncol) : (c += 1) { if (p.hover_col == p.col_x[c] + p.col_w[c] -| 1) { var row: u16 = TOPBAR_H; while (row < s.rows) : (row += 1) s.overlayDash(p.hover_col, row, "╎"); } } for (0..p.ncol) |cc| { if (p.hover_col < p.col_x[cc] or p.hover_col >= p.col_x[cc] + p.col_w[cc]) continue; // the same seam the h-handle hit test picks (see there) if (p.seamIdxAt(cc, p.hover_row) != null) { var col = p.col_x[cc]; while (col < p.col_x[cc] + p.col_w[cc]) : (col += 1) s.overlayDash(col, p.hover_row, "╌"); } } for (0..p.ncol -| 1) |cn| { if (p.hover_col != p.col_x[cn] + p.col_w[cn] -| 1) continue; if (p.seamIdxAt(cn, p.hover_row) != null) continue; if (p.seamIdxAt(cn + 1, p.hover_row) == null) continue; var col = p.col_x[cn + 1]; while (col < p.col_x[cn + 1] + p.col_w[cn + 1]) : (col += 1) s.overlayDash(col, p.hover_row, "╌"); } }, } // debug overlay: a small stats box drawn last, top-right ("Debug") if (p.settings.debug) if (p.panes[p.active]) |at| { var nlive: usize = 0; for (p.panes) |slot| { if (slot != null) nlive += 1; } var sb_off: usize = undefined; var sb_total: usize = undefined; if (at.file) |*f| { sb_off = f.scroll; sb_total = panes.File.nlines(p.gpa, f); } else if (at.pdfPage()) |page| { sb_off = page; sb_total = if (comptime pdf_enabled) at.pdf.?.page_count else 0; } else { const sb = panes.Terminal.scrollbar(at); sb_off = sb.offset; sb_total = sb.total; } const effective_font = p.settings.font.effective_name.get(); const font_name = if (effective_font.len == 0) "default" else effective_font; var ov_buf: [256]u8 = undefined; const text = std.fmt.bufPrint( &ov_buf, "pardes resizes={d}\ntheme {s}\nfont {s}\nterms {d} cols {d}\nactive #{d} {d}x{d}\nscroll {d}/{d}\npinch {d:6.4}\ntscroll {d:7.4}", .{ p.resize_count, p.theme().name, font_name[0..@min(font_name.len, 64)], nlive, p.ncol, p.active, at.cols, at.rows, sb_off, sb_total, p.ov_pinch_scale, p.ov_touch_scroll_delta }, ) catch ""; // wide enough for the longest theme name compiled in, which is the // only row here whose width is not ours to choose const ow: u16 = 38; const oh: u16 = 10; const ox: u16 = if (s.cols > ow) s.cols - ow else 0; if (ox + ow <= s.cols and TOPBAR_H + oh <= s.rows) { // vaxis single_rounded border ring, default style s.set(ox, TOPBAR_H, "╭", .{}); s.set(ox + ow - 1, TOPBAR_H, "╮", .{}); s.set(ox, TOPBAR_H + oh - 1, "╰", .{}); s.set(ox + ow - 1, TOPBAR_H + oh - 1, "╯", .{}); var bx = ox + 1; while (bx < ox + ow - 1) : (bx += 1) { s.set(bx, TOPBAR_H, "─", .{}); s.set(bx, TOPBAR_H + oh - 1, "─", .{}); } var by = TOPBAR_H + 1; while (by < TOPBAR_H + oh - 1) : (by += 1) { s.set(ox, by, "│", .{}); s.set(ox + ow - 1, by, "│", .{}); } s.fill(ox + 1, TOPBAR_H + 1, ow - 2, oh - 2, .{ .bg = .{ .rgb = chrome.scroll_track } }); var lines = std.mem.splitScalar(u8, text, '\n'); var ly: u16 = TOPBAR_H + 1; while (lines.next()) |line| : (ly += 1) { if (ly >= TOPBAR_H + oh - 1) break; _ = s.print(ox + 1, ly, ow - 2, line, .{ .fg = .{ .rgb = chrome.tag_fg } }); } } }; try p.presentation.submit(p, s); return p.composeAsciiTransitions(arena, s); } fn composeAsciiTransitions(p: *Pardes, arena: std.mem.Allocator, canonical: *Surface) !*Surface { _ = p; if (!canonical.hasPanelDiff()) return canonical; var presented: ?*Surface = null; for (canonical.panelTracks()) |track| { if (!track.effect.composedByCore() or track.phase == .closing) continue; const area = layout.CellArea.of(track.to); const col_end = @min(canonical.cols, area.x0 +| area.cols); const row_end = @min(canonical.rows, area.y0 +| area.rows); var row: u16 = area.y0; while (row < row_end) : (row += 1) { var col: u16 = area.x0; while (col < col_end) : (col += 1) { const index = @as(usize, row) * canonical.cols + col; const composed = composedCell(canonical, track, area, col, row, index) orelse continue; if (composed.visuallyEqual(&canonical.cells[index])) continue; if (presented == null) { const next = try arena.create(Surface); next.* = canonical.*; next.cells = try arena.dupe(Cell, canonical.cells); presented = next; } presented.?.cells[index] = composed; } } } return presented orelse canonical; } fn composedCell( canonical: *const Surface, track: layout.Track, area: layout.CellArea, col: u16, row: u16, index: usize, ) ?Cell { if (track.effect == .ascii) { const diff = switch (canonical.cell_diffs[index]) { .ascii => |diff| diff, .unchanged, .visual => return null, }; const byte = diff.byteAt(track.frame); if (byte == diff.to) return null; if (track.frame == 0) return canonical.previous_cells[index]; return withByte(canonical.cells[index], byte); } return switch (layout.charSource(track, col - area.x0, row - area.y0, area)) { .old => canonical.previous_cells[index], .byte => |byte| withByte(canonical.cells[index], byte), // Churn belongs on the glyph, not on the pane's empty space, and it // borrows whichever side of the diff actually has one. .churn => |byte| blk: { const new_cell = &canonical.cells[index]; if (hasGlyph(new_cell)) break :blk withByte(new_cell.*, byte); const old_cell = &canonical.previous_cells[index]; if (hasGlyph(old_cell)) break :blk withByte(old_cell.*, byte); break :blk old_cell.*; }, .at => |offset| blk: { const source_col = @as(i32, col) + offset.cols; const source_row = @as(i32, row) + offset.rows; // A glyph whose source lies outside the pane has not arrived // yet: the frozen old cell is the only truthful thing under it. if (source_col < area.x0 or source_row < area.y0 or source_col >= @as(i32, area.x0) + area.cols or source_row >= @as(i32, area.y0) + area.rows or source_col >= canonical.cols or source_row >= canonical.rows) break :blk canonical.previous_cells[index]; const source = @as(usize, @intCast(source_row)) * canonical.cols + @as(usize, @intCast(source_col)); break :blk canonical.cells[source]; }, }; } fn withByte(cell: Cell, byte: u8) Cell { var out = cell; out.text[0] = byte; out.len = 1; out.default = false; return out; } /// Whether a cell shows a glyph a character effect can work on. An unpainted /// cell and a space are both blank space, whatever style they carry. fn hasGlyph(cell: *const Cell) bool { return !cell.default and !(cell.len == 1 and cell.text[0] == ' '); } fn paintTerminalSelection(p: *Pardes, pane: *Pane, r: Rect, rows: []const Pane.PointerRow, bg: [3]u8, fg: ?[3]u8) void { const body = p.bodyText(p.scratch.allocator(), pane) catch return; const raw = pane.mode == .tty; const tx = r.x + config.GUTTER; const width = r.w -| config.GUTTER; const body_y = if (p.settings.tag_bottom) r.y else r.y + BOX_H; var lines = std.mem.splitScalar(u8, body, '\n'); var visible: i32 = 0; while (lines.next()) |line| : (visible += 1) { if (visible >= r.h -| BOX_H) break; const source_row = if (raw) panes.Terminal.gridOffset(pane) + visible else pane.wrapAt(visible).line; for (rows) |row| { const target = if (row.raw_terminal) (if (raw) row.row else pane.surfRow(row.row)) else (if (raw) pane.gridRow(row.row) else row.row); if (target != source_row) continue; var lo = row.lo; var hi = row.hi; if (row.raw_terminal and !raw) { lo -|= row.prompt_bytes; hi -|= row.prompt_bytes; } else if (!row.raw_terminal and raw) { const prefix = panes.Terminal.promptPrefixBytes(pane, source_row, line); lo += prefix; hi += prefix; } lo = modal.graphemeStart(line, @min(lo, line.len)); hi = @min(hi, line.len); if (lo >= hi) continue; var col = panes.File.rawDisplayCol(line, lo); const end = panes.File.rawDisplayCol(line, hi); while (col < end and col < width) : (col += 1) { const cell = p.surface.at(tx + @as(u16, @intCast(col)), body_y + @as(u16, @intCast(visible))); cell.default = false; cell.style.bg = .{ .rgb = bg }; if (fg) |ink| cell.style.fg = .{ .rgb = ink }; } } } } fn paintSourceSelection(p: *Pardes, pane: *Pane, r: Rect, rows: []const Pane.PointerRow, bg: [3]u8, fg: ?[3]u8) void { if (pane.isTerminal()) return p.paintTerminalSelection(pane, r, rows, bg, fg); const terminal_lines = if (pane.file == null) panes.Terminal.cursorLines(p, pane) catch return else &.{}; const tx = r.x + config.GUTTER; const width = r.w -| config.GUTTER; const body_y = if (p.settings.tag_bottom) r.y else r.y + BOX_H; const prefix: i32 = if (pane.file != null) panes.File.gutterWidth(pane) else 0; var visible: i32 = 0; while (visible < @as(i32, r.h -| BOX_H)) : (visible += 1) { const source = pane.wrapAt(visible); const line = pointerSourceLine(pane, terminal_lines, source.line); const start = @min(line.len, @as(usize, @intCast(@max(0, source.at)))); const next = pane.wrapAt(visible + 1); const end = if (next.line == source.line) @min(line.len, @as(usize, @intCast(@max(0, next.at)))) else line.len; for (rows) |row| { if (row.row != source.line) continue; const lo = @max(start, row.lo); const hi = @min(end, row.hi); if (lo >= hi) continue; var col = prefix + @as(i32, @intCast(panes.File.rawDisplayCol(line[start..], lo - start))); const last = prefix + @as(i32, @intCast(panes.File.rawDisplayCol(line[start..], hi - start))); while (col < last and col < width) : (col += 1) { const cell = p.surface.at(tx + @as(u16, @intCast(col)), body_y + @as(u16, @intCast(visible))); cell.default = false; cell.style.bg = .{ .rgb = bg }; if (fg) |ink| cell.style.fg = .{ .rgb = ink }; } } } } fn paintPointerSelection( p: *Pardes, pane: *Pane, r: Rect, tx: u16, tw: u16, tag_y: u16, body_y: u16, sl: Pane.Sel, bg: [3]u8, fg: ?[3]u8, ) void { if (pane.pointerSelection(sl)) |rows| return p.paintSourceSelection(pane, r, rows, bg, fg); const s = &p.surface; const r0 = @min(sl.r0, sl.r1); const r1 = @max(sl.r0, sl.r1); const c0 = @max(0, @min(sl.c0, sl.c1)); const c1 = @max(0, @max(sl.c0, sl.c1)); var row: u16 = 0; while (row < r.h) : (row += 1) { if (@as(i32, row) < r0 or @as(i32, row) > r1) continue; const sy = if (row < BOX_H) tag_y else body_y + row - BOX_H; // File line numbers occupy a gutter only in the body. The tag is // row zero in Sel space and starts at its real first text cell. const scroll: i32 = if (row < BOX_H) pane.tag_scroll else 0; var col: i32 = if (pane.file != null and row >= BOX_H) @max(c0, @as(i32, panes.File.gutterWidth(pane))) else @max(0, c0 - scroll); while (col <= c1 - scroll and col < tw) : (col += 1) { const cell = s.at(tx + @as(u16, @intCast(col)), sy); cell.default = false; cell.style.bg = .{ .rgb = bg }; if (fg) |ink| cell.style.fg = .{ .rgb = ink }; } } } fn renderTagLayers(p: *Pardes, arena: std.mem.Allocator) !void { const chrome = p.chromeTheme(); for (p.panes, 0..) |slot, id| { const pane = slot orelse continue; const r = p.rects[id]; if (r.w <= config.GUTTER or r.h == 0) continue; const tag = try p.tagText(arena, pane); const columns: u16 = @intCast(@min(std.math.maxInt(u16) - config.GUTTER, @max(p.tagCapacity(r.w - config.GUTTER), panes.File.displayWidth(tag) + 1))); const layer = &p.surface.tag_layers[id]; if (layer.cells.len != columns) layer.cells = try p.gpa.realloc(layer.cells, columns); const storage = try arena.alloc(Cell, @as(usize, columns) + config.GUTTER); @memset(storage, .{}); var temporary: Surface = .{ .cols = columns + config.GUTTER, .rows = 1, .cells = storage }; std.mem.swap(Surface, &p.surface, &temporary); p.paintPaneTag(arena, pane, .{ .x = 0, .y = 0, .w = columns + config.GUTTER, .h = 1 }, id, id == p.active, p.tagCapacity(r.w - config.GUTTER)) catch |err| { std.mem.swap(Surface, &p.surface, &temporary); return err; }; std.mem.swap(Surface, &p.surface, &temporary); @memcpy(layer.cells, storage[config.GUTTER..]); layer.kind = .pane; layer.id = @intCast(id); layer.serial = pane.serial; layer.viewport = .{ .x = r.x + config.GUTTER, .y = if (p.settings.tag_bottom) r.y + r.h - BOX_H else r.y, .w = r.w - config.GUTTER, .h = BOX_H }; layer.cols = columns; layer.bg = if (id == p.active and p.settings.focus_tint) chrome.tag_active_bg else chrome.tag_bg; layer.cursor = if (p.topbar_col == null and pane.promptAt() == null and temporary.cursor != null) .{ .col = temporary.cursor.?.x - config.GUTTER, .bar = temporary.cursor.?.bar } else null; } var text_buf: [1200]u8 = undefined; try p.renderHeaderLayer(arena, MAX_PANES + MAX_COLS, .workspace, 0, .{ .x = 0, .y = 0, .w = p.screen_w, .h = TOPBAR_H }, p.topbar(&text_buf), chrome.tag_bg, chrome.tag_fg); if (p.columnBarHeight() > 0) for (0..p.ncol) |column| { if (p.col_w[column] == 0) continue; const active = if (layout.findPane(p, p.active)) |found| found.col == column and p.settings.focus_tint else false; try p.renderHeaderLayer(arena, MAX_PANES + column, .column, @intCast(column), .{ .x = p.col_x[column] + @min(config.GUTTER, p.col_w[column]), .y = TOPBAR_H, .w = p.col_w[column] -| config.GUTTER, .h = COLBAR_H }, p.columnTag(column), if (active) chrome.tag_active_bg else chrome.tag_bg, if (active) chrome.tag_active_fg else chrome.tag_fg); }; } fn renderHeaderLayer(p: *Pardes, arena: std.mem.Allocator, index: usize, kind: TagKind, id: u16, viewport: Rect, text: []const u8, bg: [3]u8, fg: [3]u8) !void { _ = arena; const layer = &p.surface.tag_layers[index]; const columns: u16 = @intCast(@min(std.math.maxInt(u16), @max(p.tagCapacity(viewport.w), panes.File.displayWidth(text) + 1))); if (layer.cells.len != columns) layer.cells = try p.gpa.realloc(layer.cells, columns); layer.kind = kind; layer.id = id; layer.serial = if (kind == .column) p.col_serial[id] else 0; layer.viewport = viewport; layer.cols = columns; layer.bg = bg; layer.cursor = null; var surface: Surface = .{ .cols = columns, .rows = 1, .cells = layer.cells }; const editing = p.topbar_col != null and (if (kind == .workspace) p.header_column == null else p.header_column == id); const scroll = if (editing) p.header_scroll else 0; surface.fill(0, 0, columns, 1, .{ .bg = .{ .rgb = bg }, .font_role = .tagline }); _ = surface.print(0, 0, columns, text[panes.File.rawAtDisplay(text, scroll)..], .{ .fg = .{ .rgb = fg }, .bg = .{ .rgb = bg }, .font_role = .tagline }); if (editing) { const cursor = p.topbar_col.?; const anchor = p.header_anchor orelse cursor; var col = panes.File.rawDisplayCol(text, @min(cursor, anchor)) -| scroll; const end = panes.File.rawDisplayCol(text, @max(cursor, anchor)) -| scroll; while (col < end and col < columns) : (col += 1) { surface.at(@intCast(col), 0).style.bg = .{ .rgb = p.theme().sel_bg }; surface.at(@intCast(col), 0).style.fg = .{ .rgb = p.theme().sel_fg }; } const caret = panes.File.rawDisplayCol(text, cursor) -| scroll; if (caret < columns) layer.cursor = .{ .col = @intCast(caret), .bar = p.header_insert }; } else if (p.pointer_inside) { const tag_hit = p.pointer_tag_hit; const col: ?usize = if (tag_hit) |pointer| (if (pointer.kind == kind and pointer.id == id) pointer.col else null) else if (p.hover_row == viewport.y and p.hover_col >= viewport.x and p.hover_col - viewport.x < viewport.w) p.hover_col - viewport.x else null; if (col) |hover| if (wordBoundsAtCol(text, panes.File.rawAtDisplay(text, hover + scroll))) |bounds| { var cell = panes.File.rawDisplayCol(text, bounds.lo) -| scroll; const end = panes.File.rawDisplayCol(text, bounds.hi) -| scroll; while (cell < end and cell < columns) : (cell += 1) { surface.at(@intCast(cell), 0).style.bg = .{ .rgb = p.theme().sel_bg }; surface.at(@intCast(cell), 0).style.fg = .{ .rgb = p.theme().sel_fg }; } }; } } fn renderBodyLayer(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id: usize) !void { if (!p.settings.tree_context_tag_style or pane.context_rows == 0 or pane.file == null or pane.collapsed) return; if (r.w <= config.GUTTER or r.h <= BOX_H) return; const message = if (pane.promptAt()) |at| pane.tagSlice()[@min(at, pane.tag_tail_len)..].len > 0 else pane.msg_len > 0; const reserve: u16 = if (message or (id == p.active and p.leader_on)) 1 else 0; const viewport_rows = r.h - BOX_H -| reserve; if (viewport_rows == 0) return; // The canonical fixed grid remains available to terminal clients. The // separate body layer reserves enough rows for any compact font size. const logical_rows = @min(@as(usize, pane.rows) + pane.context_rows, pane.wrap_line.len); if (logical_rows <= pane.context_rows) return; pane.body_rows = @intCast(logical_rows); const bh: usize = @max(1, p.row_metrics.body_h); const th: usize = std.math.clamp(p.row_metrics.tagline_h, 1, bh); const body_pixels = @as(usize, viewport_rows) * bh; pane.context_row_limit = @intCast(@min(pane.context_rows, (body_pixels -| bh) / th)); const context_pixels = @as(usize, pane.context_row_limit.?) * th; pane.body_visible_rows = @intCast(@min(logical_rows, pane.context_row_limit.? + @max(1, (body_pixels -| context_pixels) / bh))); const width = r.w - config.GUTTER; const layer = &p.surface.body_layers[id]; const count = logical_rows * width; if (layer.cells.len != count) layer.cells = try p.gpa.realloc(layer.cells, count); const temporary_cells = try arena.alloc(Cell, @as(usize, r.w) * (logical_rows + BOX_H)); @memset(temporary_cells, .{}); var temporary: Surface = .{ .cols = r.w, .rows = @intCast(logical_rows + BOX_H), .cells = temporary_cells }; std.mem.swap(Surface, &p.surface, &temporary); const synthetic: Rect = .{ .x = 0, .y = 0, .w = r.w, .h = @intCast(logical_rows + BOX_H) }; p.renderBody(arena, pane, synthetic, id, id == p.active, false) catch |err| { std.mem.swap(Surface, &p.surface, &temporary); return err; }; std.mem.swap(Surface, &p.surface, &temporary); const first_row: usize = if (p.settings.tag_bottom) 0 else BOX_H; for (0..logical_rows) |row| { const start = (row + first_row) * r.w + config.GUTTER; @memcpy(layer.cells[row * width ..][0..width], temporary_cells[start..][0..width]); } layer.pane = @intCast(id); layer.serial = pane.serial; layer.viewport = .{ .x = r.x + config.GUTTER, .y = if (p.settings.tag_bottom) r.y else r.y + BOX_H, .w = width, .h = viewport_rows }; layer.cols = width; layer.rows = @intCast(logical_rows); layer.context_rows = pane.context_rows; layer.setContextLines(pane.wrap_line[0..pane.context_rows]); layer.cursor = null; if (temporary.cursor) |cursor| { if (cursor.x >= config.GUTTER and cursor.y >= first_row) layer.cursor = .{ .x = cursor.x - config.GUTTER, .y = @intCast(cursor.y - first_row), .bar = cursor.bar }; } } fn renderPane(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id: usize, active: bool) !void { const tz = tracy.zone(@src(), "renderPane"); defer tz.end(); if (r.w <= config.GUTTER or r.h == 0) return; const s = &p.surface; const th = p.theme(); const chrome = p.chromeTheme(); const tx = r.x + config.GUTTER; // text area (tag + body), right of the gutter const tw = r.w - config.GUTTER; const tag_y = if (p.settings.tag_bottom) r.y + r.h -| BOX_H else r.y; const body_y = if (p.settings.tag_bottom) r.y else r.y + BOX_H; const pane_bg: Color = if (th.bg) |c| .{ .rgb = c } else .default; // text area resets to terminal-default cells (vaxis clear semantics); // light themes paint the page over it. { // Two full passes over every cell in the pane, every frame. const tz_clear = tracy.zone(@src(), "paneClear"); defer tz_clear.end(); s.clearRect(tx, r.y, tw, r.h); if (th.bg) |bg| s.fill(tx, r.y, tw, r.h, .{ .bg = .{ .rgb = bg } }); } const box_bg = if (active) chrome.box else chrome.box_dim; const box_lum = (@as(u16, box_bg[0]) * 3 + @as(u16, box_bg[1]) * 6 + @as(u16, box_bg[2])) / 10; const box_ink: [3]u8 = if (box_lum > 140) .{ 0x00, 0x00, 0x00 } else .{ 0xff, 0xff, 0xff }; const box_style: CellStyle = .{ .fg = .{ .rgb = box_ink }, .bg = .{ .rgb = box_bg }, // Grips keep physical grid dimensions, independently of tag text. .font_role = .tagline, }; s.fill(r.x, tag_y, config.GUTTER, BOX_H, box_style); const box_mode = if (pane.tag_edit) pane.tag_mode else pane.mode; s.set(r.x, tag_y, switch (box_mode) { .normal => config.tag_normal, .insert => config.tag_insert, .tty => config.tag_tty, }, box_style); try p.paintPaneTag(arena, pane, r, id, active, p.tagCapacity(tw)); // Folded panes retain their body state, but neither body pixels nor // its cursor belong outside the one-row tag rectangle. if (pane.collapsed) return; if (comptime pdf_enabled) if (pane.hasPdf() and panes.Pdf.draw(p, pane, r, id, tx, tw)) return; if (pane.image) |*iv| { panes.Image.draw(p, iv, @intCast(id), pane.serial, tx, body_y, tw, r.h -| BOX_H); // thumbless, but the same one column as the real scrollbar below — // that is the whole point of drawing it s.fill(r.x, body_y, 1, r.h -| BOX_H, .{ .bg = .{ .rgb = chrome.scroll_track } }); s.fill(r.x + 1, body_y, 1, r.h -| BOX_H, .{ .bg = pane_bg }); return; } try p.renderBody(arena, pane, r, id, active, true); } fn paintPaneTag(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id: usize, active: bool, capacity: u16) !void { const s = &p.surface; const th = p.theme(); const chrome = p.chromeTheme(); const tx = r.x + config.GUTTER; const tw = r.w - config.GUTTER; const tag_y = if (p.settings.tag_bottom) r.y + r.h -| BOX_H else r.y; const body_y = if (p.settings.tag_bottom) r.y else r.y + BOX_H; const page_rgb = th.bg orelse th.tag_bg; // Focus is visible along the tag without changing its live text grid. const tag_bg = if (active and p.settings.focus_tint) chrome.tag_active_bg else chrome.tag_bg; const tag_fg = if (active and p.settings.focus_tint) chrome.tag_active_fg else chrome.tag_fg; const tag_style: CellStyle = .{ .fg = .{ .rgb = tag_fg }, .bg = .{ .rgb = tag_bg }, .font_role = .tagline }; // Padding belongs to the same font row but keeps its default ink: a // role must not manufacture a foreground color for blank cells. s.fill(tx, tag_y, tw, BOX_H, .{ .bg = .{ .rgb = tag_bg }, .font_role = .tagline, }); const tag = try p.tagText(arena, pane); const prompt_at = pane.promptAt(); if (active and pane.tag_edit and prompt_at == null) { const caret = panes.File.rawDisplayCol(tag, pane.tag_col); var scroll: usize = pane.tag_scroll; if (caret < scroll) scroll = caret; if (caret >= scroll + capacity) scroll = caret - capacity + 1; var raw = panes.File.rawAtDisplay(tag, scroll); scroll = panes.File.rawDisplayCol(tag, raw); if (caret >= scroll + capacity) { raw = modal.nextGrapheme(tag, raw); scroll = panes.File.rawDisplayCol(tag, raw); } pane.tag_scroll = @intCast(scroll); } else if (!pane.tag_edit or prompt_at != null) pane.tag_scroll = 0; const tag_cut = if (prompt_at) |at| @min(tag.len, tag.len - @min(tag.len, pane.tag_tail_len) + at) else tag.len; const tag_start = @min(tag_cut, panes.File.rawAtDisplay(tag, pane.tag_scroll)); _ = s.print(tx, tag_y, tw, tag[tag_start..tag_cut], tag_style); if (tagTintRange(pane, tag, tag.len - curTail(pane).len)) |name| { const lo = @max(name.lo, tag_start); const hi = @min(name.hi, tag_cut); if (lo < hi) { var col = panes.File.rawDisplayCol(tag, lo) -| pane.tag_scroll; const end = panes.File.rawDisplayCol(tag, hi) -| pane.tag_scroll; const name_fg = if (active and p.settings.focus_tint) chrome.tag_active_name_fg else chrome.tag_name_fg; while (col < end and col < tw) : (col += 1) s.at(tx + @as(u16, @intCast(col)), tag_y).style.fg = .{ .rgb = name_fg }; } } if (p.look_hover_preview) |preview| { if (preview.pane == id and preview.serial == pane.serial) if (preview.sel) |sel| if (@min(sel.r0, sel.r1) < BOX_H) { const preview_bg = mix(page_rgb, mix(page_rgb, th.sel_bg)); p.paintPointerSelection(pane, r, tx, tw, tag_y, body_y, sel, preview_bg, null); }; } if (pane.tag_edit and pane.tag_sel) { const b = tagSelBounds(pane); var col = panes.File.rawDisplayCol(tag, b.lo) -| pane.tag_scroll; const hi = modal.nextGrapheme(tag, b.hi); const end = panes.File.rawDisplayCol(tag, hi); while (col + pane.tag_scroll < end and col < tw) : (col += 1) { const cell = s.at(tx + @as(u16, @intCast(col)), tag_y); cell.default = false; cell.style.bg = .{ .rgb = th.sel_bg }; cell.style.fg = .{ .rgb = th.sel_fg }; } } // cursor while editing the tag: byte offset mapped to its display cell if (active and pane.tag_edit) { // bar while typing, block for `:` normal mode (same rule as a body) const col = panes.File.rawDisplayCol(tag, pane.tag_col) -| pane.tag_scroll; if (col < tw) s.cursor = .{ .x = tx + @as(u16, @intCast(col)), .y = tag_y, .bar = pane.mode == .insert }; } for (pane.sel, 0..) |selection, button| { if (selection.state == .none or (selection.state == .done and button != 0)) continue; if (@min(selection.r0, selection.r1) >= BOX_H) continue; p.paintPointerSelection(pane, r, tx, tw, tag_y, body_y, selection, th.sel_bg, th.sel_fg); } } fn renderBody(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id: usize, active: bool, scrollbar: bool) !void { const s = &p.surface; const th = p.theme(); const chrome = p.chromeTheme(); const tx = r.x + config.GUTTER; const tw = r.w - config.GUTTER; const tag_y = if (p.settings.tag_bottom) r.y + r.h -| BOX_H else r.y; const body_y = if (p.settings.tag_bottom) r.y else r.y + BOX_H; const pane_bg: Color = if (th.bg) |c| .{ .rgb = c } else .default; const page_rgb = th.bg orelse th.tag_bg; // body: emulator rows, prompt rows hidden outside tty mode const body_h = if (r.h > BOX_H) r.h - BOX_H else 0; const body_style: CellStyle = .{ .fg = if (th.fg) |c| .{ .rgb = c } else .default, .bg = pane_bg, }; const tz_body = tracy.zone(@src(), "bodyText"); const body = try p.bodyText(arena, pane); tz_body.end(); { // The rows themselves. `bodyText` above is only the string BUILD; // this is what writes it into the surface. const tz_rows = tracy.zone(@src(), "paneBodyRows"); defer tz_rows.end(); var it = std.mem.splitScalar(u8, body, '\n'); var i: u16 = 0; while (it.next()) |line| : (i += 1) { if (i >= body_h) break; _ = s.print(tx, body_y + i, tw, line, body_style); } } const tz_color = tracy.zone(@src(), "paneRecolor"); // File layout is independent of whether its producer requests colors. if (pane.file) |*f| { panes.File.drawGutter(p, pane, r, tx, tw, body_h, active); if (p.settings.colors and pane.colorAlgo() != .none) panes.File.recolorSyntax(p, pane, f, r, tx, tw, body_h); panes.File.drawWrapMarkers(p, pane, r, tx, tw, body_h, pane_bg); } else if (pane.isTerminal() and p.settings.colors) { panes.Terminal.recolorAnsi(p, pane, r, tx, tw, body_h, body); } tz_color.end(); const accent2 = if (std.mem.eql(u8, &th.num, &th.str)) th.comment else th.num; const sel_btn = [3][3]u8{ mix(th.sel_bg, mix(th.sel_bg, th.kw)), mix(th.sel_bg, mix(th.sel_bg, th.str)), mix(th.sel_bg, mix(th.sel_bg, accent2)), }; if (p.look_hover_preview) |preview| { if (preview.pane == id and preview.serial == pane.serial) { // Preserve body ink, then let every real user selection below // win over this quiet affordance. const preview_bg = mix(page_rgb, mix(page_rgb, th.sel_bg)); if (preview.file_word) |word| panes.File.paintWordSelection(p, pane, r, word.row, word.lo, word.hi, preview_bg) else if (preview.sel) |sel| if (@max(sel.r0, sel.r1) >= BOX_H) p.paintPointerSelection(pane, r, tx, tw, tag_y, body_y, sel, preview_bg, null); } } const modal_hover = if (p.look_hover_preview) |preview| preview.pane == id and preview.serial == pane.serial and preview.sel == null and preview.file_word == null else false; for (pane.sel, 0..) |sl, b| { if (sl.state == .none) continue; if (sl.state == .done and b != 0) continue; p.paintPointerSelection(pane, r, tx, tw, tag_y, body_y, sl, sel_btn[b], th.sel_fg); } // modal line selection (helix `x`): whole body rows, normal mode only if ((pane.mode == .normal or modal_hover) and pane.msel.active) { const hover_only = modal_hover and pane.mode != .normal; const modal_bg = if (hover_only) mix(page_rgb, mix(page_rgb, th.sel_bg)) else th.sel_bg; const lo = @min(pane.msel.r0, pane.msel.r1); const hi = @max(pane.msel.r0, pane.msel.r1); var row: u16 = BOX_H; // never paint the tag row while (row < r.h) : (row += 1) { const ar = pane.wrapAt(@as(i32, row) - @as(i32, BOX_H)).line; if (ar < lo or ar > hi) continue; var col: u16 = if (pane.file != null) panes.File.gutterWidth(pane) else 0; while (col < tw) : (col += 1) { const cell = s.at(tx + col, body_y + row - BOX_H); cell.default = false; cell.style.bg = .{ .rgb = modal_bg }; if (!hover_only) cell.style.fg = .{ .rgb = th.sel_fg }; } } } const preview = selRegexArmed(pane) != null; const show_prim = (pane.mode == .normal and pane.vsel.active) or preview or (modal_hover and pane.vsel.active); const show_extra = pane.mode != .tty and pane.nsel > 0; if (show_prim or show_extra) { const vpfx: i32 = if (pane.file != null) panes.File.gutterWidth(pane) else 0; var si: usize = 0; while (si <= pane.nsel) : (si += 1) { const primary = si == pane.nsel; if (if (primary) !show_prim else !show_extra) continue; const sr = if (primary) Pane.SelRange{ .row = pane.cur_row, .col = pane.cur_col, .arow = if (pane.vsel.active) pane.vsel.row else pane.cur_row, .acol = if (pane.vsel.active) pane.vsel.col else pane.cur_col, } else pane.sels[si]; const bnd = cellBounds(sr); const hover_only = primary and modal_hover and pane.mode != .normal and !preview; const bg = if (hover_only) mix(page_rgb, mix(page_rgb, th.sel_bg)) else if (primary) th.sel_bg else mix(th.sel_bg, page_rgb); var vr: i32 = 0; while (vr + @as(i32, BOX_H) < @as(i32, r.h)) : (vr += 1) { const w = pane.wrapAt(vr); if (w.line < bnd.lo_row or w.line > bnd.hi_row) continue; const visible_line = modal.lineSlice(body, @intCast(vr)); const cstart: i32 = if (w.line == bnd.lo_row) (if (pane.file != null) panes.File.displayOffset(pane, w.line, w.at, bnd.lo_col) else panes.File.lineDisplayOffset(visible_line, @intCast(@max(0, w.at)), @intCast(@max(0, bnd.lo_col)))) + vpfx else vpfx; const cend: i32 = if (w.line == bnd.hi_row) (if (pane.file != null) panes.File.displayEndOffset(pane, w.line, w.at, bnd.hi_col) else panes.File.lineDisplayEndOffset(visible_line, @intCast(@max(0, w.at)), @intCast(@max(0, bnd.hi_col)))) + vpfx else @as(i32, tw) - 1; var col: i32 = @max(cstart, vpfx); while (col <= cend and col < tw) : (col += 1) { const cell = s.at(tx + @as(u16, @intCast(col)), body_y + @as(u16, @intCast(vr))); cell.default = false; cell.style.bg = .{ .rgb = bg }; if (!hover_only) cell.style.fg = .{ .rgb = th.sel_fg }; } } if (primary and !preview and !hover_only) continue; // the hardware cursor IS the primary's if (hover_only) continue; // quiet preview preserves the source ink const cw = pane.wrapRow(sr.row, sr.col); const crow = cw.row + @as(i32, BOX_H); const ccol = (if (pane.file != null) panes.File.displayOffset(pane, sr.row, cw.at, sr.col) else panes.File.lineDisplayOffset( modal.lineSlice(body, @intCast(@max(0, cw.row))), @intCast(@max(0, cw.at)), @intCast(@max(0, sr.col)), )) + vpfx; if (crow >= BOX_H and crow < @as(i32, r.h) and ccol >= vpfx and ccol < tw) { const cell = s.at(tx + @as(u16, @intCast(ccol)), body_y + @as(u16, @intCast(crow - BOX_H))); cell.default = false; cell.style.bg = .{ .rgb = th.sel_fg }; cell.style.fg = .{ .rgb = th.sel_bg }; } } } // cursor: tracks the shell cursor until pinned by a click or a key // (the tag cursor above wins while the tag is focused) if (active and !pane.tag_edit) { if (pane.mode != .tty) { const cur = panes.Terminal.gridCursor(pane); const goff = panes.Terminal.gridOffset(pane); const crow = if (pane.cur_pinned) pane.cur_row else pane.surfRow(@as(i32, @intCast(cur.y)) + goff); const ccol = if (pane.cur_pinned) pane.cur_col else @as(i32, @intCast(cur.x)); const cwp = pane.wrapRow(crow, ccol); const prow = cwp.row + @as(i32, BOX_H); // Files store source-byte columns; the Surface stores display // cells, so account for every expanded tab before the cursor. const cx = if (pane.file != null) @as(i32, panes.File.gutterWidth(pane)) + panes.File.displayOffset(pane, crow, cwp.at, ccol) else if (pane.cur_pinned) panes.File.lineDisplayOffset( modal.lineSlice(body, @intCast(@max(0, cwp.row))), @intCast(@max(0, cwp.at)), @intCast(@max(0, ccol)), ) else ccol; if (prow >= BOX_H and cx >= 0 and prow < r.h and cx < tw) s.cursor = .{ .x = tx + @as(u16, @intCast(cx)), .y = body_y + @as(u16, @intCast(prow - BOX_H)), .bar = pane.mode == .insert }; } else if (panes.Terminal.visibleCursor(pane)) |cur| { if (cur.y + BOX_H < r.h and cur.x < tw) s.cursor = .{ .x = tx + cur.x, .y = body_y + cur.y }; } } if (scrollbar and r.h > BOX_H) { s.fill(r.x, body_y, 1, r.h - BOX_H, .{ .bg = .{ .rgb = chrome.scroll_track } }); s.fill(r.x + 1, body_y, 1, r.h - BOX_H, .{ .bg = pane_bg }); const sb: struct { total: usize, offset: usize, len: usize } = if (pane.file) |*f| .{ .total = panes.File.nlines(p.gpa, f), .offset = f.scroll, .len = pane.rows, } else if (pane.pdfPage()) |page| .{ .total = if (comptime pdf_enabled) pane.pdf.?.page_count else 1, .offset = page, .len = 1, } else blk: { const gsb = panes.Terminal.scrollbar(pane); break :blk .{ .total = gsb.total, .offset = gsb.offset, .len = gsb.len }; }; const track_h: usize = r.h - BOX_H; const total = if (sb.total == 0) 1 else sb.total; const len = @max(1, (track_h * @max(1, sb.len)) / total); const pos = (track_h * sb.offset) / total; var sy: usize = pos; while (sy < track_h and sy < pos + len) : (sy += 1) { s.fill(r.x, body_y + @as(u16, @intCast(sy)), 1, 1, .{ .bg = .{ .rgb = chrome.scroll_thumb } }); } } } pub fn appendImagePlace(p: *Pardes, place: ImagePlace) bool { const s = &p.surface; if (s.nimages == s.images.len) { const old_len = s.images.len; const new_len = @max(@as(usize, 4), std.math.mul(usize, @max(old_len, 1), 2) catch return false); const grown = if (old_len == 0) p.gpa.alloc(?ImagePlace, new_len) catch return false else p.gpa.realloc(s.images, new_len) catch return false; @memset(grown[old_len..], null); s.images = grown; } s.images[s.nimages] = place; s.nimages += 1; return true; } fn bodyText(p: *Pardes, arena: std.mem.Allocator, pane: *Pane) ![]const u8 { if (pane.image != null) { const buf = try arena.alloc(u8, pane.rows -| 1); @memset(buf, '\n'); return buf; } if (pane.hasPdf()) { if (comptime pdf_enabled) return panes.Pdf.visibleText( &pane.pdf.?, p.pdf_gpa, arena, pane.rows -| BOX_H, ); unreachable; } if (pane.file) |*f| return panes.File.bodyText(arena, pane, f, p.settings.wrap); return panes.Terminal.bodyText(arena, pane); } }; test "Esc alternates between two panes of the SAME kind" { if (platform == .web) return; const gpa = std.testing.allocator; var cwdbuf: [4096]u8 = undefined; const cwd = std.mem.span(@as([*:0]u8, @ptrCast(std.c.getcwd(&cwdbuf, cwdbuf.len) orelse return))); var pathbuf: [4096]u8 = undefined; const boot = try std.fmt.bufPrint(&pathbuf, "{s}/mise.toml", .{cwd}); const p = try Pardes.init(gpa, .{ .cols = 80, .rows = 40, .file = boot }); defer p.deinit(); p.update(.{ .resize = .{ .cols = 80, .rows = 40 } }); const a = p.active; p.runBuiltin(.Look, a, "", "build.zig"); p.sync(); const b = p.active; try std.testing.expect(b != a); try std.testing.expect(!p.panes[a].?.isTerminal() and !p.panes[b].?.isTerminal()); for (0..6) |_| { const was = p.active; p.update(.{ .key = .{ .cp = Key.escape } }); try std.testing.expectEqual(if (was == a) b else a, p.active); } } test "a boot file that will not open boots an errors pane rather than failing the launch" { if (platform == .web) return; const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .cols = 80, .rows = 24, .file = "/definitely/not/here/notes.md", }); defer p.deinit(); p.update(.{ .resize = .{ .cols = 80, .rows = 24 } }); const pane = p.panes[0].?; const f = pane.file.?; try std.testing.expectEqual(panes.Output.Origin.errors, f.output.?.from); // The reason IN WORDS, not an error name: `PermissionDenied` on a screen // is jargon, and the whole point of this pane is that a human reads it. try std.testing.expect(std.mem.indexOf(u8, f.content, "cannot open") != null); try std.testing.expect(std.mem.indexOf(u8, f.content, "no file of that name") != null); try std.testing.expect(std.mem.indexOf(u8, f.content, "/definitely/not/here/notes.md") != null); } test "argv naming nothing boots an errors pane rather than failing the launch" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .cols = 80, .rows = 24, .missing = .{ .word = "notes/tdoo.md", .dir = "/home/pardes-test/work" }, }); defer p.deinit(); p.update(.{ .resize = .{ .cols = 80, .rows = 24 } }); // One pane, filling the window, and it is an OUTPUT pane rather than the // shell a bare `pardes` boots: the answer is the whole screen. try std.testing.expectEqual(@as(u8, 1), p.ncol); try std.testing.expectEqual(@as(usize, 1), p.col_n[0]); try std.testing.expectEqual(@as(usize, 0), p.col_panes[0][0]); try std.testing.expectEqual(@as(usize, 0), p.active); for (p.panes[1..]) |slot| try std.testing.expect(slot == null); const pane = p.panes[0].?; try std.testing.expect(!pane.isTerminal()); const f = pane.file.?; try std.testing.expectEqual(panes.Output.Origin.errors, f.output.?.from); // ...and it says what happened AND what was asked for. The word as typed, // which is the half a bare "not found" leaves out. try std.testing.expect(std.mem.indexOf(u8, f.content, "not found") != null); try std.testing.expect(std.mem.indexOf(u8, f.content, "notes/tdoo.md") != null); try std.testing.expectEqualStrings("/home/pardes-test/work/+Errors", f.path); } test "raw terminal cursor obeys visibility without hiding modal and tag cursors" { if (comptime !panes.Terminal.enabled) return error.SkipZigTest; const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 40, .rows = 12 }); defer p.deinit(); while (p.nextEffect()) |_| {} const pane = p.panes[0].?; pane.mode = .tty; p.update(.{ .output = .{ .pane = 0, .bytes = "abc" } }); var frame = std.heap.ArenaAllocator.init(gpa); defer frame.deinit(); const visible = (try p.render(frame.allocator())).cursor orelse return error.MissingCursor; const rect = p.rects[0]; const body_y = if (p.settings.tag_bottom) rect.y else rect.y + BOX_H; try std.testing.expectEqual(rect.x + config.GUTTER + 3, visible.x); try std.testing.expectEqual(body_y, visible.y); p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?25l" } }); try std.testing.expect((try p.render(frame.allocator())).cursor == null); pane.mode = .normal; pane.cur_pinned = true; pane.cur_row = 0; pane.cur_col = 1; const modal_cursor = (try p.render(frame.allocator())).cursor orelse return error.MissingCursor; try std.testing.expectEqual(rect.x + config.GUTTER + 1, modal_cursor.x); try std.testing.expectEqual(body_y, modal_cursor.y); p.enterTagEdit(pane, 0); const tag_cursor = (try p.render(frame.allocator())).cursor orelse return error.MissingCursor; try std.testing.expectEqual(if (p.settings.tag_bottom) rect.y + rect.h - BOX_H else rect.y, tag_cursor.y); Pardes.exitTagEdit(pane); pane.mode = .tty; try std.testing.expect((try p.render(frame.allocator())).cursor == null); p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?25h\x1b[?1049h\x1b[2;5H" } }); const alternate = (try p.render(frame.allocator())).cursor orelse return error.MissingCursor; try std.testing.expectEqual(rect.x + config.GUTTER + 4, alternate.x); try std.testing.expectEqual(body_y + 1, alternate.y); p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?25l" } }); try std.testing.expect((try p.render(frame.allocator())).cursor == null); p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?1049l\x1b[?25h" } }); const returned = (try p.render(frame.allocator())).cursor orelse return error.MissingCursor; try std.testing.expectEqual(visible.x, returned.x); try std.testing.expectEqual(visible.y, returned.y); } test "raw terminal cursor follows its active row only while that row is visible" { if (comptime !panes.Terminal.enabled) return error.SkipZigTest; const gpa = std.testing.allocator; for ([_]bool{ false, true }) |tag_bottom| { const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 40, .rows = 12 }); defer p.deinit(); while (p.nextEffect()) |_| {} p.settings.tag_bottom = tag_bottom; const pane = p.panes[0].?; pane.mode = .tty; for (0..60) |_| p.update(.{ .output = .{ .pane = 0, .bytes = "row\r\n" } }); p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[3;4H" } }); var frame = std.heap.ArenaAllocator.init(gpa); defer frame.deinit(); const live = (try p.render(frame.allocator())).cursor orelse return error.MissingCursor; const rect = p.rects[0]; const body_y = if (tag_bottom) rect.y else rect.y + BOX_H; try std.testing.expectEqual(rect.x + config.GUTTER + 3, live.x); try std.testing.expectEqual(body_y + 2, live.y); panes.Terminal.scrollGrid(pane, -1); const scrolled = (try p.render(frame.allocator())).cursor orelse return error.MissingCursor; try std.testing.expectEqual(live.x, scrolled.x); try std.testing.expectEqual(live.y + 1, scrolled.y); panes.Terminal.scrollGrid(pane, -@as(i32, pane.rows)); try std.testing.expect((try p.render(frame.allocator())).cursor == null); panes.Terminal.followOutput(pane); const returned = (try p.render(frame.allocator())).cursor orelse return error.MissingCursor; try std.testing.expectEqual(live.x, returned.x); try std.testing.expectEqual(live.y, returned.y); } } test "Esc back into a tty leaves its view at the prompt" { if (platform == .web) return; const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .cols = 80, .rows = 24, .file = "mise.toml" }); defer p.deinit(); p.update(.{ .resize = .{ .cols = 80, .rows = 24 } }); p.update(.{ .key = .{ .cp = 'n', .alt = true } }); // a shell under the doc const shell = p.active; const sp = p.panes[shell].?; try std.testing.expect(sp.isTerminal()); // A click pins the modal cursor — one of the several ordinary ways to get a // pin, and the pin is what a shell's rows then scroll out from under. const r = p.rects[shell]; p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = r.x + config.GUTTER + 1, .row = r.y + BOX_H } }); p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = r.x + config.GUTTER + 1, .row = r.y + BOX_H } }); try std.testing.expectEqual(shell, p.active); p.update(.{ .key = .{ .cp = Key.escape, .shift = true } }); try std.testing.expectEqual(Pane.Mode.tty, sp.mode); // tty mode follows output to the bottom, so a screenful and a half of it // rides the view down and leaves that pin far up in the scrollback. for (0..60) |i| { var buf: [32]u8 = undefined; p.update(.{ .output = .{ .pane = @intCast(shell), .bytes = std.fmt.bufPrint(&buf, "line {d}\r\n", .{i}) catch unreachable } }); } p.sync(); const live = sp.terminal.?.vt.screens.active.pages.scrollbar().offset; try std.testing.expect(live > 0); p.update(.{ .key = .{ .cp = Key.escape } }); // shell-prompt heuristic: out to the doc try std.testing.expect(p.active != shell); p.update(.{ .key = .{ .cp = Key.escape } }); // ...and back in p.sync(); try std.testing.expectEqual(shell, p.active); try std.testing.expectEqual(live, sp.terminal.?.vt.screens.active.pages.scrollbar().offset); } test "Esc back into a file leaves its view where it was" { if (platform == .web) return; const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .cols = 80, .rows = 24, .file = "src/memory.zig" }); defer p.deinit(); p.update(.{ .resize = .{ .cols = 80, .rows = 24 } }); p.update(.{ .key = .{ .cp = 'n', .alt = true } }); // a shell under the doc const shell = p.active; p.update(.{ .key = .{ .cp = Key.escape } }); // back onto the doc p.sync(); const doc = p.active; try std.testing.expect(doc != shell); const dp = p.panes[doc].?; try std.testing.expect(dp.file != null); // Ride the cursor down until the view has scrolled: it now sits in the // bottom band, which is exactly where a recenter is visible. for (0..20) |_| p.update(.{ .key = .{ .cp = 'j' } }); p.sync(); const view = dp.file.?.scroll; const row = dp.cur_row; try std.testing.expect(view > 0); p.update(.{ .key = .{ .cp = Key.escape } }); // out to the shell p.sync(); try std.testing.expectEqual(shell, p.active); p.update(.{ .key = .{ .cp = Key.escape } }); // ...and back p.sync(); try std.testing.expectEqual(doc, p.active); // Nothing moved. The old landing recentred the line under the cursor, so // coming back repainted the whole screen to show what was already on it. try std.testing.expectEqual(view, dp.file.?.scroll); try std.testing.expectEqual(row, dp.cur_row); // ...and the cursor is still on screen, which is all `.nearest` promises. try std.testing.expect(dp.cur_row >= @as(i32, @intCast(view))); try std.testing.expect(dp.cur_row < @as(i32, @intCast(view)) + @as(i32, dp.rows)); // The other arm still centres: a look target, a `:NN`, a search hit. p.focusPaneLine(doc, .{ .line = @intCast(row + 1), .col = 1 }, .center); try std.testing.expect(dp.file.?.scroll != view); try std.testing.expectEqual(@as(usize, @intCast(row)) -| @as(usize, dp.rows) / 2, dp.file.?.scroll); } test "raw tty Escape only leaves at a shell prompt" { if (platform == .web) return; const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24, .file = "mise.toml" }); defer p.deinit(); p.spawnTty(0); const shell = p.active; const pane = p.panes[shell].?; var buf: [256]u8 = undefined; _ = drainWrites(p, &buf); for ([_]Key{ .{ .cp = Key.escape, .shift = true }, }) |key| { p.update(.{ .key = key }); try std.testing.expectEqual(shell, p.active); try std.testing.expectEqual(Pane.Mode.tty, pane.mode); try std.testing.expect(drainWrites(p, &buf).len > 0); } p.update(.{ .key = .{ .cp = Key.escape } }); try std.testing.expectEqual(@as(usize, 0), p.active); try std.testing.expectEqualStrings("", drainWrites(p, &buf)); try std.testing.expectEqual(Pane.Mode.tty, pane.mode); } test "raw tty Ctrl-B switches to editor mode without sending child input" { if (platform == .web) return; const p = try Pardes.init(std.testing.allocator, .{ .file = "mise.toml" }); defer p.deinit(); p.spawnTty(0); const shell = p.active; p.sync(); var buf: [256]u8 = undefined; _ = drainWrites(p, &buf); p.update(.{ .key = .{ .cp = 'b', .ctrl = true } }); try std.testing.expectEqual(shell, p.active); try std.testing.expectEqual(Pane.Mode.normal, p.panes[shell].?.mode); try std.testing.expectEqualStrings("", drainWrites(p, &buf)); } test "hopping between two panes does not grow the jump stack" { if (platform == .web) return; const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .cols = 80, .rows = 24, .file = "mise.toml" }); defer p.deinit(); p.update(.{ .resize = .{ .cols = 80, .rows = 24 } }); p.update(.{ .key = .{ .cp = 'n', .alt = true } }); // a shell under the doc const doc = 0; const shell = p.active; try std.testing.expect(shell != doc); const depth = p.njumps; // Esc is Last — the everyday two-pane hop. Each press used to // append, so sixty-four of them left the stack remembering nothing else. for (0..8) |_| { const was = p.active; p.update(.{ .key = .{ .cp = Key.escape } }); try std.testing.expect(p.active != was); } try std.testing.expectEqual(depth, p.njumps); // ...and so are the other two ways to switch panes. for (0..8) |_| { const want = if (p.active == doc) shell else doc; const dir: u21 = if (p.active == doc) 'j' else 'k'; p.update(.{ .key = .{ .cp = ' ' } }); p.update(.{ .key = .{ .cp = 'w' } }); p.update(.{ .key = .{ .cp = dir } }); try std.testing.expectEqual(want, p.active); } try std.testing.expectEqual(depth, p.njumps); for (0..8) |_| { const other = if (p.active == doc) shell else doc; const r = p.rects[other]; p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = r.x + config.GUTTER + 1, .row = r.y + BOX_H + 1 } }); p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = r.x + config.GUTTER + 1, .row = r.y + BOX_H + 1 } }); try std.testing.expectEqual(other, p.active); } try std.testing.expectEqual(depth, p.njumps); const before = p.active; p.runBuiltin(.Back, p.active, "", null); p.sync(); try std.testing.expect(p.active != before); try std.testing.expectEqual(depth, p.njumps); } /// Drain the queue and say whether the shell was asked to do `want`. fn drainedEffect(p: *Pardes, want: std.meta.Tag(Effect)) bool { var seen = false; while (p.nextEffect()) |effect| { if (std.meta.activeTag(effect) == want) seen = true; } return seen; } test "only the SPC clipboard commands cross to the system clipboard" { if (platform == .web) return; const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .cols = 80, .rows = 24, .file = "mise.toml" }); defer p.deinit(); p.update(.{ .resize = .{ .cols = 80, .rows = 24 } }); const pane = p.panes[0].?; _ = drainedEffect(p, .set_clipboard); p.update(.{ .key = .{ .cp = 'y' } }); try std.testing.expect(p.yank != null and p.yank.?.len > 0); try std.testing.expect(!drainedEffect(p, .set_clipboard)); p.update(.{ .key = .{ .cp = 'd' } }); try std.testing.expect(!drainedEffect(p, .set_clipboard)); // `SPC y` is the one that does, and it puts the SAME text there that `y` // put in the register — it is the ordinary yank path plus the mirror. p.update(.{ .key = .{ .cp = ' ' } }); p.update(.{ .key = .{ .cp = 'y' } }); const yanked = p.yank orelse return error.MissingYank; try std.testing.expect(drainedEffect(p, .set_clipboard)); const before = pane.file.?.content.len; p.update(.{ .key = .{ .cp = ' ' } }); p.update(.{ .key = .{ .cp = 'p' } }); try std.testing.expect(p.clip_pending != null); try std.testing.expect(drainedEffect(p, .read_clipboard)); try std.testing.expectEqual(before, pane.file.?.content.len); // nothing yet p.update(.{ .paste = "PASTED" }); try std.testing.expect(p.clip_pending == null); try std.testing.expect(std.mem.indexOf(u8, pane.file.?.content, "PASTED") != null); // ...and it did NOT land in the register on its way past, nor echo back // out to the clipboard it came from. try std.testing.expectEqualStrings(yanked, p.yank orelse ""); try std.testing.expect(!drainedEffect(p, .set_clipboard)); // An UNSOLICITED paste — the outer terminal's bracketed paste, a Cmd-V — // is the same event with no request behind it, and means paste after. p.update(.{ .paste = "UNASKED" }); try std.testing.expect(std.mem.indexOf(u8, pane.file.?.content, "UNASKED") != null); try std.testing.expectEqualStrings(yanked, p.yank orelse ""); // A request the shell never answers dies at the next keystroke rather // than firing late into whatever pane is focused by then. p.update(.{ .key = .{ .cp = ' ' } }); p.update(.{ .key = .{ .cp = 'P' } }); try std.testing.expect(p.clip_pending != null); p.update(.{ .key = .{ .cp = 'l' } }); try std.testing.expect(p.clip_pending == null); } /// Every `.write` a keystroke produced, joined — what the pane's program would /// actually have read off its pty. fn drainWrites(p: *Pardes, buf: []u8) []const u8 { var n: usize = 0; while (p.nextEffect()) |effect| switch (effect) { .write => |w| { const b = w.bytes.slice(); if (n + b.len > buf.len) break; @memcpy(buf[n..][0..b.len], b); n += b.len; }, else => {}, }; return buf[0..n]; } test "raw tty forwards Ctrl letters and former global shortcuts to the child" { if (platform == .web) return; const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer p.deinit(); var buf: [256]u8 = undefined; _ = drainWrites(p, &buf); p.setYank("must not be pasted"); for ('a'..'z' + 1) |letter| { if (letter == p.opts.tty_toggle) continue; // editor/TTY mode shortcut p.update(.{ .key = .{ .cp = @intCast(letter), .ctrl = true } }); const expected = [_]u8{@intCast(letter - 'a' + 1)}; // The terminal encoder distinguishes Ctrl-I/M from Tab/Return. const encoded: []const u8 = switch (letter) { 'i' => "\x1b[105;5u", 'm' => "\x1b[109;5u", else => &expected, }; try std.testing.expectEqualStrings(encoded, drainWrites(p, &buf)); try std.testing.expectEqual(@as(usize, 0), p.active); try std.testing.expectEqual(Pane.Mode.tty, p.panes[0].?.mode); try std.testing.expect(!p.ctrl_w_pending and p.clip_pending == null); } p.update(.{ .key = .{ .cp = 'v', .ctrl = true, .shift = true } }); try std.testing.expectEqualStrings("\x1b[118;6u", drainWrites(p, &buf)); try std.testing.expect(p.clip_pending == null); for ([_]u8{ 'n', 'c' }) |letter| { p.update(.{ .key = .{ .cp = letter, .alt = true } }); const expected = [_]u8{ 0x1b, letter }; try std.testing.expectEqualStrings(&expected, drainWrites(p, &buf)); try std.testing.expectEqual(@as(usize, 0), p.active); try std.testing.expect(p.panes[1] == null); } p.update(.{ .key = .{ .cp = Key.up } }); try std.testing.expectEqualStrings("\x1b[A", drainWrites(p, &buf)); p.panes[0].?.terminal.?.vt.modes.set(.cursor_keys, true); p.update(.{ .key = .{ .cp = Key.up } }); try std.testing.expectEqualStrings("\x1bOA", drainWrites(p, &buf)); p.update(.{ .key = .{ .cp = Key.left, .ctrl = true } }); try std.testing.expectEqualStrings("\x1b[1;5D", drainWrites(p, &buf)); } test "an unasked desktop paste reaches a tty pane's program, not its buffer" { if (platform == .web) return; const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 }); defer p.deinit(); p.update(.{ .resize = .{ .cols = 80, .rows = 24 } }); var buf: [256]u8 = undefined; _ = drainWrites(p, &buf); const pane = p.panes[0].?; panes.Terminal.enterTty(p, 0); // No request behind it: the window manager's own paste, or SDL answering a // Ctrl-Shift-V the desktop handled. It still has to reach the shell. p.update(.{ .paste = "ls -la" }); try std.testing.expectEqualStrings("ls -la", drainWrites(p, &buf)); // and nothing was typed into an overlay the pty does not know about try std.testing.expect(pane.ovl == null); } test "a paste larger than the effect ring reaches the program whole and in order" { if (platform == .web) return; const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 }); defer p.deinit(); p.update(.{ .resize = .{ .cols = 80, .rows = 24 } }); const buf = try gpa.alloc(u8, 1 << 20); defer gpa.free(buf); _ = drainWrites(p, buf); panes.Terminal.enterTty(p, 0); const text = try gpa.alloc(u8, 300 * 1024); defer gpa.free(text); for (text, 0..) |*c, i| c.* = 'a' + @as(u8, @intCast(i % 26)); p.update(.{ .paste = text }); try std.testing.expectEqualSlices(u8, text, drainWrites(p, buf)); // ...and the core is not still holding a copy afterwards try std.testing.expectEqual(@as(usize, 0), p.pending_write_bytes); } test "a bracketed paste larger than the ring still closes its bracket" { if (platform == .web) return; if (comptime !panes.Terminal.enabled) return; const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 }); defer p.deinit(); p.update(.{ .resize = .{ .cols = 80, .rows = 24 } }); const buf = try gpa.alloc(u8, 1 << 20); defer gpa.free(buf); _ = drainWrites(p, buf); panes.Terminal.enterTty(p, 0); p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?2004h" } }); try std.testing.expect(panes.Terminal.bracketedPaste(p.panes[0].?)); const text = try gpa.alloc(u8, 300 * 1024); defer gpa.free(text); @memset(text, 'z'); p.update(.{ .paste = text }); const got = drainWrites(p, buf); try std.testing.expect(std.mem.startsWith(u8, got, "\x1b[200~")); try std.testing.expect(std.mem.endsWith(u8, got, "\x1b[201~")); try std.testing.expectEqualSlices(u8, text, got["\x1b[200~".len .. got.len - "\x1b[201~".len]); } test "pasted bytes still queued at shutdown are freed, not leaked" { if (platform == .web) return; const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 }); defer p.deinit(); p.update(.{ .resize = .{ .cols = 80, .rows = 24 } }); const buf = try gpa.alloc(u8, 1 << 20); defer gpa.free(buf); _ = drainWrites(p, buf); panes.Terminal.enterTty(p, 0); const text = try gpa.alloc(u8, 300 * 1024); defer gpa.free(text); @memset(text, 'q'); p.update(.{ .paste = text }); try std.testing.expect(p.pending_write_bytes > 0); } test "leaving tty hides the prompt and keeps the command typed at it" { if (platform == .web) return; const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .cols = 80, .rows = 24, .file = "mise.toml" }); defer p.deinit(); p.update(.{ .resize = .{ .cols = 80, .rows = 24 } }); p.update(.{ .key = .{ .cp = 'n', .alt = true } }); // a shell under the doc const shell = p.active; const sp = p.panes[shell].?; try std.testing.expect(sp.isTerminal()); // Exactly what an OSC 133 shell draws: prompt, then the marker that says // the rest of this row is the user's, then what they typed. One grid row. p.update(.{ .output = .{ .pane = @intCast(shell), .bytes = "\x1b]133;A\x1b\\user@box ~/src $ \x1b]133;B\x1b\\grep -rn TODO src/", } }); p.sync(); const rowOf = struct { fn at(pp: *Pardes, pane: *Pane, needle: []const u8) ?[]const u8 { const rows = panes.Terminal.shellRows(pp, pane) catch return null; for (rows) |r| if (std.mem.indexOf(u8, r, needle) != null) return r; return null; } }.at; const bodyRowOf = struct { fn at(pp: *Pardes, pane: *Pane, needle: []const u8) ?[]const u8 { const body = panes.Terminal.bodyText(pp.scratch.allocator(), pane) catch return null; var it = std.mem.splitScalar(u8, body, '\n'); while (it.next()) |r| if (std.mem.indexOf(u8, r, needle) != null) return r; return null; } }.at; // The BODY is what you look at, and it follows the mode: in tty the pane // is the program's own screen, so the prompt is there. sp.mode = .tty; try std.testing.expectEqualStrings( "user@box ~/src $ grep -rn TODO src/", bodyRowOf(p, sp, "grep") orelse return error.MissingPromptRow, ); sp.mode = .normal; try std.testing.expectEqualStrings( "grep -rn TODO src/", bodyRowOf(p, sp, "grep") orelse return error.MissingPromptRow, ); p.shell_rows.stale = true; try std.testing.expectEqualStrings( "grep -rn TODO src/", rowOf(p, sp, "grep") orelse return error.MissingPromptRow, ); // ...and a prompt with nothing typed at it yet is all chrome, so the whole // row goes, which is what it has always done. p.update(.{ .output = .{ .pane = @intCast(shell), .bytes = "\r\n\x1b]133;A\x1b\\user@box ~/src $ \x1b]133;B\x1b\\", } }); p.sync(); p.shell_rows.stale = true; try std.testing.expect(rowOf(p, sp, "user@box") == null); } test "entering tty walks the shell cursor to the column clicked past the prompt" { if (platform == .web) return; const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .cols = 80, .rows = 24, .file = "mise.toml" }); defer p.deinit(); p.update(.{ .resize = .{ .cols = 80, .rows = 24 } }); p.update(.{ .key = .{ .cp = 'n', .alt = true } }); const shell = p.active; const sp = p.panes[shell].?; p.update(.{ .output = .{ .pane = @intCast(shell), .bytes = "\x1b]133;A;cl=line\x1b\\prompt> \x1b]133;B\x1b\\0123456789", } }); p.sync(); sp.mode = .normal; p.shell_rows.stale = true; // The command shows LEFT-HUGGED, so its column 3 is the '3'... const rows = try panes.Terminal.shellRows(p, sp); var row: i32 = 0; const at = for (rows, 0..) |r, i| { if (std.mem.indexOf(u8, r, "0123456789") != null) break i; } else return error.MissingInputRow; row = @intCast(at); try std.testing.expectEqualStrings("0123456789", rows[at]); // ...but the shell's own cursor lives on the real grid, eight cells // further right, behind the prompt this pane is not showing. sp.cur_row = row; sp.cur_col = 3; sp.cur_pinned = true; while (p.nextEffect()) |_| {} panes.Terminal.enterTty(p, shell); // The walk is arrow keys the shell understands. Seven lefts: readline's // cursor sits past the '9' and the click was on the '3'. var lefts: usize = 0; var rights: usize = 0; while (p.nextEffect()) |effect| switch (effect) { .write => |wr| { if (std.mem.eql(u8, wr.bytes.slice(), "\x1b[D")) lefts += 1; if (std.mem.eql(u8, wr.bytes.slice(), "\x1b[C")) rights += 1; }, else => {}, }; try std.testing.expectEqual(@as(usize, 0), rights); try std.testing.expectEqual(@as(usize, 7), lefts); } const board_cols: u16 = 56; const board_rows: u16 = 14; fn boardBudgetOptions(gpa: std.mem.Allocator, cols: u16, rows: u16) Options { return .{ .cols = cols, .rows = rows, .tty_only = true, .frame_allocator = gpa, .image_allocator = gpa, .pdf_allocator = gpa, .tree_sitter_allocator = gpa, }; } fn bootAndTearDown(gpa: std.mem.Allocator, cols: u16, rows: u16) !void { const p = try Pardes.init(gpa, boardBudgetOptions(gpa, cols, rows)); p.deinit(); } test "board heap: every inline ring the board pays for still fits its budget" { const heap = limits.board_heap_bytes; const effect_ring = 128 * @sizeOf(Effect); // ...and the undo history, the largest in `Pane` once the terminal is out: // 16 snapshots on the board against 256 on a desktop. const undo_history = 2 * 16 * @sizeOf(panes.Terminal.Snapshot); const grid = @as(usize, board_cols) * board_rows * (2 * @sizeOf(Cell) + @sizeOf(PanelCellDiff)); // Each of the three separately, so a failure names the one that grew // instead of reporting a total nobody can attribute. try std.testing.expect(effect_ring <= heap / 8); try std.testing.expect(undo_history <= heap / 32); try std.testing.expect(grid <= heap / 8); try std.testing.expect(effect_ring + undo_history + grid <= heap / 2); } test "board heap: transition grids fit the budget and the surface borrows its snapshots" { const per_cell = 2 * @sizeOf(Cell) + @sizeOf(PanelCellDiff); const p = try Pardes.init(std.testing.allocator, .{ .cols = 60, .rows = 16, .tty_only = true }); defer p.deinit(); var frame: std.heap.ArenaAllocator = .init(std.testing.allocator); defer frame.deinit(); const boot = try p.render(frame.allocator()); p.acknowledgePanelPresentation(boot.panelTracks()); p.settings.panel_transition = .vertical; _ = try p.newShell(1, ""); try std.testing.expect(layout.splitColumn(p, 0, 1, false)); p.sync(); _ = frame.reset(.retain_capacity); _ = try p.render(frame.allocator()); const cells = @as(usize, p.screen_w) * p.screen_h; try std.testing.expectEqual(cells, p.surface.cells.len); try std.testing.expectEqual(cells, p.presentation.previous_cells.len); try std.testing.expectEqual(cells, p.presentation.diffs.len); try std.testing.expect(p.surface.previous_cells.ptr == p.presentation.previous_cells.ptr); try std.testing.expect(p.surface.cell_diffs.ptr == p.presentation.diffs.ptr); try std.testing.expect(p.surface.cells.ptr != p.surface.previous_cells.ptr); const owned = p.surface.cells.len * @sizeOf(Cell) + p.presentation.previous_cells.len * @sizeOf(Cell) + p.presentation.diffs.len * @sizeOf(PanelCellDiff); try std.testing.expectEqual(cells * per_cell, owned); try std.testing.expect(@as(usize, board_cols) * board_rows * per_cell <= limits.board_heap_bytes / 8); } test "LSP failure clears only its matching wait without completion indentation" { if (!lsp.supports.contains(.completion)) return; const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer p.deinit(); const pane = try p.setTestFile("x"); pane.mode = .insert; pane.cur_col = 1; p.lspRequest(p.active, .completion, ""); const old_id = p.lsp_wait.?.id; p.lspRequest(p.active, .completion, ""); const current_id = p.lsp_wait.?.id; p.update(.{ .lsp_resp = .{ .id = old_id, .rows = null } }); try std.testing.expectEqual(current_id, p.lsp_wait.?.id); p.update(.{ .lsp_resp = .{ .id = current_id, .rows = null } }); try std.testing.expect(p.lsp_wait == null); try std.testing.expectEqualStrings("x", pane.file.?.content); try std.testing.expectEqual(@as(i32, 1), pane.cur_col); p.lspRequest(p.active, .completion, ""); p.update(.{ .lsp_resp = .{ .id = p.lsp_wait.?.id, .rows = "" } }); try std.testing.expect(p.lsp_wait == null); try std.testing.expectEqualStrings("x ", pane.file.?.content); try std.testing.expectEqual(@as(i32, 4), pane.cur_col); } test "board heap: every allocation failure during boot is a clean OutOfMemory" { try std.testing.checkAllAllocationFailures( std.testing.allocator, bootAndTearDown, .{ board_cols, board_rows }, ); } test "jump history preserves modal and mouse selections" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 60, .rows = 12 }); defer p.deinit(); const pane = try p.setTestFile("alpha beta\ngamma delta\nepsilon zeta\n"); pane.setRange(pane.file.?.content, 0, .{ .anchor = 1, .head = 5 }, true); pane.sel[0] = .{ .state = .done, .c0 = 2, .c1 = 5, .r0 = 1, .r1 = 1 }; const selection = pane.primaryRange(pane.file.?.content, 0); try p.capturePointerSelection(pane, 0); const mouse = pane.sel; p.njumps = 2; p.jcur = 1; p.jumps[0] = .{ .pane = 0, .serial = pane.serial, .line = 3, .col = 8 }; p.jumps[1] = .{ .pane = 0, .serial = pane.serial, .line = 1, .col = 1 }; p.update(.{ .key = .{ .cp = 'o', .ctrl = true } }); try std.testing.expectEqual(@as(usize, 0), p.jcur); try std.testing.expectEqual(@as(u32, 3), p.jumps[0].line); try std.testing.expectEqualDeep(selection, pane.primaryRange(pane.file.?.content, 0)); try std.testing.expectEqualDeep(mouse, pane.sel); p.update(.{ .key = .{ .cp = 'i', .ctrl = true } }); try std.testing.expectEqual(@as(usize, 1), p.jcur); try std.testing.expectEqualDeep(selection, pane.primaryRange(pane.file.?.content, 0)); try std.testing.expectEqualDeep(mouse, pane.sel); pane.vsel.active = false; p.jumpBy(-1); try std.testing.expectEqual(@as(i32, 2), pane.cur_row); try std.testing.expectEqual(@as(i32, 7), pane.cur_col); } test "jump history terminal rectangle follows output and clears on reflow" { if (comptime !terminal_panes) return error.SkipZigTest; const p = try Pardes.init(std.testing.allocator, .{ .cols = 60, .rows = 12, .tty_only = true }); defer p.deinit(); const pane = p.panes[0].?; p.update(.{ .output = .{ .pane = 0, .bytes = "alpha bravo\r\nsecond line\r\n" } }); pane.sel[0] = .{ .state = .done, .c0 = 0, .c1 = 4, .r0 = BOX_H, .r1 = BOX_H }; try p.capturePointerSelection(pane, 0); try std.testing.expectEqualStrings("alpha", try p.selectionText(pane, pane.sel[0])); for (0..40) |_| p.update(.{ .output = .{ .pane = 0, .bytes = "more output\r\n" } }); try std.testing.expectEqualStrings("alpha", try p.selectionText(pane, pane.sel[0])); panes.Terminal.resizeGrid(pane, p.gpa, pane.cols - 1, pane.rows); try std.testing.expect(pane.pointerSelection(pane.sel[0]) == null); try std.testing.expectEqual(.none, pane.sel[0].state); } test "mouse thumb buttons navigate once without disturbing selection gestures" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 60, .rows = 12 }); defer p.deinit(); const pane = try p.setTestFile("alpha beta\ngamma delta\nepsilon zeta\n"); pane.setRange(pane.file.?.content, 0, .{ .anchor = 1, .head = 5 }, true); const selection = pane.primaryRange(pane.file.?.content, 0); p.njumps = 3; p.jcur = 2; for (0..3) |i| p.jumps[i] = .{ .pane = 0, .serial = pane.serial, .line = @intCast(i + 1), .col = 1 }; p.drag = .{ .select = .{ .id = 0, .button = config.select_button } }; const drag = p.drag; p.update(.{ .mouse = .{ .button = .back, .kind = .press, .col = 0, .row = 0 } }); try std.testing.expectEqual(@as(usize, 1), p.jcur); for ([_]Mouse.Kind{ .drag, .release, .motion }) |kind| p.update(.{ .mouse = .{ .button = .back, .kind = kind, .col = 0, .row = 0 } }); try std.testing.expectEqual(@as(usize, 1), p.jcur); p.update(.{ .mouse = .{ .button = .forward, .kind = .press, .col = 0, .row = 0 } }); p.update(.{ .mouse = .{ .button = .forward, .kind = .release, .col = 0, .row = 0 } }); try std.testing.expectEqual(@as(usize, 2), p.jcur); try std.testing.expectEqualDeep(selection, pane.primaryRange(pane.file.?.content, 0)); try std.testing.expectEqualDeep(drag, p.drag); }