//! The pardes core: a text environment as a library, the way ghostty-vt is a //! library. The platform shell owns the event loop and all IO; it feeds this //! core events (input, pty bytes, resizes) and reads back two plain values: //! a Surface — the canonical cell-grid interface, which the tty shell hands to //! vaxis nearly verbatim and the SDL shells rasterize — and a queue of Effects, //! the IO the core wants performed (spawn a shell, write a pty, open a link). //! The core never does IO and never blocks; it is a state machine. //! //! Platform divergence inside the core is the `platform` comptime tag, used //! the way the stdlib uses os.tag. Click-on-text semantics live in look.zig. //! //! src/ layout: the core lies flat at src/, and every SUBDIRECTORY is one //! backend (tty/ gui/ lsp/) — so a file being in no directory at all is what //! says it is core, and nothing needs a header to claim it. //! //! Not one key, button or piece of Look syntax is spelled in this file: every //! one of them is a named binding in config.zig, and `hit`/`isPrefix` below are //! the only two matchers. That is so retargeting anything is an edit in one //! file, and so a later builtin can enumerate the bindings the way Help already //! enumerates the leader. const std = @import("std"); const ghostty_vt = @import("ghostty-vt"); const uucode = @import("uucode"); const mvzr = @import("mvzr"); const modal = @import("modal.zig"); const look = @import("look.zig"); const syntax = @import("syntax.zig"); const tracy = @import("tracy.zig"); const term_pane = @import("term_pane.zig"); const file_pane = @import("file_pane.zig"); const output_pane = @import("output_pane.zig"); const builtins = @import("builtins.zig"); pub const config = @import("config.zig"); pub const pdf_enabled = @import("pardes_config").mupdf; const pdf_impl = if (pdf_enabled) @import("mupdf") else struct {}; pub const image = @import("image.zig"); pub const dump = @import("dump.zig"); pub const lsp = @import("lsp/lsp.zig"); pub const Platform = enum { tty, gui, web }; pub const platform: Platform = @field(Platform, @tagName(@import("pardes_config").platform)); /// Native PDF quality is a shell property, but the core owns MuPDF and the /// RGBA cache. Kitty favors wire bandwidth; SDL favors physical-pixel text /// quality and asks the renderer to cover either fit axis without upscaling. pub const PdfRasterPolicy = struct { dpi: u16, max_dimension: u16, match_viewport: bool, }; 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, .gui => sdl_pdf_raster_policy, .web => kitty_pdf_raster_policy, }; test "SDL PDF raster policy is materially denser than Kitty" { try std.testing.expect(sdl_pdf_raster_policy.dpi >= kitty_pdf_raster_policy.dpi * 2); try std.testing.expect(sdl_pdf_raster_policy.max_dimension > kitty_pdf_raster_policy.max_dimension * 3); try std.testing.expect(!kitty_pdf_raster_policy.match_viewport); try std.testing.expect(sdl_pdf_raster_policy.match_viewport); } // The capacities and the two heights that are STRUCTURE, not taste: the // fixed-size pane/column arrays, and the fact that the topbar and a tag are // one row each (nothing here works at any other value). The layout numbers // that ARE taste — the gutter, the line-number prefix, scrolloff, the pane // minimums — live in config.zig with everything else a user retargets. pub const MAX_PANES = 16; pub const MAX_COLS = 6; /// how far back the jump stack remembers. Vim keeps 100; this is a session of /// at most sixteen panes, so the depth that matters is "more visits than you /// can hold in your head" and the oldest entry falls off the bottom. pub const MAX_JUMPS = 64; pub const TOPBAR_H: u16 = 1; pub const BOX_H: u16 = 1; /// A place the keyboard has been: a pane AND a spot in it, which is the whole /// upgrade over the stack of bare pane ids this replaces — Ctrl-o can now /// rewind WITHIN a pane, and a Jumplist row can name a line. /// /// It is SAFE against the pane it names dying: `serial` is the pane's own /// identity, so an entry whose slot has since been handed to a different pane /// reads as dead rather than silently retargeting itself at the newcomer, and /// sync() drops it. What it does not do is outlive the pane — ponytail: a /// location is a place in the SESSION, not on disk, so closing a file forgets /// the entries pointing into it. To make Ctrl-o RE-OPEN a closed file, this /// grows a path field and jumpBy looks it when the pane is gone. pub const Loc = struct { pane: u16, serial: u32, /// 1-based, both — this is the `path:LINE:COL` a look word spells, and /// focusPaneLine takes exactly these. 0 = no spot, just the pane (see /// trackJump: a shell whose cursor is still the program's). line: u32, col: u32, }; test "MuPDF pane renders, navigates, searches, and round-trips its page" { if (!pdf_enabled or platform == .web) return; const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .file = "docs/design.pdf", .cols = 80, .rows = 24, }); defer p.deinit(); const pane = p.panes[0].?; try std.testing.expect(hasPdf(pane)); try std.testing.expect(pane.pdf.?.page_count > 1); try std.testing.expectEqual(PdfFitMode.width, pane.pdf.?.fit); try std.testing.expectEqual(@as(u16, 0), pane.pdf.?.pan_x); try std.testing.expectEqual(@as(u16, 0), pane.pdf.?.pan_y); try std.testing.expectEqual(@as(u16, 8), p.cell_pixels.w); try std.testing.expectEqual(@as(u16, 16), p.cell_pixels.h); const initial_tag = try p.tagText(p.scratch.allocator(), pane); try std.testing.expect(std.mem.indexOf(u8, initial_tag, "pdf 1/") != null); try std.testing.expect(std.mem.indexOf(u8, initial_tag, " width ") != null); try std.testing.expect(std.mem.indexOf(u8, initial_tag, " height ") == null); try std.testing.expect(std.mem.indexOf(u8, initial_tag, "PdfFit") != null); try std.testing.expect(std.mem.endsWith(u8, initial_tag, pane_tail)); p.native_images = true; var frame = std.heap.ArenaAllocator.init(gpa); defer frame.deinit(); const first = try p.render(frame.allocator()); try std.testing.expectEqual(@as(usize, 1), first.nimages); const first_place = first.images[0].?; try std.testing.expect(first_place.rgba.len == first_place.iw * first_place.ih * 4); try std.testing.expectEqual(image.NativeFit.width, first_place.native.fit); const request = p.pdfRenderRequest(pane) orelse return error.MissingPdfRenderRequest; try std.testing.expectEqual(pdf_raster_policy.dpi, request.dpi); try std.testing.expectEqual(pdf_raster_policy.max_dimension, request.max_dimension); try std.testing.expectEqual(request, pane.pdf.?.render_request); try std.testing.expect(@max(first_place.iw, first_place.ih) <= request.max_dimension); if (pdf_raster_policy.match_viewport) { const viewport = p.pdfViewport(pane) orelse return error.MissingPdfViewport; try std.testing.expectEqual(viewport.pixel_w, request.minimum_width); try std.testing.expectEqual(viewport.pixel_h, request.minimum_height); try std.testing.expect(first_place.iw >= viewport.pixel_w or @max(first_place.iw, first_place.ih) == request.max_dimension); try std.testing.expect(first_place.ih >= viewport.pixel_h or @max(first_place.iw, first_place.ih) == request.max_dimension); } else { try std.testing.expectEqual(@as(u32, 0), request.minimum_width); try std.testing.expectEqual(@as(u32, 0), request.minimum_height); } // The default reading view moves one visible cell row without replacing // the page pixels. Fit/pan are placement state, not texture identity. p.update(.{ .key = .{ .cp = 'j' } }); try std.testing.expectEqual(@as(usize, 0), pane.pdf.?.page); try std.testing.expect(pane.pdf.?.pan_y > 0); try std.testing.expectEqual(first_place.native.revision, pane.pdf.?.render_revision); _ = frame.reset(.retain_capacity); const panned = try p.render(frame.allocator()); try std.testing.expectEqual(first_place.native.revision, panned.images[0].?.native.revision); try std.testing.expectEqual(pane.pdf.?.pan_y, panned.images[0].?.native.pan_y); const row_pan = pane.pdf.?.pan_y; pane.pdf.?.pan_y = 0; p.update(.{ .key = .{ .cp = 'd', .ctrl = true } }); const half_pan = pane.pdf.?.pan_y; try std.testing.expect(half_pan > row_pan); pane.pdf.?.pan_y = 0; p.update(.{ .key = .{ .cp = 'f', .ctrl = true } }); try std.testing.expect(pane.pdf.?.pan_y >= half_pan); try std.testing.expectEqual(first_place.native.revision, pane.pdf.?.render_revision); // Crossing the fit-width bottom enters the next page at its top. Crossing // back enters the previous one at its bottom, ready to keep reading up. pane.pdf.?.pan_y = std.math.maxInt(u16); p.update(.{ .key = .{ .cp = 'j' } }); try std.testing.expectEqual(@as(usize, 1), pane.pdf.?.page); try std.testing.expectEqual(@as(u16, 0), pane.pdf.?.pan_y); _ = frame.reset(.retain_capacity); const second = try p.render(frame.allocator()); try std.testing.expectEqual(@as(usize, 1), second.nimages); try std.testing.expect(second.images[0].?.native.revision != first_place.native.revision); p.update(.{ .key = .{ .cp = 'k' } }); try std.testing.expectEqual(@as(usize, 0), pane.pdf.?.page); try std.testing.expectEqual(std.math.maxInt(u16), pane.pdf.?.pan_y); // PdfFit exists as a real builtin in this build. It resets placement but // preserves the current page pixels; fit-height j/k remains page-wise. const fit_builtin = std.meta.stringToEnum(Builtin, "PdfFit") orelse return error.MissingPdfFitBuiltin; const revision_before_toggle = pane.pdf.?.render_revision; p.runBuiltin(fit_builtin, 0, "", null); try std.testing.expectEqual(PdfFitMode.height, pane.pdf.?.fit); try std.testing.expectEqual(@as(u16, 0), pane.pdf.?.pan_x); try std.testing.expectEqual(@as(u16, 0), pane.pdf.?.pan_y); try std.testing.expectEqual(revision_before_toggle, pane.pdf.?.render_revision); const height_tag = try p.tagText(p.scratch.allocator(), pane); try std.testing.expect(std.mem.indexOf(u8, height_tag, "pdf 1/") != null); try std.testing.expect(std.mem.indexOf(u8, height_tag, " width ") == null); try std.testing.expect(std.mem.indexOf(u8, height_tag, " height ") != null); try std.testing.expect(std.mem.indexOf(u8, height_tag, "PdfFit") != null); for (p.panes) |slot| { const other = slot orelse continue; if (hasPdf(other)) continue; p.togglePdfFit(other); // pane-scoped and deliberately inert here try std.testing.expectEqual(PdfFitMode.height, pane.pdf.?.fit); break; } // A fit-height landscape page exposes horizontal overflow to a horizontal // wheel without rerasterizing. Use synthetic dimensions only for the // geometry check; no frame is drawn while they differ from the buffer. const saved_iw = pane.pdf.?.iw; const saved_ih = pane.pdf.?.ih; pane.pdf.?.iw = 2000; pane.pdf.?.ih = 500; p.pdfHorizontalWheel(pane, 1); try std.testing.expect(pane.pdf.?.pan_x > 0); try std.testing.expectEqual(revision_before_toggle, pane.pdf.?.render_revision); pane.pdf.?.iw = saved_iw; pane.pdf.?.ih = saved_ih; pane.pdf.?.pan_x = 0; p.update(.{ .key = .{ .cp = 'j' } }); try std.testing.expectEqual(@as(usize, 1), pane.pdf.?.page); const revision_before_search = pane.pdf.?.render_revision; p.runSearch(0, "Pardes", .text, .top); try std.testing.expectEqualStrings("Pardes", pane.pdf.?.search_query); try std.testing.expectEqual(revision_before_search, pane.pdf.?.render_revision); const results_id = pane.search_pane orelse return error.MissingPdfSearchResults; const results = p.panes[results_id].?.file.?.content; try std.testing.expect(std.mem.indexOf(u8, results, "design.pdf:") != null); try std.testing.expect(p.searchStep(0, 1)); try std.testing.expectEqualStrings("Pardes", pane.pdf.?.search_query); // Search state is owned and untruncated, and changing pages invalidates // only page-local state while retaining the document query. const long_query = "a query deliberately longer than any tag display budget: " ++ "012345678901234567890123456789012345678901234567890123456789" ++ "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; p.setPdfSearchQuery(&pane.pdf.?, long_query); try std.testing.expectEqualStrings(long_query, pane.pdf.?.search_query); const next_page = if (pane.pdf.?.page == 0) @as(usize, 1) else 0; p.setPdfPage(pane, next_page); try std.testing.expectEqualStrings(long_query, pane.pdf.?.search_query); try p.dumpState(); const restored = try Pardes.initFromDump(gpa, .{}, p.dump_out.?); defer restored.deinit(); try std.testing.expect(hasPdf(restored.panes[0].?)); try std.testing.expectEqual(pane.pdf.?.page, restored.panes[0].?.pdf.?.page); } test "PDF n/N addresses and reveals distinct MuPDF hits on one page" { if (!pdf_enabled or platform == .web) return; const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .file = "docs/design.pdf", .cols = 120, .rows = 24, }); defer p.deinit(); const pane = p.panes[0].?; const pv = &pane.pdf.?; p.native_images = true; var page_hits = try pv.document.search(gpa, 0, "Pardes"); defer page_hits.deinit(gpa); try std.testing.expect(page_hits.hit_count >= 3); p.runSearch(0, "Pardes", .text, .top); const results_id = pane.search_pane orelse return error.MissingPdfSearchResults; const rows = p.panes[results_id].?.file.?.content; try std.testing.expect(std.mem.indexOf(u8, rows, "design.pdf:1:1 Pardes\n") != null); try std.testing.expect(std.mem.indexOf(u8, rows, "design.pdf:1:2 Pardes\n") != null); try std.testing.expect(std.mem.indexOf(u8, rows, "design.pdf:1:3 Pardes\n") != null); // Exaggerate the cell aspect only to make the three fixture hits occupy // distinct fit-width crops. The search/reveal math must use the same // reported metrics as placement, whatever a backend reports. p.update(.{ .resize = .{ .cols = p.screen_w, .rows = p.screen_h, .cell_pixels = .{ .w = 16, .h = 4 }, } }); var frame = std.heap.ArenaAllocator.init(gpa); defer frame.deinit(); try std.testing.expect(p.searchStep(0, 1)); try std.testing.expectEqual(@as(usize, 0), pv.search_hit); const first = try p.render(frame.allocator()); const revision = first.images[0].?.native.revision; const first_pan = pv.pan_y; const first_results = pv.search_results orelse return error.MissingPdfPageSearch; const first_quad = for (first_results.quads) |item| { if (item.hit == 0) break item.quad; } else return error.MissingFirstPdfHit; const first_y = Pardes.normalizedPdfPixel( (first_quad.ul.y + first_quad.ur.y + first_quad.ll.y + first_quad.lr.y) / 4, pv.ih, ); const first_geometry = p.pdfGeometry(pane) orelse return error.MissingPdfGeometry; try std.testing.expect(first_y >= first_geometry.src.y and first_y < first_geometry.src.y + first_geometry.src.h); try std.testing.expect(p.searchStep(0, 1)); try std.testing.expectEqual(@as(usize, 1), pv.search_hit); _ = frame.reset(.retain_capacity); const second = try p.render(frame.allocator()); try std.testing.expectEqual(revision, second.images[0].?.native.revision); try std.testing.expect(p.searchStep(0, 1)); try std.testing.expectEqual(@as(usize, 2), pv.search_hit); _ = frame.reset(.retain_capacity); const third = try p.render(frame.allocator()); try std.testing.expectEqual(revision, third.images[0].?.native.revision); try std.testing.expect(pv.pan_y != first_pan); const third_results = pv.search_results orelse return error.MissingPdfPageSearch; const third_quad = for (third_results.quads) |item| { if (item.hit == 2) break item.quad; } else return error.MissingThirdPdfHit; const third_y = Pardes.normalizedPdfPixel( (third_quad.ul.y + third_quad.ur.y + third_quad.ll.y + third_quad.lr.y) / 4, pv.ih, ); const third_geometry = p.pdfGeometry(pane) orelse return error.MissingPdfGeometry; try std.testing.expect(third_y >= third_geometry.src.y and third_y < third_geometry.src.y + third_geometry.src.h); } test "PDF native mouse selection, Look, and highlights share page geometry" { if (!pdf_enabled or platform == .web) return; const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .file = "docs/design.pdf", .cols = 80, .rows = 24, }); defer p.deinit(); const pane = p.panes[0].?; var frame = std.heap.ArenaAllocator.init(gpa); defer frame.deinit(); // A host without Kitty/native pixels keeps the projected-text contract: // j/k can still change pages and a body drag remains a generic selection. const fallback = try p.render(frame.allocator()); try std.testing.expectEqual(@as(usize, 0), fallback.nimages); p.update(.{ .key = .{ .cp = 'j' } }); try std.testing.expectEqual(@as(usize, 1), pane.pdf.?.page); p.update(.{ .key = .{ .cp = 'k' } }); try std.testing.expectEqual(@as(usize, 0), pane.pdf.?.page); const fallback_rect = p.rects[0]; const fallback_col = fallback_rect.x + config.GUTTER + 1; const fallback_row = fallback_rect.y + BOX_H + 1; p.update(.{ .mouse = .{ .button = config.select_button, .kind = .press, .col = fallback_col, .row = fallback_row } }); p.update(.{ .mouse = .{ .button = config.select_button, .kind = .drag, .col = fallback_col + 4, .row = fallback_row } }); p.update(.{ .mouse = .{ .button = config.select_button, .kind = .release, .col = fallback_col + 4, .row = fallback_row } }); try std.testing.expectEqual(.done, pane.sel[sel_slot].state); try std.testing.expect(pane.pdf.?.selection == null); pane.sel[sel_slot].state = .none; p.native_images = true; _ = frame.reset(.retain_capacity); const plain = try p.render(frame.allocator()); const plain_revision = plain.images[0].?.native.revision; var found = try pane.pdf.?.document.search(gpa, 0, "Pardes"); defer found.deinit(gpa); const quad = found.quads[0].quad; const nx = (quad.ul.x + quad.ur.x + quad.ll.x + quad.lr.x) / 4; const ny = (quad.ul.y + quad.ur.y + quad.ll.y + quad.lr.y) / 4; const pv = &pane.pdf.?; const source_x: u32 = @intCast(@min( pv.iw - 1, @as(usize, @intFromFloat(nx * @as(f32, @floatFromInt(pv.iw)))), )); const source_y: u32 = @intCast(@min( pv.ih - 1, @as(usize, @intFromFloat(ny * @as(f32, @floatFromInt(pv.ih)))), )); // Bring the known word into the fit-width crop, then invert the shared // source/destination geometry to the nearest body cell. var geometry = p.pdfGeometry(pane).?; if (source_y < geometry.src.y or source_y >= geometry.src.y + geometry.src.h) { const overflow = @as(u32, @intCast(pv.ih)) - geometry.src.h; const wanted = @min(overflow, source_y -| geometry.src.h / 2); pv.pan_y = if (overflow == 0) 0 else @intCast( (@as(u64, wanted) * std.math.maxInt(u16) + overflow / 2) / overflow, ); geometry = p.pdfGeometry(pane).?; } const pixel_x = geometry.dst.x + @as(u32, @intCast( @as(u64, source_x - geometry.src.x) * geometry.dst.w / geometry.src.w, )); const pixel_y = geometry.dst.y + @as(u32, @intCast( @as(u64, source_y - geometry.src.y) * geometry.dst.h / geometry.src.h, )); const r = p.rects[0]; const base_col: i32 = @intCast(r.x + config.GUTTER + pixel_x / p.cell_pixels.w); const base_row: i32 = @intCast(r.y + BOX_H + pixel_y / p.cell_pixels.h); var selected_col: ?u16 = null; var selected_row: u16 = 0; const nearby = [_]i32{ 0, -1, 1, -2, 2 }; find_word: for (nearby) |dy| for (nearby) |dx| { const col: u16 = @intCast(std.math.clamp( base_col + dx, @as(i32, r.x + config.GUTTER), @as(i32, r.x + r.w - 1), )); const row: u16 = @intCast(std.math.clamp( base_row + dy, @as(i32, r.y + BOX_H), @as(i32, r.y + r.h - 1), )); if (!p.beginPdfSelection(pane, col, row)) continue; if (std.ascii.indexOfIgnoreCase(pv.selection_text, "Pardes") != null) { selected_col = col; selected_row = row; break :find_word; } }; const word_col = selected_col orelse return error.PdfMouseMappingMissedWord; try std.testing.expect(std.ascii.indexOfIgnoreCase(p.heldSelection(0).?, "Pardes") != null); _ = frame.reset(.retain_capacity); const selected_frame = try p.render(frame.allocator()); const selected_revision = selected_frame.images[0].?.native.revision; try std.testing.expect(selected_revision != plain_revision); // Repeating an identical drag endpoint is a no-op: Kitty/SDL keep the // same texture generation instead of retransmitting identical pixels. try std.testing.expect(p.beginPdfSelection(pane, word_col, selected_row)); try std.testing.expect(pv.render_tried); _ = frame.reset(.retain_capacity); const duplicate = try p.render(frame.allocator()); try std.testing.expectEqual(selected_revision, duplicate.images[0].?.native.revision); // A native right-click resolves the same MuPDF-snapped word and feeds it // to Look. Search highlights precede selection highlights so the live // selection remains visually authoritative where they overlap. p.update(.{ .mouse = .{ .button = config.look_button, .kind = .press, .col = word_col, .row = selected_row } }); p.update(.{ .mouse = .{ .button = config.look_button, .kind = .release, .col = word_col, .row = selected_row } }); try std.testing.expect(std.ascii.indexOfIgnoreCase(pv.search_query, "Pardes") != null); p.resolvePdfSearch(pv); const highlights = try p.pdfHighlights(pv); try std.testing.expect(highlights.len > 1); try std.testing.expectEqual(pdf_impl.HighlightKind.search, highlights[0].kind); try std.testing.expectEqual(pdf_impl.HighlightKind.selection, highlights[highlights.len - 1].kind); const revision_before_reveal = pv.render_revision; pv.pan_y = std.math.maxInt(u16); p.revealPdfSearch(pane, pv); const revealed_pan = pv.pan_y; try std.testing.expect(revealed_pan != std.math.maxInt(u16)); try std.testing.expectEqual(revision_before_reveal, pv.render_revision); pv.pan_y = std.math.maxInt(u16); p.revealPdfSearch(pane, pv); // one shot: subsequent manual pan stays put try std.testing.expectEqual(std.math.maxInt(u16), pv.pan_y); pv.pan_y = revealed_pan; _ = frame.reset(.retain_capacity); const searched = try p.render(frame.allocator()); try std.testing.expect(searched.images[0].?.native.revision != selected_revision); // Fit and viewport changes can crop a hit which was already revealed. // Fit remains placement-only. SDL's physical-viewport quality request // changes with a resize and therefore replaces pixels; Kitty's fixed, // bandwidth-conscious request retains them. const revision_before_geometry_change = searched.images[0].?.native.revision; pv.search_reveal_pending = false; p.togglePdfFit(pane); try std.testing.expect(pv.search_reveal_pending); pv.search_reveal_pending = false; p.togglePdfFit(pane); // restore the reading-width geometry try std.testing.expect(pv.search_reveal_pending); pv.search_reveal_pending = false; const original_cell_pixels = p.cell_pixels; p.update(.{ .resize = .{ .cols = p.screen_w, .rows = p.screen_h, .cell_pixels = p.cell_pixels, } }); try std.testing.expect(!pv.search_reveal_pending); p.update(.{ .resize = .{ .cols = p.screen_w, .rows = p.screen_h, .cell_pixels = .{ .w = original_cell_pixels.w, .h = original_cell_pixels.h + 1, }, } }); try std.testing.expect(pv.search_reveal_pending); _ = frame.reset(.retain_capacity); const geometry_changed = try p.render(frame.allocator()); if (pdf_raster_policy.match_viewport) try std.testing.expect(geometry_changed.images[0].?.native.revision != revision_before_geometry_change) else try std.testing.expectEqual( revision_before_geometry_change, geometry_changed.images[0].?.native.revision, ); p.update(.{ .resize = .{ .cols = p.screen_w, .rows = p.screen_h, .cell_pixels = original_cell_pixels, } }); _ = frame.reset(.retain_capacity); _ = try p.render(frame.allocator()); // A fresh modal range supersedes native selection, and the select-button's // no-drag click retains its longstanding "clear selection" contract. p.update(.{ .key = .{ .cp = 'v' } }); try std.testing.expect(pv.selection == null); try std.testing.expect(pane.vsel.active); pane.vsel.active = false; try std.testing.expect(p.beginPdfSelection(pane, word_col, selected_row)); p.update(.{ .mouse = .{ .button = config.select_button, .kind = .press, .col = word_col, .row = selected_row } }); p.update(.{ .mouse = .{ .button = config.select_button, .kind = .release, .col = word_col, .row = selected_row } }); try std.testing.expect(pv.selection == null); try std.testing.expect(p.beginPdfSelection(pane, word_col, selected_row)); // Native drag events update MuPDF quads and copied text immediately, but // keep the already transmitted page generation stable until release. _ = frame.reset(.retain_capacity); const before_drag = try p.render(frame.allocator()); const before_drag_revision = before_drag.images[0].?.native.revision; const drag_col = @min(r.x + r.w - 1, word_col +| 12); const drag_row = @min(r.y + r.h - 1, selected_row +| 4); p.update(.{ .mouse = .{ .button = config.select_button, .kind = .press, .col = word_col, .row = selected_row, } }); p.update(.{ .mouse = .{ .button = config.select_button, .kind = .drag, .col = @min(drag_col, word_col +| 4), .row = @min(drag_row, selected_row +| 2), } }); _ = frame.reset(.retain_capacity); const during_first_drag = try p.render(frame.allocator()); try std.testing.expectEqual( before_drag_revision, during_first_drag.images[0].?.native.revision, ); p.update(.{ .mouse = .{ .button = config.select_button, .kind = .drag, .col = drag_col, .row = drag_row, } }); try std.testing.expect(p.drag.select.pdf.selection_changed); _ = frame.reset(.retain_capacity); const during_second_drag = try p.render(frame.allocator()); try std.testing.expectEqual( before_drag_revision, during_second_drag.images[0].?.native.revision, ); p.update(.{ .mouse = .{ .button = config.select_button, .kind = .release, .col = drag_col, .row = drag_row, } }); try std.testing.expect(!pv.render_tried); _ = frame.reset(.retain_capacity); const committed_drag = try p.render(frame.allocator()); try std.testing.expectEqual( before_drag_revision + 1, committed_drag.images[0].?.native.revision, ); _ = frame.reset(.retain_capacity); const stable_drag = try p.render(frame.allocator()); try std.testing.expectEqual( committed_drag.images[0].?.native.revision, stable_drag.images[0].?.native.revision, ); const saved_query = try gpa.dupe(u8, pv.search_query); defer gpa.free(saved_query); p.setPdfPage(pane, 1); try std.testing.expect(pv.selection == null); try std.testing.expectEqual(@as(usize, 0), pv.selection_text.len); try std.testing.expectEqualStrings(saved_query, pv.search_query); } test "PDF native Exec release may delete its own pane safely" { if (!pdf_enabled or platform == .web) return; const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .file = "docs/design.pdf", .cols = 80, .rows = 24, }); defer p.deinit(); const pane = p.panes[0].?; const pv = &pane.pdf.?; pv.selection_text = try gpa.dupe(u8, "Del"); pv.drag_anchor = .{ .x = 0.5, .y = 0.5 }; pv.drag_head = pv.drag_anchor; p.pdfDragRelease(pane, .{ .id = 0, .button = config.exec_button, .pdf = .{ .native = true }, }); try std.testing.expect(p.panes[0] == null); } test "feature-off .pdf argv opens an ordinary file pane" { if (comptime pdf_enabled or platform == .web) return; const p = try Pardes.init(std.testing.allocator, .{ .file = "docs/design.pdf", .cols = 80, .rows = 24, }); defer p.deinit(); const pane = p.panes[0].?; try std.testing.expect(pane.file != null); try std.testing.expect(pane.image == null); try std.testing.expect(!hasPdf(pane)); } const pane_tail = " " ++ config.pane_builtins_str; const file_pane_tail = " " ++ config.file_pane_builtins_str; // Dumps persist the whole rendered tag. These were the two canonical tails // before New joined every pane; restoreTail recognizes exactly these spellings // as defaults so an old session upgrades, without mistaking an edited tail for // chrome it may replace. const legacy_pane_tail = " Del"; const legacy_file_pane_tail = " Save Del"; // Builtins: executing the name (middle-click / Tab) runs it through the ONE // dispatcher (runBuiltin, reached from execute), no matter where the name // appears — and Look and Exec are two of them, so the click itself is a // builtin. One STRUCT per builtin in builtins.zig — name, comment and // body in one place — and this enum is folded out of THAT FILE'S declarations // at comptime, so the enum FIELD NAME is still the user-visible word (the one // in the topbar, the one sitting in a tag, the one Help prints, the one you // execute) and `std.meta.stringToEnum` is still the lookup with no name table // to keep in sync. It lands here rather than in builtins.zig because a // container cannot hold a decl folded out of its own decl list, and here it // sits with the other two comptime folds (builtin_rows, the topbar check). const Builtin = builtins.Builtin(); test "PdfFit builtin and leader path follow the MuPDF feature gate" { const maybe_fit = std.meta.stringToEnum(Builtin, "PdfFit"); try std.testing.expectEqual(pdf_enabled, maybe_fit != null); if (pdf_enabled) { const path = config.leader_path.get(maybe_fit.?) orelse return error.MissingPdfFitLeaderPath; try std.testing.expectEqualStrings("tz", path); } } 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(); // The last valid command wins even after unknown, malformed, and // well-formed-but-failing lines. `Kill trailing-garbage` must not be // accepted as Kill, nor fall through to the shell from config. try std.testing.expectEqualStrings("acme", p.theme().name); try std.testing.expect(!p.quit); for (p.effects.items) |effect| switch (effect) { .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 "pane-tag Exec prefers New 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 const pane = p.panes[0].?; try std.testing.expectEqualStrings(" New Del", Pardes.curTail(pane)); const tag_x = p.rects[0].x + config.GUTTER; const tag_y = p.rects[0].y; // A real middle-click on the canonical pane-tag word reaches Exec, which // must consume New as a builtin before any write can reach the shell. 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 saw_new = false; while (p.nextEffect()) |effect| switch (effect) { .new_file => |request| { try std.testing.expectEqual(@as(u8, 0), request.pane); try std.testing.expectEqual(pane.serial, request.serial); saw_new = true; }, .write => return error.NewFellThroughToPty, else => {}, }; try std.testing.expect(saw_new); // 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.clearRetainingCapacity(); try pane.tag_tail.appendSlice(gpa, " 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.clearRetainingCapacity(); try pane.tag_tail.appendSlice(gpa, " 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: std.ArrayList(u8) = .empty; defer sent.deinit(gpa); while (p.nextEffect()) |effect| switch (effect) { .write => |w| { try std.testing.expectEqual(@as(u8, 0), w.pane); try sent.appendSlice(gpa, w.bytes.slice()); }, .new_file => return error.UnknownCommandRanNew, else => {}, }; try std.testing.expectEqualStrings("DefinitelyNotABuiltin\r", sent.items); } 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(" New Del", 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)); // 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.hxOpenFileContent(""); 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 New Del", 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)); } 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.setCwd(0, "/tmp/pardes-image-dir"); const image_pane = try p.newDocPane(1); image_pane.image = .{ .path = try gpa.dupe(u8, "/tmp/pardes-image-dir/pic.ppm") }; try std.testing.expectEqualStrings("/tmp/pardes-image-dir", Pardes.paneDir(image_pane)); try std.testing.expectEqual(@as(?usize, 0), p.execute(1, "echo image-fallback")); var sent: std.ArrayList(u8) = .empty; defer sent.deinit(gpa); while (p.nextEffect()) |effect| switch (effect) { .write => |w| { try std.testing.expectEqual(@as(u8, 0), w.pane); try sent.appendSlice(gpa, w.bytes.slice()); }, .spawn => return error.ImageExecSpawnedDespiteMatchingTerminal, else => {}, }; try std.testing.expectEqualStrings("echo image-fallback\r", sent.items); } test "New completes as an empty watched file in the calling column and focuses it" { const p = try Pardes.init(std.testing.allocator, .{ .shells = 3, .cols = 100, .rows = 30 }); defer p.deinit(); while (p.nextEffect()) |_| {} // initial shell spawns const source: usize = 2; // the right column; active starts in the left const source_col = p.layoutFindTerm(source).?.col; try std.testing.expect(p.executeBuiltinLine(source, "New")); var request: ?Effect = null; while (p.nextEffect()) |effect| switch (effect) { .new_file => request = effect, else => {}, }; const new_effect = request orelse return error.MissingNewFileEffect; const req = new_effect.new_file; try std.testing.expectEqual(@as(u8, @intCast(source)), req.pane); try std.testing.expectEqual(p.panes[source].?.serial, req.serial); // Focus changes while the shell is doing IO. Placement must still use the // effect's calling-pane token, never this later-active left column. p.active = 0; try std.testing.expect(p.openNewFile(req.pane, req.serial, "/tmp/pardes-test-new-a")); const id = p.active; try std.testing.expect(id != 0 and id != source); try std.testing.expectEqual(source_col, p.layoutFindTerm(id).?.col); const sf = p.layoutFindTerm(source).?; try std.testing.expectEqual(id, p.col_terms[sf.col][sf.idx + 1]); const file = p.panes[id].?.file orelse return error.NewIsNotAFile; try std.testing.expectEqualStrings("/tmp/pardes-test-new-a", file.path); try std.testing.expectEqual(@as(usize, 0), file.content.len); try std.testing.expect(file.output == null); try std.testing.expect(!p.panes[id].?.isTerminal()); var watched = false; while (p.nextEffect()) |effect| switch (effect) { .watch => |w| if (w.pane == id and w.on) { watched = true; }, else => {}, }; try std.testing.expect(watched); // It is an ordinary file from here on: Save uses the normal file effect, // and the dump contains its path and empty content. try std.testing.expect(p.executeBuiltinLine(id, "Save")); var saved = false; while (p.nextEffect()) |effect| switch (effect) { .save_file => |s| if (s.pane == id) { saved = true; }, else => {}, }; try std.testing.expect(saved); try p.dumpState(); const state = try dump.readZon(std.testing.allocator, p.dump_out.?, "New-test"); defer dump.free(std.testing.allocator, state); var dumped = false; for (state.panes) |dp| if (dp.file) |df| { if (std.mem.eql(u8, df.path, "/tmp/pardes-test-new-a")) { try std.testing.expectEqual(@as(usize, 0), df.content.len); dumped = true; } }; try std.testing.expect(dumped); while (p.nextEffect()) |_| {} // write_dump // Del closes and unwatches, but there is intentionally no unlink effect: // adopted temporary documents preserve user data and dump restorability. try std.testing.expect(p.executeBuiltinLine(id, "Del")); try std.testing.expect(p.panes[id] == null); var unwatched = false; while (p.nextEffect()) |effect| switch (effect) { .watch => |w| if (w.pane == id and !w.on) { unwatched = true; }, else => {}, }; try std.testing.expect(unwatched); } test "New requests repeat, stay anchored, and reject stale or full completions" { const p = try Pardes.init(std.testing.allocator, .{ .shells = 3, .cols = 100, .rows = 30 }); defer p.deinit(); while (p.nextEffect()) |_| {} const source: usize = 2; const source_col = p.layoutFindTerm(source).?.col; try std.testing.expect(p.executeBuiltinLine(source, "New")); try std.testing.expect(p.executeBuiltinLine(source, "New")); var requests: [2]Effect = undefined; var n: usize = 0; while (p.nextEffect()) |effect| switch (effect) { .new_file => if (n < requests.len) { requests[n] = effect; n += 1; }, else => {}, }; try std.testing.expectEqual(requests.len, n); p.active = 0; try std.testing.expect(p.openNewFile(requests[0].new_file.pane, requests[0].new_file.serial, "/tmp/pardes-test-new-1")); const first = p.active; p.active = 1; // change focus again before the second completion try std.testing.expect(p.openNewFile(requests[1].new_file.pane, requests[1].new_file.serial, "/tmp/pardes-test-new-2")); const second = p.active; try std.testing.expect(first != second); try std.testing.expectEqual(source_col, p.layoutFindTerm(first).?.col); try std.testing.expectEqual(source_col, p.layoutFindTerm(second).?.col); try std.testing.expect(!std.mem.eql(u8, p.panes[first].?.file.?.path, p.panes[second].?.file.?.path)); while (p.nextEffect()) |_| {} // A reused pane slot cannot answer an old request, and a malformed shell // answer cannot manufacture a nameless file. const old_serial = p.panes[source].?.serial; p.panes[source].?.serial +%= 1; try std.testing.expect(!p.openNewFile(@intCast(source), old_serial, "/tmp/pardes-test-stale")); try std.testing.expect(!p.openNewFile(@intCast(source), p.panes[source].?.serial, "")); // Exhaust every remaining slot after a request was issued: completion // rejects it, which tells the shell to unlink the just-created candidate. const live_serial = p.panes[source].?.serial; for (0..MAX_PANES) |id| { if (p.panes[id] == null) _ = try p.newDocPane(id); } try std.testing.expect(!p.openNewFile(@intCast(source), live_serial, "/tmp/pardes-test-full")); try std.testing.expect(p.executeBuiltinLine(source, "New")); var emitted_when_full = false; while (p.nextEffect()) |effect| switch (effect) { .new_file => emitted_when_full = true, else => {}, }; try std.testing.expect(!emitted_when_full); } 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); } /// How a `Chord` is SPELLED in the index. The named keys come from Key's OWN /// declarations rather than a table beside them — a new special key names /// itself here, and a wrong name is impossible because there is only one. The /// rest is the printable character; anything else is a compile error, because /// a private-use codepoint cast to a byte would render as silent garbage in a /// listing nobody diffs. 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))}; } } /// Every way to run `b` that is NOT its leader path: the chords and buttons /// config binds to it, plus the topbar if it has a word up there (row 0 is a /// click target, and the only shortcut that works in tty mode where SPC /// belongs to the shell). Walked from config's own tables — window_keys' and /// jump_keys' `cmd` columns, look_cmd/exec_cmd, topbar_str — so retargeting a /// binding there re-renders here with nothing to keep in step. /// /// What is NOT here, and cannot be: a chord that reaches no builtin. Alt-n, /// Alt-c, the tty toggle and the 1-2/1-3 cut/paste chords are inline handlers /// with no word to index by. The day one of them becomes a builtin — a struct /// in builtins.zig and a `cmd` column beside its binding — it appears here for /// free, which is the upgrade path rather than a special case here. fn shortcuts(comptime b: Builtin) []const u8 { comptime { @setEvalBranchQuota(20000); var parts: []const []const u8 = &.{}; // both spellings of the four directional moves: the arrow is a real // key someone presses, and the table carries it precisely so it is // discoverable from the builtin as well as the other way round 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 = parts ++ &[_][]const u8{ pfx ++ chordName(wk.letter), pfx ++ chordName(wk.arrow) }; } for (config.jump_keys) |jk| { if (jk.cmd == b) parts = parts ++ &[_][]const u8{chordName(jk.chord)}; } // the two acme verbs, a key and a mouse button each. Asked of the // BINDING (look_cmd/exec_cmd point at a builtin) rather than of Look // and Exec by name, so pointing look_cmd at Grep moves the row. if (config.look_cmd == b) { for (config.look_key) |k| parts = parts ++ &[_][]const u8{chordName(k)}; parts = parts ++ &[_][]const u8{@tagName(config.look_button) ++ "-click"}; } if (config.exec_cmd == b) { for (config.exec_key) |k| parts = parts ++ &[_][]const u8{chordName(k)}; parts = parts ++ &[_][]const u8{@tagName(config.exec_button) ++ "-click"}; } var it = std.mem.tokenizeScalar(u8, config.topbar_str, ' '); while (it.next()) |w| { if (std.mem.eql(u8, w, @tagName(b))) parts = parts ++ &[_][]const u8{"topbar"}; } var out: []const u8 = ""; for (parts, 0..) |s, i| out = out ++ (if (i > 0) ", " else "") ++ s; return out; } } /// THE BUILTIN INDEX, flattened: one row per builtin — the leader path that /// runs it (null: SPC does not reach it), every other way to run it, and the /// Help line those are rendered into. SORTED BY PATH, so every prefix's /// subtree is a contiguous run, which is all the two runtime readers need. The /// matcher asks "exact hit? still a prefix of something?" and Help filters the /// same rows by the same prefix. A node-and-pointer trie for forty /// two-character paths would be ceremony. /// /// A row per BUILTIN and no longer a row per leader PATH, which is what makes /// Help the complete index instead of a second builtin listing a superset of /// what Help lists. A path-less builtin (Look, Exec, Theme) is a row like any /// other and its empty key column is the information: SPC does not reach this /// one, the last column does — or nothing does, which is also worth knowing. /// The mid-chord filter is the SAME array read with a non-empty prefix, and it /// drops those rows by itself because an empty path starts with nothing. 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| { var keys: []const u8 = ""; if (config.leader_path.get(b)) |path| { keys = "SPC "; for (path, 0..) |c, k| { if (k > 0) keys = keys ++ " "; keys = keys ++ &[_]u8{c}; } } // the name column is only padded when something follows it: a row // whose builtin has no other shortcut ends at the name, so the listing // carries no trailing whitespace 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, }; } // insertion sort by path: a group sorts right before what extends it, and // a path-less builtin sorts after every path — DEL is not a path because // leaderKey only ever stores a printable key 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; }; // config.topbar_str is a HAND-PICKED subset of the builtins in a fixed order, // not a derivation — see it there for why each word is in or out. This is the // check that a rename cannot silently rot it. comptime { @setEvalBranchQuota(20000); // the tokenizer walks the string one branch per byte, stringToEnum builds a map per name, and both sit behind the decl walk that folds Builtin var it = std.mem.tokenizeScalar(u8, config.topbar_str, ' '); while (it.next()) |w| { if (std.meta.stringToEnum(Builtin, w) == null) @compileError("topbar word is not a builtin: " ++ w); } } /// the whitespace-delimited word covering `col` in `str` (topbar dispatch) fn wordAtCol(str: []const u8, col: u16) []const u8 { if (col >= str.len or str[col] == ' ') return ""; 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 str[lo..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"); const rc_path = "/tmp/pardes-osc133.bash"; pub const bash_rc = \\[ -f "$HOME/.bashrc" ] && source "$HOME/.bashrc" \\PS1='\[\e]133;A;cl=line\a\]'"$PS1"'\[\e]133;B\a\]' \\PROMPT_COMMAND='printf "\e]133;D\a"'"${PROMPT_COMMAND:+;$PROMPT_COMMAND}" \\trap 'printf "\e]133;C\a"' DEBUG \\ ; // ---- theme ---- // per-button selection highlight: distinct muted colors per mouse button const SEL_BG = [3][3]u8{ .{ 0x2c, 0x46, 0x74 }, .{ 0x2c, 0x60, 0x38 }, .{ 0x64, 0x2c, 0x58 } }; const sel_fg = [3]u8{ 0xd2, 0xd2, 0xd2 }; // modal line/char selection: warm tan, reads as keyboard-driven const msel_bg = [3]u8{ 0x4a, 0x3a, 0x1e }; const msel_fg = [3]u8{ 0xd8, 0xc8, 0xa8 }; // the OTHER cursors of a multi-selection: the same tan turned down, so the // primary is the one that reads as "here" at a glance (helix does the same // with its two selection styles). Each secondary's own cursor cell is then // painted msel_fg-on-msel_bg — there is only one hardware cursor. const msel2_bg = [3]u8{ 0x2e, 0x26, 0x16 }; const msel2_fg = [3]u8{ 0xa8, 0x9c, 0x84 }; const UNDO_MAX = 256; const EDIT_UNDO_MAX = 64; // terminal snapshots copy the edit buffer; cap tighter /// Everything a theme repaints. `bg`/`fg` null = leave the host terminal's own /// default cell showing (the native-dark shape); `palette` null = let a child's /// ANSI indices reach the host untranslated. The gutter's move box lives here /// too: it used to be a pair of module constants, but a theme that wants to be /// restrained has to be able to turn the accent DOWN, and the box is the one /// piece of loud chrome on screen. The overlay/drag greys that were also /// hardcoded turned out to be the dark theme's own scroll_track/lineno/tag_fg /// spelled a second time, so they read those fields now instead. pub const Theme = struct { name: []const u8, bg: ?[3]u8, 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, }; /// The three themes pardes ships with, in RING ORDER, which is the one thing /// the files themselves cannot say: `helix` is index 0 and so what boots, and /// NextColor walks from here out into the generated ones. Written here rather /// than in a fourth file because the order IS the information — and it is /// load-bearing, since test/snapshots/theme.snap captures the first three /// steps of the ring by their colors. const curated = struct { pub const helix = @import("themes/helix.zig"); pub const dark = @import("themes/dark.zig"); pub const acme = @import("themes/acme.zig"); }; /// A zig file IS a struct, so the FILES are the list: this walks a container's /// declarations — each one an imported theme file — and copies its `theme` /// value into a real Theme. FIELD BY FIELD rather than by plain coercion, /// because a theme file deliberately imports nothing (it is data, not code) and /// zig will not coerce a whole anonymous struct into a named one; assigning one /// field at a time puts each value in a result location that knows the type, /// which is also what makes a missing field a compile error naming it. /// /// A FUNCTION and not a const for the same reason builtins.all is one: it is /// only ever a signature to whoever walks it, never a value in its own way. fn fold(comptime C: type) []const Theme { comptime { @setEvalBranchQuota(400000); // 228 themes x 15 fields, each field a @field lookup, and the array re-concatenated per theme var out: []const Theme = &.{}; for (@typeInfo(C).@"struct".decls) |d| { var t: Theme = undefined; for (@typeInfo(Theme).@"struct".fields) |f| @field(t, f.name) = @field(@field(C, d.name).theme, f.name); out = out ++ &[_]Theme{t}; } return out; } } /// The ring: ours, then every theme tools/gen_themes.zig exported out of the /// helix and zed sources in vendor/themes (build.zig runs it and hands the /// result over as a module). Adding one is dropping a file in there — there is /// no list here to append to, which is the whole point of folding the files. /// 228 of them: everything helix and zed ship, because "which of these is worth /// having" is the user's call and not the build's. That length is why ThemeSel /// exists — NextColor is a browse, not a way to arrive anywhere in particular. pub const themes = fold(curated) ++ fold(@import("generated_themes")); // Two themes answering to one name is a bug: `Theme ` resolves by name // and would silently pick whichever came first, and ThemeSel would list the // loser as a row that does nothing. The generated half cannot collide with // ITSELF — one file per theme, all imported into one struct, so zig's own // redeclaration error already catches that — which leaves exactly the cross // pair to check here, three times 225 rather than 228 squared. This is the // check that makes helix's `acme.toml` vendored as `acme_helix.toml`. 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"); } // ---- the boundary types ---- pub const Color = union(enum) { default, index: u8, rgb: [3]u8 }; 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, }; /// One surface cell. `default = true` means "never painted this frame": the /// shell renders it as the terminal's default cell (vaxis clear semantics). 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]; } }; /// One generation of a pixel attachment. `serial` identifies the pane for its /// whole lifetime; `revision` identifies pixels rendered later by that same /// pane (for example, a different PDF page or zoom level). Backends must use /// both: pane slots are reused, while a live pane may replace its pixels. pub const ImageCacheKey = if (pdf_enabled) struct { serial: u32, revision: u32, pub fn eql(a: @This(), b: @This()) bool { return a.serial == b.serial and a.revision == b.revision; } } else struct { serial: u32, pub fn eql(a: @This(), b: @This()) bool { return a.serial == b.serial; } }; /// Native PDF sizing is deliberately semantic rather than a backend knob. /// Width mode is the document-reading view (the page may be panned vertically); /// height mode keeps one whole page-height visible and may pan wide pages. /// The type remains available to shared core code, while PdfView itself and /// the builtin which mutates it are compiled away with `-Dmupdf=false`. const PdfFitMode = if (pdf_enabled) enum { width, height } else void; /// 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, fit: image.NativeFit = .contain, pan_x: u16 = 0, pan_y: u16 = 0, } else struct {}; /// A pixel image riding the surface: the shell transmits/places it over the /// given cell rect (tty: Kitty graphics; SDL: alpha-blended GPU texture). /// This is also the backend-neutral transport for rasterized PDF pages. pub const ImagePlace = struct { pane: u8, // cache slot /// 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, .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, .revision = 3 }; try std.testing.expect(first.eql(.{ .serial = 41, .revision = 3 })); try std.testing.expect(!first.eql(.{ .serial = 41, .revision = 4 })); try std.testing.expect(!first.eql(.{ .serial = 42, .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(PdfDrag)); } } 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 { 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: [MAX_PANES]?ImagePlace = @splat(null), nimages: usize = 0, 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]; } 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; } /// Print UTF-8 text into a row, no wrap, clipped to [x, x+w). Returns the /// column after the last written cell. Wide glyphs take two cells. /// /// The text is NOT trusted to be valid UTF-8 — a file pane holds whatever /// bytes are on disk (latin-1 source, an ELF opened by mistake), a path can /// be any bytes at all, and a search row splices both. std's unchecked /// iterator panics on a bad start byte, so decode by hand and paint one /// U+FFFD per undecodable byte (what a terminal does). 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; // 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; const cp_slice = if (decoded == null) "\u{FFFD}" else text[i .. i + n]; i += if (decoded == null) 1 else n; const cp = decoded orelse 0xFFFD; if (cp == '\r') continue; const width: u16 = if (cp < 0x80) 1 else uucode.get(.width, cp); if (width == 0) continue; s.set(col, y, cp_slice, 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; } 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; } }; pub const Mouse = struct { // 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 }; pub const Kind = enum { press, release, motion, drag }; button: Button, kind: Kind, col: u16, row: u16, /// Ctrl held during the click. Only the left press reads it (ctrl-click = /// goto-definition, the one chord every editor with an LSP has); every /// other button ignores it, because acme's button semantics are already /// the vocabulary here and modifiers are not part of it. ctrl: bool = false, }; pub const Key = struct { cp: u21, // codepoint or one of the specials below text: []const u8 = "", ctrl: bool = false, alt: bool = false, /// Only consulted for keys whose codepoint does NOT already carry the /// shift (Escape and friends) — `A` is `A`, not shift-`a`, so `hit()` /// ignores this field unless a binding explicitly asks for it. 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; }; /// Does this press match a binding? Every keymap test in the core goes through /// here, so the modifier rules are written once instead of once per modifier /// (the three is/isC/isA predicates this replaces each spelled out the same /// comparison with a different pair of negations). /// /// A binding is a LIST: most have two spellings that must reach the same arm — /// `h` and Left, `Ctrl-f` and PageDown — and the `or` chain that used to do /// that at every call site is now one loop here. 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; // shift is carried in the codepoint for anything printable (`A` is // `A`, not shift-`a`), so it is consulted ONLY when a binding asks for // it — otherwise every letter binding would newly demand shift be up if (c.shift and !key.shift) continue; return true; } return false; } /// `Pane.sel` is indexed by @intFromEnum(button), so this is the SELECT /// button's slot: where a sweep lands and where the left half of every acme /// chord reads its selection from. Named rather than the bare 0 it used to be /// so that config.select_button is genuinely the only place the choice is /// made. const sel_slot = @intFromEnum(config.select_button); /// The same test for a MODAL PREFIX (`g`, `z`, `m`, `mi`, `]`...). config /// spells those as bare codepoints rather than Chords because pardes stores /// the codepoint itself in pane.pending until the next key completes the /// sequence — so this is the one place the two shapes meet, and it is the same /// comparison the stored byte gets a few lines later. fn isPrefix(key: Key, cp: u21) bool { return key.cp == cp and !key.ctrl and !key.alt; } /// 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, /// Physical pixels in one grid cell, present only when native PDF /// placement is compiled in. Defaults keep headless/core callers /// useful and give terminals which cannot report pixels the /// conventional 1:2 cell aspect. cell_pixels: CellPixels = .{}, }, output: struct { pane: u8, bytes: []const u8 }, eof: struct { pane: u8 }, /// a language query the shell ran on a worker has finished. `rows` is /// `+Search`-format text (see lsp.zig) and is borrowed for this call only, /// exactly like `output` bytes. An id the core no longer recognises is a /// stale answer (the pane was closed, or a newer query superseded it) and /// is dropped. lsp_resp: struct { id: u32, rows: []const u8 }, /// a file the shell was asked to watch changed on disk; `bytes` are its /// new contents, borrowed for this call exactly like `output`. A shell /// with no filesystem (the browser) or no watcher simply never sends one — /// nothing in the core waits for it. file_changed: struct { pane: u8, bytes: []const u8 }, paste: []const u8, pinch: f32, touch_scroll: f32, tick, }; /// IO the core wants done. Payloads are inline (fixed buffers): effects are /// queued values with no lifetime ties back into the core. pub const Effect = union(enum) { spawn: struct { pane: u8, cwd: Buf(256) }, write: struct { pane: u8, bytes: Buf(64) }, resize_pty: struct { pane: u8, cols: u16, rows: u16 }, 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 }, /// Create an empty, uniquely-named temporary file. `pane` + `serial` /// identify the pane/column that issued New even if focus moves before the /// shell completes the IO. The shell calls openNewFile with the path and /// unlinks it itself when that completion rejects the request. new_file: struct { pane: u8, serial: u32 }, /// a serialized state dump is ready in core.dump_out; write it to the /// path dump.outPath resolves (acme-style: another instance loads it /// with -l, or the Restore builtin loads it into this one) write_dump, /// the yank register changed; the shell reads it off the core (OSC 52 out) set_clipboard, /// answer a language query OFF the event loop and post the rows back as an /// `lsp_resp` Event. The shell reads the file's path and content off the /// core (like save_file) and must SNAPSHOT them before the worker starts — /// the core keeps editing while this is in flight. lsp: struct { id: u32, kind: lsp.Kind, pane: u8, offset: u32, arg: Buf(128) }, /// start (`on`) or stop watching this pane's file on disk. Starting, the /// shell reads the path off the core exactly like save_file does; stopping /// carries nothing, because by the time an `off` is drained the pane is /// already freed — the shell remembers what it watches per pane id. /// Only real files ask for this: an output buffer has no file behind it. watch: struct { pane: u8, on: bool }, quit, fn Buf(comptime n: usize) type { return struct { data: [n]u8 = undefined, len: u16 = 0, pub fn slice(b: *const @This()) []const u8 { return b.data[0..b.len]; } 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; } }; } }; pub const Mode = enum { normal, insert, tty }; /// A terminal's edit buffer: real gpa-owned text standing in for the `rows` /// shell rows starting at ABSOLUTE body row `row`. It is edited by exactly /// the machinery File.content is — that is the whole point, a terminal in /// insert mode has to behave like a text file — and it rides the scrollback /// the way the char-mode insertion runs it replaced did. The vt grid itself /// is never written, so output keeps streaming underneath. /// /// Seeded from the rows it stands in for the first time you type, and grown /// whenever an edit reaches past either end (absorbing the shell rows it /// swallows). When it ends up holding MORE lines than the rows it covers — /// which is what pressing Enter does — the surface below simply slides down /// by the difference: see Pane.surfRow. pub const Ovl = struct { row: i32 = 0, rows: i32 = 1, text: []u8 = &.{}, }; /// One mouse selection (block-shaped), per button. c/r are text-area relative; /// r counts from the tag row (body starts at BOX_H). pub const Sel = struct { state: enum { none, dragging, done } = .none, c0: i32 = 0, c1: i32 = 0, r0: i32 = 0, r1: i32 = 0, }; /// A modal line selection (helix `x`): whole rows [r0, r1], absolute. pub const LineSel = struct { active: bool = false, r0: i32 = 0, r1: i32 = 0, }; /// A modal char-range selection: the anchor lives here, the head is the pane /// cursor. Since the helix motion model landed, EVERY motion leaves one of /// these — `explicit` separates user-intent selections (v / x / X / terminal /// n/N / file-search n/N) from bare motion residue: the acme Enter/Tab chords /// only act on explicit ones. Mutually exclusive with LineSel. pub const CharSel = struct { active: bool = false, row: i32 = 0, col: i32 = 0, explicit: bool = false, }; /// ponytail: at most this many cursors at once. helix's `Selection.ranges` is /// an unbounded Vec; a fixed array keeps a Pane trivially copyable (the undo /// snapshots memcpy it) and costs nothing at one cursor. The ceiling only /// bites on `C`/`Alt-s` over a very long selection, where the extra ranges are /// simply not created — raise the number, or swap in an ArrayList, if that /// ever matters. pub const MAX_SELS = 64; /// One selection range in PANE coordinates: the block-cursor cell and the /// anchor cell. Deliberately the same pair `cur_row`/`cur_col` + `vsel` /// already are, so a range moves in and out of the primary slot without a /// conversion. pub const SelRange = struct { row: i32, col: i32, arow: i32, acol: i32, /// this range's own j/k goal column (helix Range::old_visual_position); /// the PRIMARY's copy is Pane.sticky_col sticky: i32 = -1, }; /// Undo snapshot of a terminal pane's edit buffer (whole-state, dumb on /// purpose). `ovl` is null for a pane nothing has been typed into yet, and /// owns its text exactly like FileSnap does. pub const EditSnap = struct { ovl: ?Ovl, cur_row: i32, cur_col: i32, vsel: CharSel, }; /// Undo snapshot of a file pane: content + the selection at commit time — /// helix undo restores the selection along with the text. /// ponytail: the PRIMARY selection only. With multiple cursors an undo puts /// the primary back and leaves the others where they are — which is right /// whenever the undone edit is the one they made (the common case, and what /// the msel-undo differential case pins), and merely misplaced otherwise. /// Storing the whole list would put MAX_SELS ranges in every one of the 256 /// snapshots a pane keeps, for a case nobody has complained about. pub const FileSnap = struct { content: []u8, cur_row: i32, cur_col: i32, vsel: CharSel, }; /// A file pane's backing: content is gpa-owned; undo/redo are whole snapshots. pub const File = struct { path: []u8, content: []u8, /// set = this is an OUTPUT buffer (acme's +Errors): a file pane with no /// file behind it, showing text the core produced itself. It records the /// COMMAND that opened it, and output_pane.zig's one table turns that into /// every way it differs from a file — ask it, never this field's shape. /// `path` still names it (and gives its directory) so looks inside it /// resolve like anywhere else. output: ?output_pane.Output = null, scroll: usize = 0, /// THE LINE INDEX: `line_starts[i]` is the byte offset where line i begins, /// and `line_starts.len` IS the line count. Empty means "not built yet" — /// a real index always holds at least one entry, because a file always has /// at least one line. Built and read only through file_pane.lineIndex / /// nlines / lineStart; see the invalidation contract on lineIndex. line_starts: []usize = &.{}, /// one syntax.Syn byte per content byte in the highlighted window highlights: []u8 = &.{}, highlight_start: usize = 0, syntax_dirty: bool = true, undo: std.ArrayList(FileSnap) = .empty, redo: std.ArrayList(FileSnap) = .empty, }; /// An image pane's backing: the decoded+downscaled RGBA (kept so the petscii /// grid can re-render on resize/toggle) and the cached glyph grid. pub const Image = struct { path: []u8, petscii: bool = false, pmode: image.PaletteMode = .commodore, ascii: bool = true, tried: bool = false, // decode attempted — exactly once rgba: []u8 = &.{}, iw: usize = 0, ih: usize = 0, /// dump-loaded image bytes (gpa-owned), decoded lazily like a file read raw: []u8 = &.{}, grid: []image.petscii.Cell = &.{}, gw: usize = 0, gh: usize = 0, grid_w: u16 = 0, grid_h: u16 = 0, grid_mode: image.PaletteMode = .commodore, grid_ascii: bool = true, }; const PdfPoint = if (pdf_enabled) pdf_impl.Point else void; const PdfHighlight = if (pdf_enabled) pdf_impl.Highlight else void; /// A PDF pane is compiled out as a zero-bit `void` field when MuPDF is off. /// With it enabled, the pane owns one live document, one lazy current-page /// raster for SDL/Kitty, and one lazy text projection for non-pixel hosts and /// normal Pardes search semantics. const PdfView = if (pdf_enabled) struct { path: []u8, document: pdf_impl.Document, page: usize = 0, page_count: usize, fit: PdfFitMode = .width, /// Normalized 0..65535 pan positions. NativeGeometry maps the extrema /// exactly onto the source overflow, independent of page or cell size. pan_x: u16 = 0, pan_y: u16 = 0, rgba: []u8 = &.{}, /// Search/selection geometry changed without changing fit or pan. The next /// draw transactionally regenerates this one RGBA buffer with MuPDF's baked /// highlights; a second full-page buffer is never retained after the swap. highlights_dirty: bool = false, search_reveal_pending: bool = false, search_results: ?pdf_impl.SearchResults = null, selection: ?pdf_impl.Selection = null, selection_text: []u8 = &.{}, selection_anchor: ?pdf_impl.Point = null, selection_head: ?pdf_impl.Point = null, drag_anchor: ?pdf_impl.Point = null, drag_head: ?pdf_impl.Point = null, iw: usize = 0, ih: usize = 0, render_tried: bool = false, /// Last quality request attempted, successful or not. A viewport change /// gets one transactional retry; a capped/failed request does not spin on /// every frame. Fit and pan are intentionally absent from this key. render_request: pdf_impl.RenderRequest = .{}, render_request_valid: bool = false, render_revision: u32 = 0, text: []u8 = &.{}, text_tried: bool = false, text_scroll: usize = 0, /// Full, untruncated PDF query. +Search records only a display/Look copy; /// this survives page navigation while page-local quads are rebuilt. search_query: []u8 = &.{}, /// Zero-based logical MuPDF hit on the current page. Result rows encode /// this as Spot.col + 1, letting same-page occurrences reveal distinctly. search_hit: usize = 0, /// Last viewport for which search_hit was revealed. Comparing this at /// draw time covers layout/border changes without treating every frame /// (or a manual pan) as a reason to snap back to the hit. reveal_viewport_w: u32 = 0, reveal_viewport_h: u32 = 0, reveal_fit: PdfFitMode = .width, reveal_viewport_valid: bool = false, } else void; const PdfSlot = if (pdf_enabled) ?PdfView else void; fn hasPdf(pane: *const Pane) bool { return if (comptime pdf_enabled) pane.pdf != null else false; } fn hasPdfSelection(pane: *const Pane) bool { return if (comptime pdf_enabled) if (pane.pdf) |pv| pv.selection != null and pv.selection_text.len > 0 else false else false; } test "feature-off PDF pane state is zero-sized" { if (!pdf_enabled) try std.testing.expectEqual(@as(usize, 0), @sizeOf(PdfSlot)); } pub const Pane = struct { vt: ghostty_vt.Terminal, stream: ghostty_vt.TerminalStream, /// The same allocator Pardes holds. A pane already owns heap (its content, /// its emulator, its undo stacks) and Pardes frees all of it; this is here /// so the pane methods that need the file's LINE INDEX — scrollBy, /// ensureCursorVisible, lastNavRow — can build it. The alternative was /// threading an allocator through ensureCursorVisible's 33 call sites. gpa: std.mem.Allocator, /// WHICH pane this is, for anything that outlives the pane: slots are /// REUSED (freeSlot hands back the lowest free one), so a remembered id /// alone can silently come to mean an unrelated pane. Handed out by /// Pardes.next_serial and never reused. The one reader is the jump stack. serial: u32 = 0, mode: Mode = .normal, vweight: f32 = 1, cols: u16, rows: u16, greet: bool = false, file: ?File = null, image: ?Image = null, pdf: PdfSlot = if (pdf_enabled) null else {}, msel: LineSel = .{}, vsel: CharSel = .{}, /// MULTIPLE CURSORS. helix's Selection is a list of ranges plus a primary /// index; pardes keeps the PRIMARY exactly where it has always been — /// cur_row/cur_col + vsel — and the other ranges here, document-ordered /// and disjoint (helix's Selection::normalize). That split is the whole /// design: every motion, operator, renderer and mouse path in this file /// still reads one selection, so with `nsel == 0` not a byte of behaviour /// moves, and the 800 differential cases and 66 snapshots keep proving it. /// The extra ranges are driven by replaying the single-selection key /// handler once per range (see replaySels). sels: [MAX_SELS - 1]SelRange = undefined, nsel: u8 = 0, /// helix select/extend mode (`v`): motions extend the selection from its /// fixed anchor instead of replacing it. Reported as mode "select"; /// pane.mode stays .normal (insert/tty transitions drop it). select: bool = false, /// sticky goal column for j/k runs (helix old_visual_position): any /// non-vertical range write resets it to -1. sticky_col: i32 = -1, /// an `a` append session's original block-cursor cell: Esc backs the /// cursor up one grapheme and rebuilds the appended-over selection from /// here (helix doc.restore_cursor). Null outside `a` sessions. append_at: ?struct { row: i32, col: i32 } = null, /// match-mode second key in flight (`m` + i/a/s/r/d awaiting its char) pending2: u21 = 0, /// captured first char argument (`mr` holds here) pending_ch: u21 = 0, /// count prefix accumulator (0 = none). ponytail: capped at 0xffff — /// nobody types a bigger count, and it keeps the row math in i32 range. count: u32 = 0, /// last f/F/t/T motion, for Alt-. repeat find_op: u8 = 0, find_ch: u21 = 0, /// one pending prefix codepoint (0 = none): `g` `z` `m` `[` `]`, or an /// op waiting for its char argument (`f` `F` `t` `T` `r`) pending: u21 = 0, /// `/` search and the Find builtin: the pattern is typed into the tag tail /// (no popup); search_at is the tail length before the marker was appended /// (submit/cancel restore it, and the marker itself says WHICH search this /// is). Enter fills the output buffer in pane search_pane with one result /// per row; n/N walk it, search_row being the row they last landed on /// (null = fresh results, nothing stepped to yet). WHICH command filled /// that buffer is the BUFFER's own record, never a second field here — /// output_pane.resultsFrom is how `]d` asks. search_edit: bool = false, search_at: u16 = 0, search_pane: ?usize = null, search_row: ?usize = null, /// The selection an `s`/`S` input was armed on, as gap offsets over the /// motion surface. Every keystroke re-derives the preview FROM here rather /// than from the previous preview — which is what helix's regex_prompt /// does (it reverts to its snapshot before each update), what makes typing /// a pattern one character at a time land on the same answer as pasting it /// whole, and what makes Esc a plain restore with nothing else to undo. /// `nsel_snap == 0` means no such input is armed; exitTagEdit, the one /// place search_edit is ever cleared, clears it too. sel_snap: [MAX_SELS]modal.HxRange = undefined, nsel_snap: u8 = 0, sel_snap_pri: u8 = 0, /// ...including whether it was a user-intent selection: a preview IS one /// (you picked those matches), but restoring must not silently promote /// motion residue into something the acme chords will act on sel_snap_expl: bool = false, /// the editable tag tail: seeded with the default builtins on first touch; /// edited in place by a modal one-line editor sharing the pane's mode tag_tail: std.ArrayList(u8) = .empty, tag_init: bool = false, tag_edit: bool = false, tag_sel: bool = false, /// the body mode a tag edit hijacked (tags are always insert); terminals /// restore it on exit so clicking the tag never changes the pane's mode tag_mode: Mode = .normal, /// THE tag coordinate space: byte columns into the WHOLE rendered tag, /// prefix ++ tail (tagText). One space, no conversions — the renderer, the /// mouse and every motion speak it directly. The prefix is live chrome, so /// it is selectable/yankable/executable but READ-ONLY: every edit op /// measures from `edit0` (= tagPrefix().len, the first editable column) and /// does nothing left of it. tag_col: u16 = 0, tag_anchor: u16 = 0, ed_undo: std.ArrayList(EditSnap) = .empty, ed_redo: std.ArrayList(EditSnap) = .empty, /// live working directory, shell-reported (setCwd); shown in the tag and /// the base for look-resolution of relative paths. cwd: [1024]u8 = undefined, cwd_len: u16 = 0, /// modal cursor, at ABSOLUTE body rows of the pane's SURFACE (file lines, /// or the terminal's shell rows with its edit buffer standing in). Tracks /// the shell cursor until pinned by a click or a key. cur_pinned: bool = false, cur_row: i32 = 0, cur_col: i32 = 0, /// horizontal scroll, file panes only (terminals wrap at pty width, they /// never have wider lines): content columns hidden left of the gutter. /// No scrollbar — the wheel and cursor movement (with scrolloff) drive it, /// the goal is just being able to read long lines. Byte columns, like the /// rest of the file-pane code. hscroll: i32 = 0, sel: [3]Sel = @splat(.{}), /// terminals only: the typed-text buffer standing in for shell rows ovl: ?Ovl = null, /// every raw pty byte, in order — the dump/replay record. tty_stream: std.ArrayList(u8) = .empty, /// query replies ghostty computed (DSR, DA, kitty); the stream handler has /// no path to the effect queue, so they land here and sync() drains them /// into write effects. Bounded: replies are tiny escape sequences. reply: [256]u8 = undefined, reply_len: u16 = 0, pub fn cwdSlice(p: *const Pane) []const u8 { return p.cwd[0..p.cwd_len]; } pub fn isTerminal(pane: *const Pane) bool { return pane.file == null and pane.image == null and !hasPdf(pane); } pub fn pdfPath(pane: *const Pane) ?[]const u8 { if (comptime pdf_enabled) if (pane.pdf) |pv| return pv.path; return null; } pub fn pdfPage(pane: *const Pane) ?usize { if (comptime pdf_enabled) if (pane.pdf) |pv| return pv.page; return null; } /// Surface row of shell row `g`. The edit buffer's lines stand in for the /// `rows` shell rows it covers, so everything below it slides by the /// difference — the identity on files and on terminals nobody has typed /// a newline into, which is why the rest of the row math can stay naive. pub fn surfRow(pane: *const Pane, g: i32) i32 { const o = pane.ovl orelse return g; if (g <= o.row) return g; const lines: i32 = @intCast(modal.lineCount(o.text)); if (g >= o.row + o.rows) return g + lines - o.rows; return @min(g, o.row + lines - 1); // inside the buffer: its own rows } /// the inverse; every surface row inside the edit buffer maps to its anchor pub fn gridRow(pane: *const Pane, s: i32) i32 { const o = pane.ovl orelse return s; if (s <= o.row) return s; const lines: i32 = @intCast(modal.lineCount(o.text)); if (s < o.row + lines) return o.row; return s - lines + o.rows; } /// current scroll offset: file top line, or the scrollback offset pub fn scroll(pane: *Pane) i32 { if (pane.file) |f| return @intCast(f.scroll); if (comptime pdf_enabled) if (pane.pdf) |pv| return @intCast(pv.text_scroll); return pane.surfRow(@intCast(pane.vt.screens.active.pages.scrollbar().offset)); } fn scrollBy(pane: *Pane, delta: i32) void { if (pane.file) |*f| { const max: i64 = @intCast(file_pane.nlines(pane.gpa, f) -| 1); const n = std.math.clamp(@as(i64, @intCast(f.scroll)) + delta, 0, max); const next: usize = @intCast(n); if (next != f.scroll) { f.scroll = next; f.syntax_dirty = true; } } else if (hasPdf(pane)) { if (comptime pdf_enabled) { const pv = &pane.pdf.?; const max: i64 = @intCast(modal.lineCount(pv.text) -| 1); pv.text_scroll = @intCast(std.math.clamp( @as(i64, @intCast(pv.text_scroll)) + delta, 0, max, )); } } else { // the vt scrolls in SHELL rows; convert through the edit buffer const off: i32 = @intCast(pane.vt.screens.active.pages.scrollbar().offset); pane.vt.screens.active.scroll(.{ .delta_row = pane.gridRow(pane.surfRow(off) + delta) - off }); } } pub fn ensureCursorVisible(pane: *Pane) void { // scrolloff margin, shrunk on short panes so the band stays non-empty const margin: i32 = @min(config.scroll_off, @divTrunc(@as(i32, pane.rows) - 1, 2)); const off = pane.scroll(); const last = off + @as(i32, pane.rows) - 1; if (pane.cur_row < off + margin) { pane.scrollBy(pane.cur_row - margin - off); // scrollBy clamps at line 0 } else if (pane.cur_row > last - margin) { // don't scroll a file past EOF-at-bottom-row (vim's bottom clamp); // terminals overshoot harmlessly — the vt clamps at the live bottom var to = pane.cur_row + margin; if (pane.file) |*f| to = @min(to, @as(i32, @intCast(file_pane.nlines(pane.gpa, f) -| 1))); if (comptime pdf_enabled) { if (pane.pdf) |pv| to = @min(to, @as(i32, @intCast(modal.lineCount(pv.text) -| 1))); } pane.scrollBy(@max(0, to - last)); } // the horizontal mirror, files only: keep scroll_off columns of // context around the cursor (wheel-driven hscroll is exempt — it // never moves the cursor, and a cursor move pulls the view back) if (pane.file != null) { const w: i32 = @max(1, @as(i32, pane.cols) - @as(i32, config.PREFIX_W)); const hmargin: i32 = @min(config.scroll_off, @divTrunc(w - 1, 2)); if (pane.cur_col < pane.hscroll + hmargin) pane.hscroll = @max(0, pane.cur_col - hmargin) else if (pane.cur_col > pane.hscroll + w - 1 - hmargin) pane.hscroll = pane.cur_col - (w - 1 - hmargin); } } /// the last navigable row: file -> last content line; terminal -> viewport bottom fn lastNavRow(pane: *Pane) i32 { if (pane.file) |*f| return @intCast(file_pane.nlines(pane.gpa, f) -| 1); if (hasPdf(pane)) return @intCast(modal.lineCount(if (comptime pdf_enabled) pane.pdf.?.text else "") -| 1); return pane.scroll() + @as(i32, pane.rows) - 1; } fn pinCursor(pane: *Pane) void { if (pane.cur_pinned) return; if (pane.file != null or hasPdf(pane)) { pane.cur_row = pane.scroll(); pane.cur_col = 0; } else { const goff: i32 = @intCast(pane.vt.screens.active.pages.scrollbar().offset); pane.cur_row = pane.surfRow(@as(i32, @intCast(pane.vt.screens.active.cursor.y)) + goff); pane.cur_col = @intCast(pane.vt.screens.active.cursor.x); } pane.cur_pinned = true; } }; const PdfDrag = if (pdf_enabled) struct { native: bool = false, /// Drag updates keep selection geometry/text live, but their expensive /// baked raster highlight is committed once on release. selection_changed: bool = false, } else struct {}; const Drag = union(enum) { none, /// `corner_idx` is what makes this a CORNER grab: the press landed on a /// cell that is both this v-border and one of left_col's own h-borders, /// and then the one drag moves both boundaries — cur_x the column pair, /// cur_y left_col's pane pair at index corner_idx. null is a plain edge /// drag and cur_y is only carried along for the preview. /// /// It is the LEFT column's h-splits a corner honours, and only those. The /// v handle IS left_col's last cell, so left_col's horizontal hint is /// drawn straight THROUGH it while the right column's spans start one cell /// further right — the corners this finds are exactly the cells where the /// user can see the two lines cross. Honouring the right column too would /// scatter corners across rows with no visible crossing. /// /// ponytail: so a corner moves exactly TWO boundaries, never three — a /// right-column h-split that happens to sit at the same row is left alone, /// even though the eye may read the whole row as one line. Add a second /// optional index (the right column's k) here and a second applyRowSplit /// call if that ever reads as a bug rather than as restraint. border_v: struct { left_col: usize, cur_x: u16, corner_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 }, /// a left sweep along a pane's TAG row: it drives the tag's own cursor and /// selection (rendered-tag columns) rather than the body's block selection, /// which is what makes a one-line tag select like a line of text 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: PdfDrag = .{}, }, }; // The two border clamps, pulled out as plain arithmetic on plain numbers for // one reason: a CORNER drag runs both of them off the same mouse position, and // the thing that has to hold is that each one only ever looks at its own axis. // A clamp that consulted the other axis — or a single "is this point legal" // test over the pair — would freeze the whole gesture the moment either edge // hit its stop, when what the hand expects is the free axis to keep tracking // and the pinned one to sit at the wall. Being pure, they are also the piece // worth a test; see below. /// Where a vertical border drag settles: `mcol` clamped so neither column of /// the pair falls under MINW. `lx`/`lw` are the left column's x and width, /// `rw` the right column's. Degenerate pairs (a window too narrow to hold two /// minimums at all) pass the mouse through rather than snapping to a lie. 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; } /// Where a horizontal border drag settles: `mrow` clamped so either pane may /// shrink to just its tag row (BOX_H) but no further. `ay`/`ah` are the upper /// pane's y and height, `bh` the lower pane's. fn clampBorderRow(ay: u16, ah: u16, bh: u16, mrow: u16) u16 { const lo = ay + BOX_H - 1; const hi = ay +| ah +| bh -| (BOX_H + 1); 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; // the walls, spelled out: the handle is the upper pane's LAST row, so the // upper pane bottoms out with its tag row alone at ay, and the lower pane // does the same one row above the pair's end 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)); try std.testing.expectEqual(row_hi, clampBorderRow(ay, ah, bh, 999)); // 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)); // and the mirror: pinned at the bottom, x still free try std.testing.expectEqual(row_hi, clampBorderRow(ay, ah, bh, 999)); 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)); // 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)); } pub const Rect = struct { x: u16, y: u16, w: u16, h: u16 }; pub const Options = struct { tty_only: bool = false, /// initial shell panes: 1 (default) or 3 for the classic two-column boot shells: u8 = 1, /// argv FILE (resolved absolute): boot with it focused as a doc pane in /// the left column, a terminal on the launch directory in the right file: ?[]const u8 = null, file_line: usize = 0, tty_toggle: u21 = config.tty_toggle_default, /// load a dump of another instance instead of spawning shells (acme -l) load_path: ?[]const u8 = null, /// Native main fills this with the contents of the per-user config file. /// Keeping discovery out of the core makes constructors and web builds /// deterministic; when present, each line is dispatched as a builtin /// before init returns and therefore before any frontend can render. startup_config: ?[]const u8 = null, /// Initial grid. Shell contract: for LIVE sessions leave these at the /// defaults and deliver the real size as the first resize EVENT — the core /// defers each shell's greeting until after a resize (so `ls` wraps to the /// real pane width); pre-sizing here means that resize never fires and the /// greeting never runs. Pre-size only for dump loads (nothing greets, and /// it avoids reflowing replayed content twice). cols: u16 = 80, rows: u16 = 24, }; pub const Pardes = struct { gpa: std.mem.Allocator, opts: Options, screen_w: u16, screen_h: u16, cell_pixels: CellPixels = .{}, resize_count: usize = 0, panes: [MAX_PANES]?*Pane = @splat(null), // layout: columns own x by weight; panes own y by vweight within a column. ncol: usize = 0, col_weight: [MAX_COLS]f32 = @splat(1), col_terms: [MAX_COLS][MAX_PANES]usize = undefined, col_n: [MAX_COLS]usize = @splat(0), // derived each sync rects: [MAX_PANES]Rect = undefined, col_x: [MAX_COLS]u16 = undefined, col_w: [MAX_COLS]u16 = undefined, active: usize = 0, /// THE focus history: where the keyboard has BEEN, oldest first, and `jcur` /// is the entry it is at NOW (so `jumps[jcur]` is always the live spot and /// the entries past it are the ones Ctrl-i walks forward into). Maintained /// in exactly one place — sync() — and read by everything that asks "where /// was I": Back/Forward, Last, the Jumplist buffer, prevFocus when a pane /// closes, Toggleterm, and the directory order a look resolves in. /// /// One list, not two. A jumplist kept beside a focus history is two things /// to keep agreeing, and they would disagree the first time one of them /// forgot a pane the other still names. jumps: [MAX_JUMPS]Loc = undefined, njumps: usize = 0, jcur: usize = 0, /// hands out Pane.serial; monotonic, never reused next_serial: u32 = 0, theme_idx: usize = 0, colors_on: bool = true, show_debug: bool = false, /// the Crt builtin's CRT post-process, rendered by the gui shell only /// (the tty and web shells never read it) crt_on: bool = false, /// Native pixel attachments supported by the shell (Kitty graphics in a /// terminal, GPU textures in SDL). Image panes dynamically fall back to /// the PETSCII matcher without it. native_images: bool = false, quit: bool = false, drag: Drag = .none, hover_col: u16 = 0, hover_row: u16 = 0, /// touchpad drift guard, counted down in horizontal wheel ticks — see /// config.wheelTick, which owns the whole rule. Global, and clock-free on /// purpose: the core has no clock, so "recently scrolled vertically" can /// only mean "in the last few wheel events", which is all the heuristic /// needs. /// ponytail: it therefore never times out — only a horizontal tick spends /// it, so a sideways swipe an hour after a scroll still pays the toll. If /// that ever bites, clear it on any non-wheel event: a keypress or a click /// is proof the gesture ended, and still needs no clock. wheel_guard: u8 = 0, ctrl_w_pending: bool = false, /// A key is being REPLAYED over several selections (replaySels). The one /// thing the replay cannot do is let a per-pass action that is really a /// per-KEYSTROKE action fire once per range: the undo snapshot must be /// taken once, the yank register accumulates instead of being overwritten, /// and anything that opens, closes or focuses a pane (runBuiltin) or asks /// the language backend (lspRequest) happens on the first pass and stops /// the replay dead — see multiOnce. multi_on: bool = false, multi_first: bool = false, multi_stop: bool = false, /// SPC leader in flight, holding the key path typed so far (empty = just /// SPC). Global like ctrl_w_pending — there is one leader and it acts on /// the active pane, whose tag shows the pending path while it waits. leader_on: bool = false, leader_keys: [4]u8 = undefined, leader_n: u8 = 0, /// the TOPBAR holds the keyboard, parked at this column of the rendered /// row-0 line. Global like leader_on for the same reason: row 0 is not a /// pane and never will be, so its one piece of focus state cannot live on /// one. `null` = the panes have the keyboard, which is every other frame. topbar_col: ?u16 = null, 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, /// The one language query in flight. ONE, deliberately: every one of these /// is a keystroke the user is waiting on, so a second press means "I meant /// this one" — the id bump makes the older answer stale and lspResponse /// drops it. A queue would only buy the right to render an answer nobody /// is waiting for any more. /// `arg` rides along only so the buffer the answer opens can record what /// was asked (a rename's new name, a symbol query) — the query itself has /// it in the effect already. lsp_seq: u32 = 0, lsp_wait: ?struct { id: u32, kind: lsp.Kind, pane: usize, arg: Effect.Buf(128) } = null, /// pending effects, drained by the shell after each update. Growable: one /// update can emit unbounded writes (a big selection executed is chunked /// into 64-byte write effects) — a fixed ring overflows and panics. effects: std.ArrayList(Effect) = .empty, effects_head: usize = 0, /// modal yank register (gpa-owned); a yank also mirrors out to the system /// clipboard via the set_clipboard effect. yank: ?[]u8 = 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 (core stays sans-IO) restore_req: ?[]const u8 = null, restore_buf: [1024]u8 = undefined, surface: Surface = .{}, /// per-update scratch (paneCursorLines, selection text); reset each update scratch: std.heap.ArenaAllocator, pub fn init(gpa: std.mem.Allocator, opts: Options) !*Pardes { const p = try gpa.create(Pardes); p.* = .{ .gpa = gpa, .opts = opts, .screen_w = opts.cols, .screen_h = opts.rows, .scratch = .init(gpa) }; if (opts.file) |path| { // FILE argv boot: the doc focused in the left column, a terminal // on the launch directory in the right (files left, shells right) _ = initial_doc: { if (comptime pdf_enabled) if (look.isPdfPath(path)) break :initial_doc try p.openPdfView(0, path, opts.file_line); if (look.isImagePath(path)) break :initial_doc try p.openImageView(0, path); break :initial_doc try file_pane.open(p, 0, path, opts.file_line); }; const sh = try p.newPane(1); sh.greet = true; p.ncol = 2; p.col_n[0] = 1; p.col_terms[0][0] = 0; p.col_n[1] = 1; p.col_terms[1][0] = 1; } else if (opts.tty_only) { _ = try p.newPane(0); p.panes[0].?.mode = .tty; p.ncol = 1; p.col_n[0] = 1; p.col_terms[0][0] = 0; } else if (opts.shells >= 3) { // classic layout: two columns, the left one split in two. _ = try p.newPane(0); _ = try p.newPane(1); _ = try p.newPane(2); for (p.panes[0..3]) |slot| slot.?.greet = true; p.ncol = 2; p.col_n[0] = 2; p.col_terms[0][0] = 0; p.col_terms[0][1] = 1; p.col_n[1] = 1; p.col_terms[1][0] = 2; } else { // ponytail: 1 and 3 are the only boot layouts; anything else acts as 1 _ = try p.newPane(0); p.panes[0].?.greet = true; p.ncol = 1; p.col_n[0] = 1; p.col_terms[0][0] = 0; } p.sync(); p.applyStartupConfig(); p.sync(); return p; } pub fn deinit(p: *Pardes) void { for (&p.panes) |*slot| if (slot.*) |pane| { p.deinitPane(pane); slot.* = null; }; const gpa = p.gpa; if (p.yank) |y| gpa.free(y); if (p.dump_out) |d| gpa.free(d); if (p.last_dump) |d| gpa.free(d); if (p.chord_arg) |a| gpa.free(a); p.effects.deinit(gpa); p.scratch.deinit(); gpa.free(p.surface.cells); 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 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.last_dump) |d| return std.fmt.bufPrint(buf, config.topbar_str ++ " Restore {s}", .{d}) catch config.topbar_str; return config.topbar_str; } pub fn deinitPane(p: *Pardes, pane: *Pane) void { if (pane.image) |*iv| { p.gpa.free(iv.path); if (iv.rgba.len > 0) p.gpa.free(iv.rgba); if (iv.raw.len > 0) p.gpa.free(iv.raw); if (iv.grid.len > 0) p.gpa.free(iv.grid); } if (comptime pdf_enabled) { if (pane.pdf) |*pv| { p.gpa.free(pv.path); if (pv.rgba.len > 0) p.gpa.free(pv.rgba); if (pv.text.len > 0) p.gpa.free(pv.text); if (pv.search_query.len > 0) p.gpa.free(pv.search_query); if (pv.search_results) |*results| results.deinit(p.gpa); if (pv.selection) |*selection| selection.deinit(p.gpa); if (pv.selection_text.len > 0) p.gpa.free(pv.selection_text); pv.document.deinit(); } } if (pane.file) |*f| { // a watched file is going away: tell the shell to drop it. The id // comes from the slot, which every caller still has pointing at // `pane` (they null it right after) — deinitPane has never needed // one before and threading it through seven call sites for this is // worse than the scan. if (f.output == null) for (p.panes, 0..) |slot, i| { if (slot == pane) p.emit(.{ .watch = .{ .pane = @intCast(i), .on = false } }); }; p.gpa.free(f.path); p.gpa.free(f.content); if (f.line_starts.len > 0) p.gpa.free(f.line_starts); if (f.highlights.len > 0) p.gpa.free(f.highlights); for (f.undo.items) |s| p.gpa.free(s.content); f.undo.deinit(p.gpa); for (f.redo.items) |s| p.gpa.free(s.content); f.redo.deinit(p.gpa); } if (pane.ovl) |o| p.gpa.free(o.text); pane.tag_tail.deinit(p.gpa); for (pane.ed_undo.items) |sn| if (sn.ovl) |o| p.gpa.free(o.text); pane.ed_undo.deinit(p.gpa); for (pane.ed_redo.items) |sn| if (sn.ovl) |o| p.gpa.free(o.text); pane.ed_redo.deinit(p.gpa); pane.stream.deinit(); pane.vt.deinit(p.gpa); pane.tty_stream.deinit(p.gpa); p.gpa.destroy(pane); } /// Allocate a pane slot with a live terminal emulator and queue the spawn /// effect (optionally in a directory); the shell answers by forking a pty /// and wiring reads back as Event.output for this pane id. pub fn newPane(p: *Pardes, id: usize) !*Pane { return p.newShell(id, ""); } pub fn newShell(p: *Pardes, id: usize, cwd: []const u8) !*Pane { std.debug.assert(p.panes[id] == null); if (cwd.len > 256) return error.PathTooLong; // spawn effect cwd is a Buf(256) const pane = try p.gpa.create(Pane); pane.* = .{ .vt = try ghostty_vt.Terminal.init(p.gpa, .{ .cols = p.screen_w, .rows = p.screen_h, .max_scrollback = 16 * 1024 * 1024, }), .stream = undefined, .gpa = p.gpa, .cols = p.screen_w, .rows = p.screen_h, }; pane.stream = pane.vt.vtStream(); // answer terminal queries (DSR/DA/kitty) back into the pty, else // crossterm apps (nushell, helix, fzf) block on the reply forever. pane.stream.handler.effects.write_pty = term_pane.ptyReport; pane.stream.handler.effects.device_attributes = term_pane.ptyDeviceAttrs; p.next_serial += 1; pane.serial = p.next_serial; p.panes[id] = pane; p.emit(.{ .spawn = .{ .pane = @intCast(id), .cwd = .from(cwd) } }); return pane; } /// a doc pane (file/image): no pty, no spawn; a stub 1x1 emulator only /// because the shared machinery touches its allocator-owned bits. pub fn newDocPane(p: *Pardes, id: usize) !*Pane { std.debug.assert(p.panes[id] == null); const pane = try p.gpa.create(Pane); pane.* = .{ .vt = try ghostty_vt.Terminal.init(p.gpa, .{ .cols = 1, .rows = 1 }), .stream = undefined, .gpa = p.gpa, .cols = p.screen_w, .rows = p.screen_h, }; pane.stream = pane.vt.vtStream(); p.next_serial += 1; pane.serial = p.next_serial; p.panes[id] = pane; return pane; } pub fn freeSlot(p: *Pardes) ?usize { return for (p.panes, 0..) |slot, i| { if (slot == null) break i; } else null; } /// chunk arbitrary-length bytes into fixed-size write effects (order kept) pub fn emitWrite(p: *Pardes, id: usize, bytes: []const u8) void { var off: usize = 0; while (off < bytes.len) { const n = @min(bytes.len - off, 64); p.emit(.{ .write = .{ .pane = @intCast(id), .bytes = .from(bytes[off .. off + n]) } }); off += n; } } /// 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 n = @min(cwd.len, pane.cwd.len); @memcpy(pane.cwd[0..n], cwd[0..n]); pane.cwd_len = @intCast(n); } /// Complete a `new_file` effect without doing IO in the core. The shell /// has already created `path` as an empty file; we model those known empty /// bytes directly, insert immediately below the still-live calling pane in /// ITS column (never whichever column became active), focus it, and start /// the same watch every ordinary file pane has. False leaves ownership /// with the shell, which closes and unlinks the unadopted file. pub fn openNewFile(p: *Pardes, source: u8, serial: u32, path: []const u8) bool { if (path.len == 0 or source >= MAX_PANES) return false; const src = p.panes[source] orelse return false; if (src.serial != serial) return false; const sf = p.layoutFindTerm(source) orelse return false; const free = p.freeSlot() orelse return false; const path_copy = p.gpa.dupe(u8, path) catch return false; const content = p.gpa.dupe(u8, "") catch { p.gpa.free(path_copy); return false; }; const pane = p.newDocPane(free) catch { p.gpa.free(content); p.gpa.free(path_copy); return false; }; pane.file = .{ .path = path_copy, .content = content }; pane.cur_pinned = true; // sf was captured from SOURCE, not active. Nothing above this point // mutates layout, so it is still the exact calling column/index. p.layoutInsert(sf.col, sf.idx + 1, free); p.splitBelow(source, pane); p.active = free; p.emit(.{ .watch = .{ .pane = @intCast(free), .on = true } }); p.sync(); return true; } pub fn emit(p: *Pardes, e: Effect) void { p.effects.append(p.gpa, e) catch {}; } pub fn nextEffect(p: *Pardes) ?Effect { if (p.effects_head == p.effects.items.len) { p.effects.clearRetainingCapacity(); p.effects_head = 0; return null; } const e = p.effects.items[p.effects_head]; p.effects_head += 1; return e; } pub fn update(p: *Pardes, ev: Event) void { switch (ev) { .resize => |sz| { if (comptime pdf_enabled) { var before: [MAX_PANES]?PdfViewport = @splat(null); for (p.panes, 0..) |slot, id| { const pane = slot orelse continue; if (pane.pdf != null) before[id] = p.pdfViewport(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); // Compare the effective per-pane pixel viewport, not the // resize event itself: duplicate SIGWINCH notifications // must not undo a reader's manual pan. p.computeGeom(); for (p.panes, 0..) |slot, id| { const pane = slot orelse continue; if (pane.pdf) |*pv| { if (!std.meta.eql(before[id], p.pdfViewport(pane))) 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; pane.tty_stream.appendSlice(p.gpa, o.bytes) catch {}; pane.stream.nextSlice(o.bytes); // follow new output to the bottom only in tty mode; in // normal/insert the user is reading scrollback and the view // holds still while output streams in. Tag edits hijack // pane.mode to insert — the body mode sits in tag_mode. const bmode = if (pane.tag_edit) pane.tag_mode else pane.mode; if (bmode == .tty) pane.vt.screens.active.scroll(.active); }, .eof => |e| p.removePane(e.pane), .lsp_resp => |r| p.lspResponse(r.id, r.rows), .file_changed => |fc| p.fileChanged(fc.pane, fc.bytes), .key => |key| p.handleKey(key), .mouse => |m| p.handleMouse(m), .paste => |bytes| { // a shell-level paste (bracketed/SDL): load the register, paste p.setYank(bytes); if (p.panes[p.active]) |pane| p.normalPaste(pane, false); }, .pinch => |scale| p.ov_pinch_scale = scale, .touch_scroll => |delta| p.ov_touch_scroll_delta = delta, .tick => {}, } _ = p.scratch.reset(.retain_capacity); p.sync(); } fn setYank(p: *Pardes, text: []const u8) void { // Inside a multi-selection replay the register collects EVERY range's // text, in document order — the passes run last-range-first, so each // new piece goes in front of what is already there. // ponytail: helix keeps one register VALUE per range and pastes // value[i] back at range[i]; pardes has a single register, so N // cursors yank one newline-joined blob and a paste puts that whole // blob at every cursor. Written down as a differential waiver rather // than faked — a per-range register is its own feature. 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; p.emit(.{ .set_clipboard = {} }); } /// A per-KEYSTROKE action reached from inside a per-SELECTION replay — /// one that opens, closes or focuses a pane, or asks the language backend. /// It must happen once rather than once per cursor, and once it has, the /// remaining passes are meaningless (the pane they would edit may be gone), /// so the replay stops. True = this pass may go ahead. fn multiOnce(p: *Pardes) bool { if (!p.multi_on) return true; p.multi_stop = true; return p.multi_first; } // ---- tag + selection text (chord sources) ---- /// the live tag prefix: the pane's cwd/path, and nothing else (an image /// still names its kind — the renderer toggles it used to spell out are /// builtins now, under SPC t). The mode used to lead this as a word; it is /// the one character in the layout box now (renderPane), so every tagline /// starts four columns further left and spends them on the path instead. /// /// Arena-allocated like everything the renderer is handed — the tag is /// retained through the frame and a cwd can be rewritten under us by the /// next shell report, so this owns its bytes rather than lending the /// pane's. 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, "pdf {d}/{d} {s} PdfFit {s}", .{ pv.page + 1, pv.page_count, @tagName(pv.fit), pv.path }, ); if (pane.image) |iv| return std.fmt.allocPrint(arena, config.tag_image ++ " {s}", .{iv.path}); if (pane.file) |f| return arena.dupe(u8, f.path); return arena.dupe(u8, pane.cwdSlice()); } /// the editable tail: the user's edited buffer once touched, else defaults /// (a buffer with nothing to Save gets the plain tail — the table decides) fn curTail(pane: *Pane) []const u8 { if (pane.tag_init) return pane.tag_tail.items; if (pane.file) |f| if (output_pane.fileTraits(f.output).saves) return file_pane_tail; return pane_tail; } /// the tag exactly as it is rendered: prefix ++ tail. THE text tag_col and /// tag_anchor index, so the renderer, the mouse, the motions and the chord /// all read the same bytes at the same columns. fn tagText(p: *Pardes, arena: std.mem.Allocator, pane: *Pane) ![]u8 { return std.fmt.allocPrint(arena, "{s}{s}", .{ try p.tagPrefix(pane), curTail(pane) }); } /// focus the tag for editing, seeding the tail on first touch and parking /// the cursor at `col` — a column of the RENDERED tag (see tag_col). /// NEGATIVE means the first EDITABLE column, which is where `:` and a /// tagline hop land: a place no click can name, so it needs no sentinel of /// its own and the callers need no prefix length. fn enterTagEdit(p: *Pardes, pane: *Pane, col: i32) void { const edit0: i32 = @intCast((p.tagPrefix(pane) catch return).len); if (!pane.tag_init) { pane.tag_tail.appendSlice(p.gpa, curTail(pane)) catch return; pane.tag_init = true; } 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.pending = 0; const end = edit0 + @as(i32, @intCast(pane.tag_tail.items.len)); pane.tag_col = @intCast(if (col < 0) edit0 else std.math.clamp(col, 0, end)); } fn exitTagEdit(pane: *Pane) void { // tags are always insert; leaving one restores the body mode: files // back to normal, terminals to whatever they had — a click (this runs // on every body press) must never change a shell pane's mode 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.search_edit = false; // an abandoned search input 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) }; } /// the text a tag Enter/Tab chord (and `y`) acts on: the char selection, /// else the file-ish word under the cursor. Over the WHOLE rendered tag, so /// the path is a word like any other — Enter on it looks it. 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 = @min(b.hi + 1, text.len); 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; } /// tag-edit key dispatch: a modal one-line editor over the rendered tag, /// sharing the pane's mode — the cursor moves over all of it, edits reach /// only the tail. Newlines are always dropped. 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 (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; } // the prefix is live chrome, not text you own: the cursor may sit in it // (that is how the path selects), but every edit below is measured from // the first EDITABLE column and simply does nothing to the left of it. const edit0: u16 = @intCast((p.tagPrefix(pane) catch return).len); const end: u16 = edit0 + @as(u16, @intCast(pane.tag_tail.items.len)); if (key.text.len > 0) { if (pane.tag_col < edit0) return; // typing inside the path: inert for (key.text) |ch| { if (ch == '\n' or ch == '\r') continue; // one-line tag pane.tag_tail.insert(p.gpa, pane.tag_col - edit0, ch) catch return; pane.tag_col += 1; } return; } switch (key.cp) { Key.backspace => if (pane.tag_col > edit0) { _ = pane.tag_tail.orderedRemove(pane.tag_col - edit0 - 1); pane.tag_col -= 1; }, Key.left => if (pane.tag_col > 0) { pane.tag_col -= 1; }, Key.right => if (pane.tag_col < end) { pane.tag_col += 1; }, Key.home => pane.tag_col = 0, Key.end => pane.tag_col = 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.HxRange) void { const lo = @min(r.anchor, r.head); const hi = @max(r.anchor, r.head); pane.tag_col = @intCast(modal.hxCursor(text, r)); pane.tag_sel = hi > lo + 1; // a 1-wide range IS the block cursor if (pane.tag_sel) pane.tag_anchor = @intCast(if (r.head > r.anchor) lo else hi - 1); } /// normal mode ON the tag — where `:` lands. The body's own helix motions /// with the WHOLE rendered tag as a one-line document (so the path selects /// like any other text, and Enter on it looks it), plus insert entry and /// the acme chords; editing keys stay in insert (`i` then type, like the /// mouse path) and never reach left of `edit0`. 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)) return exitTagEdit(pane); // the chord: run the selection (or the word under the cursor) and drop // back into the body — the whole point of `:`. The same two bindings as // everywhere else (config.look_key / exec_key), so the command line is // `:w` by default. // Nothing under the cursor means nothing ran: the tag keeps focus. 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; } // y — yank what the chord would run: the selection, else the word under // the cursor. The path is selectable, so this is how you copy it out. if (hit(key, config.tag_yank)) { if (p.tagChordText(pane)) |txt| p.setYank(txt); return; } // insert entry (one line, so I/A are the tail's ends). The prefix is // read-only, so entering insert inside it parks at the first editable // column instead — you can never be typing into the path. 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 = (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; } // h/j/k/l — a tagline is a place in the LAYOUT, so the four letters walk // it: focus the neighbour and land on ITS tagline, still in normal mode, // so tag-to-tag navigation never drops through a body. Runs the SAME // Left/Down/Up/Right builtins `SPC w h/j/k/l` and `Ctrl-w` run, off the // SAME table (config.window_keys) — one focusDir, one binding. Only the // LETTER column: the arrows keep the in-tag grapheme motion below, so // the letters cost nothing. Nothing in that direction = stay put, tag // and all: focusDir left `active` alone, so there is nothing to undo — // EXCEPT upwards, where "nothing" is still something (see below). const dir: ?Builtin = for (config.window_keys) |wk| { if (hit(key, &.{wk.letter})) break wk.cmd; } else null; if (dir) |d| { const from = p.active; p.runBuiltin(d, from, "", null); if (p.active == from) { // above the topmost tagline is the TOPBAR — row 0, the global // one. It is not a pane, so focusDir can never reach it; this // one fallback is what makes `k` walk off the top of the // layout instead of dying there. Only from a tagline: a body's // `SPC w k`/`Ctrl-w k` keeps its pane-to-pane meaning. if (d == .Up) { exitTagEdit(pane); p.topbar_col = 0; } 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); // ...and the tail's start is the new park if (dest.tag_edit) dest.mode = .normal; return; } if (lineMotion(text, cur, key)) |r| setTagRange(pane, text, r); } /// the one-line normal-mode motion vocabulary as a helix range over `text`, /// or null when `key` is not one of them: arrows by grapheme, `0`/`$`/`^` /// (Home/End) to the ends, and the word motions, which select the span they /// traverse exactly like the body's. A pane's tag and the TOPBAR are the /// same one-line normal mode over different bytes, so the keys are read in /// one place; only `h`/`l` differ — a tagline spends them on the layout, /// the topbar has no layout — and each caller answers those itself. fn lineMotion(text: []const u8, cur: usize, key: Key) ?modal.HxRange { 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.hxWordMove(text, .{ .anchor = cur, .head = modal.nextGrapheme(text, cur) }, 1, t); } /// normal mode ON the topbar — row 0, where `k` off a top-row tagline /// lands. The same one-line vocabulary a tag has (lineMotion), plus `h`/`l` /// as plain grapheme motion: up here they have no neighbouring window to /// walk to, so they cost nothing. Enter/Tab runs the word under the cursor /// through the very dispatch a middle click on it uses, and `j` drops back /// onto a tagline. The bar is chrome with no tail of its own, so there is /// deliberately no insert mode and no selection: focus, move, run, leave. fn topbarKey(p: *Pardes, key: Key) void { var tb_buf: [1200]u8 = undefined; const bar = p.topbar(&tb_buf); // 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)) { p.topbar_col = null; // the active pane still has its body focus return; } // the chord: run the word under the cursor, exactly as a middle click // on it does. Leave the bar FIRST — `Kill` lives up here and tears the // session down, the same hazard the pane-tag chord has with `Del`. if (hit(key, config.look_key) or hit(key, config.exec_key)) { const word = wordAtCol(bar, @intCast(cur)); p.topbar_col = null; if (word.len > 0) _ = p.execute(p.active, word); return; } // j — back down onto a tagline, the mirror of the k that got you here: // the pane you came from if it still holds the top row, else the // leftmost pane that does. Recomputed, never remembered, so a pane // deleted while the bar had focus strands nobody. if (hit(key, config.topbar_down)) { var dest: ?usize = null; for (p.panes, 0..) |slot, i| { if (slot == null or p.rects[i].y != TOPBAR_H) 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.topbar_col = null; p.active = d; p.enterTagEdit(pane, -1); if (pane.tag_edit) pane.mode = .normal; 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.hxCursor(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) }); } /// 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: Sel) ![]const u8 { const arena = p.scratch.allocator(); 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 out: std.ArrayList(u8) = .empty; 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) try out.append(arena, '\n'); first = false; const b0 = @min(c0, line.len); const b1 = @min(c1 + 1, line.len); try out.appendSlice(arena, line[b0..b1]); } return out.items; } /// Is (r,c) inside the span (ar,ac)..(br,bc), in reading order? Either end /// may be given first — a selection swept upwards has its anchor after its /// head — and the coordinate SYSTEM is the caller's: screen cells for a /// mouse selection, absolute rows for a modal one. 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)); } /// 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 expandSel(p: *Pardes, pane: *Pane, sl: *Sel) void { if (sl.c0 != sl.c1 or sl.r0 != sl.r1) return; const text = p.paneText(pane) catch return; var it = std.mem.splitAny(u8, text, "\n"); var v: i32 = 0; while (it.next()) |line| : (v += 1) { if (v != sl.r0) continue; const col: usize = @intCast(@max(0, sl.c0)); if (col > line.len) return; const b = config.wordBounds(line, col); if (b.hi > b.lo) { sl.c0 = @intCast(b.lo); sl.c1 = @intCast(b.hi - 1); } return; } } /// 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(pane: *Pane) Sel { const vrow = (pane.cur_row - pane.scroll()) + @as(i32, BOX_H); const vcol = if (pane.file != null) pane.cur_col - pane.hscroll + @as(i32, config.PREFIX_W) else 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)); } /// What an execute takes as its ARGUMENT: text selected ANYWHERE (acme — /// the chord argument is whatever is selected, in any window), searched /// `first` (the pane the execute happened in), then the active pane (making /// a selection focuses its pane, so it holds the most recent one), then /// slot order. Per pane a kept left selection wins, else an explicit modal /// (v/x, n/N) one. Scratch-owned: dead at the next arena reset, so a caller /// that keeps it (the 2-1 chord) copies. 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; } /// Splice a chord argument onto what the gesture pointed at. acme's 2-1 /// chord means "run this WITH that", and that is a command LINE: `Grep` /// plus a held `foo` is the same string `Grep foo` you could have typed, /// so it goes down the one path that already knows how to split a name /// from its tail. The alternative — a second argument threaded past the /// dispatcher — is what used to be here, and it could not survive Exec /// becoming an ordinary builtin with one argument slot like every other. /// Scratch-owned; `txt` itself when there is nothing to splice. 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; } fn handleKey(p: *Pardes, key: Key) void { // the topbar holds the keyboard (`k` off the topmost tagline): row 0 is // its own one-line normal mode and owns every key until Esc or a chord. // Before the pane lookup because it needs no pane — that is the point. if (p.topbar_col != null) return p.topbarKey(key); const pane = p.panes[p.active] orelse return; // a SPC leader in flight swallows the next key, whatever it is — // before Ctrl-w, so a modified key abandons the sequence instead of // arming a second prefix on top of it if (p.leader_on) return p.leaderKey(key); // Ctrl-w prefix: helix-style directional pane focus (h/j/k/l or the // arrows), running the SAME builtins `SPC w h/j/k/l` runs off the SAME // table a tagline's own h/j/k/l reads. It stays despite the leader // covering it because it reaches one place the leader cannot: a pane // in raw tty mode never sees SPC (the shell owns every printable key), // so this is the only keyboard way out of one. 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 keeps normal/tty 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 = p.splitParent(p.active); const f = p.layoutFindTerm(src).?; p.layoutInsert(f.col, f.idx + 1, free); p.splitBelow(src, nt); p.active = free; return; } // the jump chords, global for the same reason: Ctrl-o has to get you // out of wherever you are, including a pane in raw tty mode. Above the // acme chords below, which is what makes Ctrl-i reachable at all — on // a kitty-protocol host it arrives as its own key, and where it does // not it IS Tab (0x09) and falls through to Exec, see config.jump_keys. 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 = p.layoutFindTerm(p.active).?; if (p.ncol < MAX_COLS and p.col_n[f.col] > 1) { p.absorbVWeight(p.active); p.layoutRemove(p.active); p.layoutAppendColumn(p.active); } return; } // the configured Ctrl-key, or Shift-Esc, toggles raw tty mode in and // out (terminals only); tty is deliberately off the normal editing // path. Shift-Esc needs a host that reports modifiers on Escape (the // kitty keyboard protocol); where it doesn't it arrives as a plain // Escape and still means what Escape always means. const tty_toggle = (key.ctrl and key.cp == p.opts.tty_toggle) or hit(key, config.tty_toggle_alt); if (pane.isTerminal() and tty_toggle) { if (pane.mode == .tty) { pane.mode = .normal; pane.pending = 0; } else p.enterTty(p.active); return; } // a search input in flight (`/` or Find): Enter searches, Esc abandons; // both restore the tag tail and hand focus back to the body. if (pane.search_edit and (hit(key, config.search_submit) or hit(key, config.escape))) { if (hit(key, config.search_submit)) p.submitSearch(p.active) else if (selRegexArmed(pane)) |_| // Esc on an `s`/`S` preview: back to the selection it opened // on. The empty pattern applies nothing, so this IS the // snapshot — one path, not a second restore. p.applySelRegex(pane, "", false); pane.tag_tail.shrinkRetainingCapacity(@min(pane.search_at, pane.tag_tail.items.len)); exitTagEdit(pane); pane.mode = .normal; pane.pending = 0; return; } // tag editing intercepts every other key: a modal one-line editor over // the tail, sharing the pane's mode. Above the body chords — a focused // tag owns Enter/Tab too (that IS the `:` command line). if (pane.tag_edit) { if (pane.mode == .normal) p.tagNormalKey(pane, key) else p.tagInsertKey(pane, key); // an armed `s`/`S` re-runs its pattern after EVERY keystroke: that // live preview is what makes it interactive. Through the slot, not // `pane` — a tag chord above can run a builtin that closed it. const pn = p.panes[p.active] orelse return; if (selRegexArmed(pn)) |a| p.applySelRegex(pn, a.pat, a.split); return; } // the acme chords in the body — look at / execute (config.look_key and // exec_key, Enter and Tab by default) the EXPLICIT modal selection // (v/x/X, terminal n/N, search n/N); implicit motion residue falls back // to the file-ish word under the cursor. 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; pane.pinCursor(); const explicit = (p.native_images and hasPdfSelection(pane)) or (pane.vsel.active and pane.vsel.explicit) or pane.msel.active; if (explicit) { if (p.currentSelText(pane)) |txt| { pane.vsel.active = false; pane.msel.active = false; pane.select = false; p.runBuiltin(cmd, p.active, "", txt); return; } } var sel = cursorWordSel(pane); p.expandSel(pane, &sel); const word = p.selectionText(pane, sel) catch return; p.runBuiltin(cmd, p.active, "", word); return; } if (pane.mode == .normal and p.pdfNavigationKey(pane, key)) return; // normal-mode `p`/`P` paste the core's yank register directly (helix: // the default register, NOT the system clipboard — most terminals // refuse the OSC 52 read, so a round trip would never come back). // Skipped while a prefix waits for its char argument (f/r/m and // friends take `p` literally). if (pane.mode == .normal and pane.pending == 0 and (hit(key, config.paste_after) or hit(key, config.paste_before))) { const before = hit(key, config.paste_before); if (pane.nsel == 0) p.normalPaste(pane, before) else p.replaySels(pane, if (before) .paste_before else .paste_after, key); return; } switch (pane.mode) { .normal => { const id = p.active; const serial = pane.serial; p.handleNormal(pane, key); // A new keyboard/modal range supersedes a mouse-native PDF // selection. Otherwise currentSelText would keep returning the // stale MuPDF text and make v/x appear to select nothing. const live = p.panes[id] orelse return; if (live.serial == serial and hasPdfSelection(live) and (live.vsel.active or live.msel.active or live.nsel > 0)) p.clearPdfSelection(live); }, .insert => { if (hit(key, config.escape)) { pane.mode = .normal; pane.msel.active = false; pane.pending = 0; // leaving an `a` append session: the cursor backs up one // grapheme and the appended-over span becomes the // implicit selection (helix doc.restore_cursor) if (pane.append_at) |aa| { pane.append_at = null; const pl = p.paneCursorLines(pane) catch return; const text = p.flatSurface(pane, pl) catch return; const gap = modal.hxOff(text, .{ .row = @intCast(@max(0, pane.cur_row)), .col = @intCast(@max(0, pane.cur_col)) }); const a_off = modal.hxOff(text, .{ .row = @intCast(@max(0, aa.row)), .col = @intCast(@max(0, aa.col)) }); // helix restore_cursor is Range::new(from, prev(to)) on // the GAP range, so it can never walk back past the // append origin — a session whose edits ate everything // typed collapses ONTO it. Without the clamp the cursor // lands one cell before where the append began (`la`, // Backspace, Esc). const back = @max(a_off, modal.prevGrapheme(text, gap)); const bc = modal.hxPos(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(); // The other cursors move the same way the primary did — // every one of them was handed the same keys, so every // session shrank by the same grapheme — but they // COLLAPSE rather than span: only the primary remembers // where its append began (append_at is one field). // ponytail: helix restores every range's appended-over // span; store an origin per SelRange if that matters. for (pane.sels[0..pane.nsel]) |*s| { const sgap = modal.hxOff(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.hxPos(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 => p.forwardKey(p.active, key), } } /// A key after SPC: walk the leader tree (leader_rows, the comptime table). /// `?` at any depth opens Help scoped to the path typed so far; an exact /// path runs its builtin with no arguments; a key that only extends a /// group keeps waiting. ANYTHING else abandons the sequence — a typo must /// not leave the next keystroke armed at a builtin that closes panes, and /// the indicator vanishing is the receipt (vim and helix drop unmapped /// leader keys the same way). Esc lands here as one of those. 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) return output_pane.openHelp(p, p.active, p.leader_keys[0..p.leader_n]); 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; } } } /// Enter raw tty. If the shell is at a prompt and the modal cursor sits on /// the input line, move the shell's REAL cursor there via ghostty's /// promptClickMove (arrow keys the shell understands) — dropping into tty /// lands where you navigated. Requires OSC 133 cl=line (our bash rc). fn enterTty(p: *Pardes, id: usize) void { const pane = p.panes[id] orelse return; const screen = pane.vt.screens.active; if (pane.vt.cursorIsAtPrompt()) { // the pin is a GRID position: convert the modal cursor's surface // row back through the edit buffer const goff: i32 = @intCast(screen.pages.scrollbar().offset); const vp_row: i32 = pane.gridRow(pane.cur_row) - goff; if (vp_row >= 0) { if (screen.pages.pin(.{ .viewport = .{ .x = @intCast(@max(0, pane.cur_col)), .y = @intCast(vp_row) } })) |click_pin| { const cursor_pin = screen.cursor.page_pin.*; var pit = cursor_pin.promptIterator(.left_up, null); if (pit.next()) |prompt_pin| { if (!click_pin.before(prompt_pin)) { const m = screen.promptClickMove(click_pin); var i: usize = 0; while (i < m.left) : (i += 1) p.emitWrite(id, "\x1b[D"); i = 0; while (i < m.right) : (i += 1) p.emitWrite(id, "\x1b[C"); } } } } } pane.mode = .tty; pane.msel.active = false; pane.vsel.active = false; pane.nsel = 0; // raw tty: the program owns the pane, and its cursor is the shell's pane.select = false; pane.append_at = null; pane.sticky_col = -1; pane.pending = 0; } /// move focus to the nearest pane in `dir` of `from` (overlap-aware /// nearest edge). `from` is the pane the builtin ran on, which is the /// active one for a key but the CLICKED one for a name executed in a tag. pub fn focusDir(p: *Pardes, from: usize, dir: enum { left, right, up, down }) void { const a = p.rects[from]; var best: ?usize = null; var best_d: i32 = 0; for (p.panes, 0..) |slot, i| { if (slot == null or i == from) continue; const r = p.rects[i]; const vov = a.y < r.y + r.h and r.y < a.y + a.h; const hov = a.x < r.x + r.w and r.x < a.x + a.w; const ok = switch (dir) { .left => r.x + r.w <= a.x and vov, .right => r.x >= a.x + a.w and vov, .up => r.y + r.h <= a.y and hov, .down => r.y >= a.y + a.h and hov, }; if (!ok) continue; const d: i32 = switch (dir) { .left => @as(i32, a.x) - @as(i32, r.x + r.w), .right => @as(i32, r.x) - @as(i32, a.x + a.w), .up => @as(i32, a.y) - @as(i32, r.y + r.h), .down => @as(i32, r.y) - @as(i32, a.y + a.h), }; if (best == null or d < best_d) { best = i; best_d = d; } } if (best) |b| { p.active = b; // a count typed before the hop was meant for the pane you left p.panes[b].?.pending = 0; } } // ---- move-drag placement ---- const MovePlacement = struct { preview_col: usize, above_id: usize, row: u16, above_y: u16, above_h: u16, }; fn targetColumn(p: *Pardes, cur_x: u16) usize { var tc: usize = if (p.ncol > 0) p.ncol - 1 else 0; for (0..p.ncol) |c| { if (cur_x >= p.col_x[c] and cur_x < p.col_x[c] + p.col_w[c]) { tc = c; break; } } return tc; } fn splitRowForExtent(y: u16, h: u16, cur_y: u16) ?u16 { if (h < 2) return null; const min_each: u16 = if (h >= config.MINH * 2) config.MINH else 1; const lo = y +| min_each; const hi = y + h - min_each; if (lo > hi) return y + h / 2; return std.math.clamp(cur_y, lo, hi); } fn movePlacement(p: *Pardes, id: usize, cur_x: u16, cur_y: u16) ?MovePlacement { const src = p.layoutFindTerm(id) orelse return null; const tc = p.targetColumn(cur_x); if (tc == src.col and p.col_n[src.col] == 1) return null; if (tc == src.col) { const sr = p.rects[id]; if (cur_y >= sr.y and cur_y < sr.y + sr.h) return null; } var heights: [MAX_PANES]u16 = @splat(0); for (0..p.ncol) |c| { for (0..p.col_n[c]) |k| { const pid = p.col_terms[c][k]; heights[pid] = p.rects[pid].h; } } if (p.col_n[src.col] > 1) { const sib = if (src.idx > 0) p.col_terms[src.col][src.idx - 1] else p.col_terms[src.col][src.idx + 1]; heights[sib] +|= p.rects[id].h; } var y: u16 = TOPBAR_H; var last: ?MovePlacement = null; for (0..p.col_n[tc]) |k| { const pid = p.col_terms[tc][k]; if (pid == id) continue; const h = heights[pid]; const row = splitRowForExtent(y, h, cur_y) orelse { y +|= h; continue; }; const placement: MovePlacement = .{ .preview_col = tc, .above_id = pid, .row = row, .above_y = y, .above_h = h, }; last = placement; if (cur_y < y + h) return placement; y +|= h; } return last; } /// drop pane `id` below the pane under the cursor, converting on-screen /// heights to weights so ONLY the split pane changes size fn moveTerm(p: *Pardes, id: usize, cur_x: u16, cur_y: u16) void { const placement = p.movePlacement(id, cur_x, cur_y) orelse return; const src = p.layoutFindTerm(id) orelse return; const source_multi = p.col_n[src.col] > 1; var heights: [MAX_PANES]u16 = @splat(0); for (0..p.ncol) |c| { for (0..p.col_n[c]) |k| { const pid = p.col_terms[c][k]; heights[pid] = p.rects[pid].h; } } p.layoutRemove(id); if (source_multi and src.col < p.ncol and p.col_n[src.col] > 0) { const sib = if (src.idx > 0) p.col_terms[src.col][src.idx - 1] else p.col_terms[src.col][src.idx]; heights[sib] +|= p.rects[id].h; } const af = p.layoutFindTerm(placement.above_id) orelse return; const upper_h = @max(1, placement.row -| placement.above_y); const lower_h = @max(1, placement.above_h -| upper_h); heights[placement.above_id] = upper_h; heights[id] = lower_h; p.layoutInsert(af.col, af.idx + 1, id); p.setColumnWeights(af.col, &heights); if (source_multi and src.col < p.ncol and src.col != af.col) p.setColumnWeights(src.col, &heights); } fn setColumnWeights(p: *Pardes, col: usize, heights: *const [MAX_PANES]u16) void { if (col >= p.ncol) return; for (0..p.col_n[col]) |k| { const pid = p.col_terms[col][k]; if (p.panes[pid]) |pane| pane.vweight = @floatFromInt(@max(1, heights[pid])); } } // ---- helix-modal normal mode ---- const PaneLines = struct { lines: []const []const u8, row0: i32, // absolute row of lines[0] }; fn ensurePdfText(p: *Pardes, pane: *Pane) ![]const u8 { if (comptime !pdf_enabled) return error.PdfDisabled; const pv = &(pane.pdf orelse return error.NotPdf); if (!pv.text_tried) { pv.text_tried = true; pv.text = pv.document.pageText(p.gpa, pv.page) catch &.{}; } return pv.text; } /// The lines the cursor moves over, absolute rows. File: all content lines. /// Terminal: the whole history+active grid with the edit buffer's lines /// standing in for the rows it covers, so motions ride the scrollback and /// the typed text alike. Scratch-arena backed. /// (pub only for test/hxdiff.zig — the helix differential harness dumps /// this surface as a tty case's final text.) pub fn paneCursorLines(p: *Pardes, pane: *Pane) !PaneLines { const arena = p.scratch.allocator(); if (pane.file) |*f| { // straight off the line index: exact size (no ArrayList regrowth // copying a 300k-entry table forward) and no second scan for the // newlines the index already found. Identical to splitting the // content — a file's line count is its newline count plus one. const idx = try file_pane.lineIndex(pane.gpa, f); const ls = try arena.alloc([]const u8, idx.len); for (idx, 0..) |s, i| { const e = if (i + 1 < idx.len) idx[i + 1] - 1 else f.content.len; ls[i] = f.content[s..e]; } return .{ .lines = ls, .row0 = 0 }; } if (hasPdf(pane)) { const text = try p.ensurePdfText(pane); var ls: std.ArrayList([]const u8) = .empty; var it = std.mem.splitScalar(u8, text, '\n'); while (it.next()) |line| try ls.append(arena, line); return .{ .lines = ls.items, .row0 = 0 }; } const rows = try term_pane.shellRows(p, pane); // the buffer may sit below the dump's last row (the grid's trailing // blanks are trimmed off), so run the loop to whichever ends later var last = rows.len; if (pane.ovl) |o| last = @max(last, @as(usize, @intCast(@max(0, o.row + o.rows)))); var ls: std.ArrayList([]const u8) = .empty; var g: usize = 0; while (g < last) : (g += 1) { if (pane.ovl) |o| if (o.row >= 0 and g == @as(usize, @intCast(o.row))) { var bit = std.mem.splitScalar(u8, o.text, '\n'); while (bit.next()) |ln| try ls.append(arena, ln); g += @intCast(o.rows - 1); continue; }; try ls.append(arena, if (g < rows.len) rows[g] else ""); } return .{ .lines = ls.items, .row0 = 0 }; } fn toModalCursor(pane: *Pane, pl: PaneLines) modal.Cursor { const r: i32 = pane.cur_row - pl.row0; return .{ .row = @intCast(@max(0, r)), .col = @intCast(@max(0, pane.cur_col)) }; } fn fromModalCursor(pane: *Pane, pl: PaneLines, c: modal.Cursor) void { pane.cur_row = @as(i32, @intCast(c.row)) + pl.row0; pane.cur_col = @intCast(c.col); pane.cur_pinned = true; } // ---- helix range plumbing (see modal.zig "helix range engine") ---- // The pane's cursor + vsel cells render ONE helix gap range over the flat // motion surface. Every motion builds the current range, transforms it the // way the helix command would, and writes it back: normal mode REPLACES // the selection with the motion's range, select mode (v) extends it via // put_cursor. The differential suite (zig build hxdiff) pins all of this // against a real helix, key for key. /// the flat motion surface: file content as-is; terminals join the /// cursor-lines dump (scratch-arena backed, same lifetime as pl) fn flatSurface(p: *Pardes, pane: *Pane, pl: PaneLines) ![]const u8 { if (pane.file) |f| return f.content; if (hasPdf(pane)) return p.ensurePdfText(pane); const arena = p.scratch.allocator(); var buf: std.ArrayList(u8) = .empty; for (pl.lines, 0..) |ln, i| { if (i > 0) try buf.append(arena, '\n'); try buf.appendSlice(arena, ln); } return buf.items; } /// modal.hxOff and modal.hxPos, answered from the file's LINE INDEX when /// `text` is that file's content — which is exactly what flatSurface hands /// back for a file pane, and so what every motion in a document goes /// through. The modal pair has to scan the buffer for the same two numbers /// (a newline count and a walk to the line start), and on a multi-MB file /// those scans were the bulk of a keystroke. /// /// The identity check is the whole safety argument: anything that is NOT /// this file's content — a terminal's joined motion surface, an edit /// buffer, a search haystack — falls through to the scan, where it is O(a /// screenful) anyway. The two branches must agree exactly or the 481 /// differential and 561 parity cases say so; the arithmetic below is /// hxOff/hxPos transcribed, with line i's terminator read off idx[i+1]-1 /// instead of searched for. fn indexOf(pane: *Pane, text: []const u8) ?[]const usize { const f = if (pane.file) |*ff| ff else return null; if (text.ptr != f.content.ptr or text.len != f.content.len) return null; return file_pane.lineIndex(pane.gpa, f) catch null; } fn paneOff(pane: *Pane, text: []const u8, c: modal.Cursor) usize { const idx = indexOf(pane, text) orelse return modal.hxOff(text, c); const row = @min(c.row, idx.len - 1); const s = idx[row]; const e = if (row + 1 < idx.len) idx[row + 1] - 1 else text.len; return @min(s + c.col, e); } /// modal.lineStartOffset, same contract (row past the end -> text.len) fn paneLineStart(pane: *Pane, text: []const u8, row: usize) usize { const idx = indexOf(pane, text) orelse return modal.lineStartOffset(text, row); return if (row >= idx.len) text.len else idx[row]; } fn panePos(pane: *Pane, text: []const u8, off: usize) modal.Cursor { const idx = indexOf(pane, text) orelse return modal.hxPos(text, off); const o = @min(off, text.len); // the last line start at or before `o`; idx is sorted and idx[0] == 0, // so upperBound never returns 0 const row = std.sort.upperBound(usize, idx, o, struct { fn cmp(key: usize, item: usize) std.math.Order { return std.math.order(key, item); } }.cmp) - 1; return .{ .row = row, .col = o - idx[row] }; } /// the current selection as a helix gap range over `text`, whose first /// line is absolute row `row0` (0 for the motion surface and for file /// content; a terminal's edit buffer starts wherever it was anchored) fn paneRange(pane: *Pane, text: []const u8, row0: i32) modal.HxRange { const c = paneOff(pane, text, .{ .row = @intCast(@max(0, pane.cur_row - row0)), .col = @intCast(@max(0, pane.cur_col)) }); if (pane.msel.active) { // legacy line selection (file-search results highlight): linewise 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 s = modal.lineStartOffset(text, r0); const e = if (r1 + 1 >= modal.hxLineCount(text)) text.len else modal.lineStartOffset(text, r1 + 1); return .{ .anchor = s, .head = @max(e, modal.nextGrapheme(text, c)) }; } if (pane.vsel.active) return cellRange(text, pane.vsel.row - row0, pane.vsel.col, pane.cur_row - row0, pane.cur_col); return .{ .anchor = c, .head = modal.nextGrapheme(text, c) }; } /// a gap range from its two block-cursor CELLS — the arithmetic paneRange /// does for cur/vsel, shared with the extra selections, which are stored /// in exactly the same shape fn cellRange(text: []const u8, arow: i32, acol: i32, hrow: i32, hcol: i32) modal.HxRange { const a = modal.hxOff(text, .{ .row = @intCast(@max(0, arow)), .col = @intCast(@max(0, acol)) }); const c = modal.hxOff(text, .{ .row = @intCast(@max(0, hrow)), .col = @intCast(@max(0, hcol)) }); return cellOffRange(text, a, c); } /// the same, from the two cells' gap offsets fn cellOffRange(text: []const u8, a: usize, c: usize) modal.HxRange { if (a <= c) return .{ .anchor = a, .head = modal.nextGrapheme(text, c) }; return .{ .anchor = modal.nextGrapheme(text, a), .head = c }; } /// the inverse: a gap range's cursor and anchor CELLS (equal for a bare /// 1-wide cursor). setPaneRange's own conversion, factored out so the /// extra selections write back through the same three lines. fn rangeCells(text: []const u8, r: modal.HxRange) struct { cur: usize, anc: usize } { if (r.head > r.anchor) return .{ .cur = modal.prevGrapheme(text, r.head), .anc = r.anchor }; if (r.head < r.anchor) return .{ .cur = r.head, .anc = modal.prevGrapheme(text, r.anchor) }; return .{ .cur = r.head, .anc = r.head }; } /// write a helix range back into pane state. `explicit` marks user-intent /// selections (v/x/X, terminal n/N, file-search n/N) — the acme chords /// act only on those; motion residue stays implicit. fn setPaneRange(pane: *Pane, pl: PaneLines, text: []const u8, r0: modal.HxRange, explicit: bool) void { var r = r0; if (r.anchor == r.head) r.head = modal.nextGrapheme(text, r.head); // min_width_1 const off = rangeCells(text, r); const cc = panePos(pane, text, off.cur); // a bare block cursor has both cells on the same offset — the common // case by far — and this conversion is not free even indexed const ac = if (off.anc == off.cur) cc else panePos(pane, text, off.anc); pane.cur_row = @as(i32, @intCast(cc.row)) + pl.row0; pane.cur_col = @intCast(cc.col); pane.vsel = .{ .active = off.anc != off.cur or pane.select, .row = @as(i32, @intCast(ac.row)) + pl.row0, .col = @intCast(ac.col), .explicit = explicit or pane.select, }; pane.msel.active = false; pane.nsel = 0; // writing ONE range means the selection IS that range pane.cur_pinned = true; pane.sticky_col = -1; pane.pending = 0; pane.ensureCursorVisible(); } // ---- the OTHER selections (helix Selection.ranges / primary_index) ---- // Two functions read and write the whole list; everything else in this // file still speaks the single primary range, and replaySels below is what // makes an ordinary key act at every cursor. /// The whole selection as helix gap ranges over `text`, DOCUMENT ORDER /// (pane.sels is kept that way, so this only has to slot the primary in). /// Returns how many were written and which index is the primary. fn paneRanges(pane: *Pane, text: []const u8, row0: i32, out: *[MAX_SELS]modal.HxRange) struct { n: usize, pri: usize } { const pr = paneRange(pane, text, row0); var n: usize = 0; var pri: usize = 0; var placed = false; for (pane.sels[0..pane.nsel]) |s| { const r = cellRange(text, s.arow - row0, s.acol, s.row - row0, s.col); if (!placed and @min(pr.anchor, pr.head) <= @min(r.anchor, r.head)) { pri = n; out[n] = pr; n += 1; placed = true; } out[n] = r; n += 1; } if (!placed) { pri = n; out[n] = pr; n += 1; } return .{ .n = n, .pri = pri }; } /// Write a whole selection back — helix's `Selection::new`: min-width-1, /// sorted by start, overlapping ranges merged (the primary following its /// range through a merge). `ranges[pri]` lands in the primary slot through /// setPaneRange, so nothing downstream can tell it apart from a lone /// cursor; the rest become pane.sels. `sticky` carries each range's own /// j/k goal column, -1 for the ones that have none. fn setPaneRanges(pane: *Pane, pl: PaneLines, text: []const u8, in: []const modal.HxRange, sticky: []const i32, pri0: usize, explicit: bool) void { if (in.len == 0) return; // helix asserts non-empty; here it just means "no change" var r: [MAX_SELS]modal.HxRange = undefined; var st: [MAX_SELS]i32 = undefined; var n: usize = @min(in.len, MAX_SELS); var pri: usize = @min(pri0, n - 1); for (in[0..n], 0..) |x, i| { r[i] = x; if (r[i].anchor == r[i].head) r[i].head = modal.nextGrapheme(text, r[i].head); st[i] = if (i < sticky.len) sticky[i] else -1; } // insertion sort by start — n is tiny and usually already ordered var i: usize = 1; while (i < n) : (i += 1) { var j = i; while (j > 0 and @min(r[j].anchor, r[j].head) < @min(r[j - 1].anchor, r[j - 1].head)) : (j -= 1) { std.mem.swap(modal.HxRange, &r[j], &r[j - 1]); std.mem.swap(i32, &st[j], &st[j - 1]); if (pri == j) pri = j - 1 else if (pri == j - 1) pri = j; } } // merge overlaps (helix Range::overlaps + Range::merge, kept forward: // a merged range takes the union and loses its direction only when the // two disagree, which is what helix's else-branch does) var k: usize = 0; i = 1; while (i < n) : (i += 1) { const a = r[k]; const b = r[i]; const af = @min(a.anchor, a.head); const at = @max(a.anchor, a.head); const bf = @min(b.anchor, b.head); const bt = @max(b.anchor, b.head); if (af == bf or (at > bf and bt > af)) { r[k] = if (a.anchor > a.head and b.anchor > b.head) .{ .anchor = @max(a.anchor, b.anchor), .head = @min(a.head, b.head) } else .{ .anchor = @min(af, bf), .head = @max(at, bt) }; if (pri == i) pri = k; if (st[k] < 0) st[k] = st[i]; continue; } k += 1; r[k] = b; st[k] = st[i]; if (pri == i) pri = k; } n = k + 1; setPaneRange(pane, pl, text, r[pri], explicit); pane.sticky_col = st[pri]; var w: usize = 0; for (r[0..n], 0..) |x, idx| { if (idx == pri) continue; const c = rangeCells(text, x); const cc = modal.hxPos(text, c.cur); const ac = modal.hxPos(text, c.anc); pane.sels[w] = .{ .row = @as(i32, @intCast(cc.row)) + pl.row0, .col = @intCast(cc.col), .arow = @as(i32, @intCast(ac.row)) + pl.row0, .acol = @intCast(ac.col), .sticky = st[idx], }; w += 1; } pane.nsel = @intCast(w); } /// The helix keys that act on the selection LIST rather than on the text. /// Each reads the whole list and writes a whole list back; none is a /// motion, which is why they are exempt from the per-range replay /// (wholeKey). The goal columns are dropped on the way through — every /// one of these is a fresh intent about WHERE the cursors are, the same /// reason setPaneRange resets sticky_col. fn multiSelKey(pane: *Pane, pl: PaneLines, text: []const u8, key: Key, cnt: usize) void { var rs: [MAX_SELS]modal.HxRange = undefined; const got = paneRanges(pane, text, pl.row0, &rs); const n = got.n; const expl = (pane.vsel.active and pane.vsel.explicit) or pane.msel.active; if (hit(key, config.remove_primary_sel)) { if (n < 2) return; // helix: "no selections remaining" var out: [MAX_SELS]modal.HxRange = undefined; var m: usize = 0; for (rs[0..n], 0..) |r, i| { if (i == got.pri) continue; out[m] = r; m += 1; } // helix Selection::remove: the NEXT range takes over, or the // previous one when the primary was last return setPaneRanges(pane, pl, text, out[0..m], &.{}, @min(got.pri, m - 1), expl); } if (hit(key, config.rotate_sel_fwd) or hit(key, config.rotate_sel_back)) { const step = cnt % n; const pri = if (hit(key, config.rotate_sel_fwd)) (got.pri + step) % n else (got.pri + (n - step)) % n; return setPaneRanges(pane, pl, text, rs[0..n], &.{}, pri, expl); } if (hit(key, config.merge_sels)) { // helix merge_selections: first.merge(last) — the ranges are // sorted, so that is simply the whole span const lo = @min(rs[0].anchor, rs[0].head); const hi = @max(rs[n - 1].anchor, rs[n - 1].head); const rev = rs[0].anchor > rs[0].head and rs[n - 1].anchor > rs[n - 1].head; const one: modal.HxRange = if (rev) .{ .anchor = hi, .head = lo } else .{ .anchor = lo, .head = hi }; return setPaneRanges(pane, pl, text, &.{one}, &.{}, 0, expl); } if (hit(key, config.merge_consecutive_sels)) { // ranges that TOUCH become one; setPaneRanges already merges the // ones that overlap, so widening each by a grapheme says exactly // "consecutive counts as overlapping" and nothing else var out: [MAX_SELS]modal.HxRange = undefined; var m: usize = 0; var pri: usize = 0; for (rs[0..n], 0..) |r, i| { if (m > 0 and @min(r.anchor, r.head) == @max(out[m - 1].anchor, out[m - 1].head)) { const lo = @min(@min(out[m - 1].anchor, out[m - 1].head), @min(r.anchor, r.head)); const hi = @max(@max(out[m - 1].anchor, out[m - 1].head), @max(r.anchor, r.head)); out[m - 1] = .{ .anchor = lo, .head = hi }; if (i == got.pri) pri = m - 1; continue; } if (i == got.pri) pri = m; out[m] = r; m += 1; } return setPaneRanges(pane, pl, text, out[0..m], &.{}, pri, expl); } if (hit(key, config.split_sel_newline)) { // helix selection::split_on_newline — one range per line the // selection covers, the newlines themselves left out var out: [MAX_SELS]modal.HxRange = undefined; var m: usize = 0; for (rs[0..n]) |r| { const from = @min(r.anchor, r.head); const to = @max(r.anchor, r.head); if (from == to) { if (m < MAX_SELS) { out[m] = r; m += 1; } continue; } var start = from; while (start < to and m < MAX_SELS) { const eol = modal.hxLineEndIdx(text, modal.hxLineOf(text, start)); if (eol >= to) { out[m] = .{ .anchor = start, .head = to }; m += 1; break; } out[m] = .{ .anchor = start, .head = eol }; m += 1; start = eol + 1; } } if (m == 0) return; return setPaneRanges(pane, pl, text, out[0..m], &.{}, 0, true); // helix keeps primary 0 } if (hit(key, config.trim_sels)) { // helix trim_selections: whitespace off both ends; ranges that are // empty or all whitespace are dropped entirely var out: [MAX_SELS]modal.HxRange = undefined; var m: usize = 0; for (rs[0..n]) |r| { var from = @min(r.anchor, r.head); var to = @max(r.anchor, r.head); while (from < to and std.ascii.isWhitespace(text[from])) from += 1; while (to > from and std.ascii.isWhitespace(text[to - 1])) to -= 1; if (from >= to) continue; out[m] = if (r.anchor > r.head) .{ .anchor = to, .head = from } else .{ .anchor = from, .head = to }; m += 1; } if (m == 0) { // helix: collapse_selection + keep_primary_selection const c = modal.hxCursor(text, rs[got.pri]); return setPaneRange(pane, pl, text, .{ .anchor = c, .head = c }, false); } // helix: the first survivor that OVERLAPS the old primary, else the last const pf = @min(rs[got.pri].anchor, rs[got.pri].head); const pt = @max(rs[got.pri].anchor, rs[got.pri].head); var pri = m - 1; for (out[0..m], 0..) |r, i| { const f = @min(r.anchor, r.head); const t = @max(r.anchor, r.head); if (f == pf or (t > pf and pt > f)) { pri = i; break; } } return setPaneRanges(pane, pl, text, out[0..m], &.{}, pri, expl); } // C / Alt-C — helix copy_selection_on_line, a copy of each range on the // next/previous line that is long enough to hold its columns. // ponytail: BYTE columns, not helix's visual ones, so a TAB counts as // one column here. Everything else in this file measures the same way // (hscroll, the mouse, the renderer's gutter), and fixing it means // teaching all of them tab stops at once. const below = hit(key, config.copy_sel_below); var out: [MAX_SELS]modal.HxRange = undefined; var m: usize = 0; var pri: usize = 0; const nlines = modal.hxLineCount(text); for (rs[0..n], 0..) |r, ri| { const is_pri = ri == got.pri; // head-exclusive: back the leading end off onto its own cell const hp = modal.hxPos(text, if (r.anchor < r.head) modal.prevGrapheme(text, r.head) else r.head); const ap = modal.hxPos(text, if (r.anchor < r.head) r.anchor else modal.prevGrapheme(text, r.anchor)); const height = @max(hp.row, ap.row) - @min(hp.row, ap.row) + 1; if (m >= MAX_SELS) break; if (is_pri) pri = m; out[m] = r; m += 1; var made: usize = 0; var k: usize = 0; while (made < cnt and m < MAX_SELS) : (k += 1) { const d = (k + 1) * height; const arow = if (below) ap.row + d else ap.row -| d; const hrow = if (below) hp.row + d else hp.row -| d; if (arow >= nlines or hrow >= nlines) break; const a2 = modal.hxOff(text, .{ .row = arow, .col = ap.col }); const h2 = modal.hxOff(text, .{ .row = hrow, .col = hp.col }); // a line too short to reach the column is skipped, not clamped if (modal.hxPos(text, a2).col == ap.col and modal.hxPos(text, h2).col == hp.col) { if (is_pri) pri = m; out[m] = modal.hxPutCursor(text, .{ .anchor = a2, .head = a2 }, h2, true); m += 1; made += 1; } if (arow == 0 and hrow == 0) break; } } setPaneRanges(pane, pl, text, out[0..m], &.{}, pri, expl); } // ---- `s` / `S`: the selection LIST from a regex ---- // The other two list-making keys, and the only ones that need a pattern // typed first. They reuse the `/` input wholesale (startSearch — the tag // tail IS the prompt) and differ from it in one thing: the pattern is // re-applied on every keystroke, so the selection is the preview. /// helix `s` / `S`: arm the tag input for a regex, remembering the /// selection it is about to rewrite. Nothing moves yet — every keystroke /// below re-derives the preview from this snapshot, and Enter simply stops /// while Esc puts the snapshot back. fn startSelRegex(p: *Pardes, pane: *Pane, split: bool) void { const pl = p.paneCursorLines(pane) catch return; const text = p.flatSurface(pane, pl) catch return; const got = paneRanges(pane, text, pl.row0, &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.startSearch(pane, if (split) config.split_marker else config.select_marker); } /// The pattern an armed `s`/`S` input holds right now, and which of the two /// it is — read back off the MARKER, exactly the way submitSearch decides /// which search is running. Null for `/`, Find, Grep and Rename. fn selRegexArmed(pane: *Pane) ?struct { pat: []const u8, split: bool } { if (!pane.search_edit or pane.nsel_snap == 0) return null; const tail = pane.tag_tail.items; const armed = tail[@min(pane.search_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 }; } /// Put the selection back the way `s`/`S` found it and then, if `pat` /// compiles and hits, rewrite it: helix's select_on_matches (every match /// INSIDE each range becomes a range) and split_on_matches (each range /// becomes the pieces BETWEEN its matches). /// /// Anything that yields nothing — an empty pattern, one that will not /// compile, one that does not match — leaves the snapshot standing, which /// is helix's "nothing selected" and also what makes typing a pattern one /// character at a time bearable: every prefix of it is one of those. /// /// ponytail: MAX_SELS ranges, and matches past that are dropped rather /// than growing the list — the ceiling the whole selection model has. /// ponytail: mvzr searches from each match's end, so `^` and `$` assert /// against THAT position rather than against a line the way helix's /// multi_line regex does, and `.` matches a newline like any other byte. /// Both are waived cases; `[^\n]` is the workaround for the second. fn applySelRegex(p: *Pardes, pane: *Pane, pat: []const u8, split: bool) void { const pl = p.paneCursorLines(pane) catch return; const text = p.flatSurface(pane, pl) catch return; const snap = pane.sel_snap[0..pane.nsel_snap]; var out: [MAX_SELS]modal.HxRange = undefined; var m: usize = 0; if (pat.len > 0) if (mvzr.compile(pat)) |re| { // helix smart-case: a pattern with no uppercase in it matches // case-blind. mvzr has no such flag — "lowercase your string" is // its own advice — and ASCII folding is byte for byte, so a // lowercased copy of the surface has exactly the same offsets. 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 < MAX_SELS) { 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) { // a match sitting right off the END of the range is // dropped (helix: what `\b` and empty matches produce // there), everything else becomes a range 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 < MAX_SELS) { 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 setPaneRanges(pane, pl, text, snap, &.{}, pane.sel_snap_pri, pane.sel_snap_expl); } setPaneRanges(pane, pl, text, out[0..m], &.{}, 0, true); // helix keeps primary 0 (its own TODO) } /// Everything one keystroke may CONSUME on the way through the modal /// handler. A key means the same thing at every cursor, so the replay puts /// all of it back before each pass and keeps whatever the PRIMARY's pass /// left. (sticky_col is deliberately absent: it is per-range, and rides /// along in SelRange.sticky instead.) const KeyState = struct { mode: Mode, select: bool, count: u32, pending: u21, pending2: u21, pending_ch: u21, find_op: u8, find_ch: u21, append_at: @FieldType(Pane, "append_at"), fn of(pane: *Pane) KeyState { return .{ .mode = pane.mode, .select = pane.select, .count = pane.count, .pending = pane.pending, .pending2 = pane.pending2, .pending_ch = pane.pending_ch, .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.count = s.count; pane.pending = s.pending; pane.pending2 = s.pending2; pane.pending_ch = s.pending_ch; 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 = enum { normal, insert, paste_after, paste_before }; /// Run one keystroke at EVERY cursor, by replaying the single-selection /// handler once per range. This IS the multiple-cursor mechanism, and it /// is why one cursor costs nothing: with `nsel == 0` nobody calls it, and /// the handler underneath is the same code the 800 differential cases pin. /// /// Two rules make the replay legal without helix's change-mapping: /// * ranges are visited LAST FIRST, so an edit never disturbs the /// row/col of a range still waiting its turn — everything it touches /// is below. /// * a finished pass's result is recorded as a distance from the END of /// the surface, which an edit strictly before it cannot change (the /// text and the position shift by exactly the same amount). fn replaySels(p: *Pardes, pane: *Pane, what: Replay, key: Key) 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: [MAX_SELS]SelRange = undefined; var n: usize = 0; var pri: usize = 0; const prim: 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 = selStart(prim); var placed = false; for (pane.sels[0..pane.nsel]) |s| { const sr = 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: [MAX_SELS]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 => p.normalKey(pane, key), .insert => p.insertKey(pane, key), .paste_after => p.normalPaste(pane, false), .paste_before => p.normalPaste(pane, true), } // the stop check comes FIRST: the pass that set it may have freed // this very pane (a builtin closing it), so nothing below may read // through the pointer if (p.multi_stop) break; if (i == pri) { after = KeyState.of(pane); after_expl = pane.vsel.explicit; } const pl = p.paneCursorLines(pane) catch { p.multi_stop = true; // out of memory mid-replay: collapse, don't guess break; }; const text = p.flatSurface(pane, pl) catch { p.multi_stop = true; break; }; const co = modal.hxOff(text, .{ .row = @intCast(@max(0, pane.cur_row - pl.row0)), .col = @intCast(@max(0, pane.cur_col)) }); const ao = if (pane.vsel.active) modal.hxOff(text, .{ .row = @intCast(@max(0, pane.vsel.row - pl.row0)), .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) { // the pass reached outside the buffer (a builtin, a language // query) and may have closed or reused the pane it ran on: drop // back to one cursor rather than replaying it n more times p.multi_stop = false; const pn = p.panes[id] orelse return; if (pn.serial == serial) pn.nsel = 0; return; } const pl = p.paneCursorLines(pane) catch return; const text = p.flatSurface(pane, pl) catch return; var rs: [MAX_SELS]modal.HxRange = undefined; var st: [MAX_SELS]i32 = undefined; for (res[0..n], 0..) |r, k| { rs[k] = cellOffRange(text, text.len - @min(r.anc, text.len), text.len - @min(r.cur, text.len)); st[k] = r.sticky; } setPaneRanges(pane, pl, text, rs[0..n], st[0..n], pri, after_expl); after.into(pane); pane.ensureCursorVisible(); // the view follows the PRIMARY, not the last pass } /// a range's start CELL (document order key) — the smaller of its two ends fn selStart(s: SelRange) struct { row: i32, col: i32 } { if (s.arow < s.row or (s.arow == s.row and s.acol < s.col)) return .{ .row = s.arow, .col = s.acol }; return .{ .row = s.row, .col = s.col }; } /// The last line a goto may land on: helix skips the empty trailing line. /// Called from the three `g`/`G` branches that need it and NOWHERE else — /// it used to be a `const` at the top of normalKey, which meant every /// keystroke of every kind paid a full count of the buffer's newlines. /// "Does the buffer end in a newline" is the same question as the walk to /// the last line start that stood here, and it is one byte instead of a /// second pass. fn maxLine(text: []const u8) usize { const nl = modal.hxLineCount(text); return if (text.len == 0 or text[text.len - 1] == '\n') nl -| 2 else nl - 1; } /// point-target motion: collapse there (extend in select mode) fn pointMove(pane: *Pane, pl: PaneLines, text: []const u8, range: modal.HxRange, target: usize) void { setPaneRange(pane, pl, text, modal.hxPutCursor(text, range, target, pane.select), false); } /// word motions select their traversed span (extend mode: head only) fn wordMove(pane: *Pane, pl: PaneLines, text: []const u8, range: modal.HxRange, cnt: usize, target: modal.WordTarget) void { const wr = modal.hxWordMove(text, range, cnt, target); const res = if (pane.select) modal.hxPutCursor(text, range, modal.hxCursor(text, wr), true) else wr; setPaneRange(pane, pl, text, res, false); } /// f/t/F/T: anchor at the old cursor cell, head on the hit (not found: no move) fn findMove(pane: *Pane, pl: PaneLines, text: []const u8, range: modal.HxRange, ch: u21, fwd: bool, till: bool, cnt: usize) void { if (ch > 0x7f) return; // ponytail: ASCII targets only (byte columns) const cur = modal.hxCursor(text, range); const t = modal.hxFindTarget(text, cur, @intCast(ch), fwd, till, cnt) orelse return; const res = if (pane.select) modal.hxPutCursor(text, range, t, true) else modal.hxPutCursor(text, .{ .anchor = cur, .head = cur }, t, true); setPaneRange(pane, pl, text, res, false); } /// j/k and friends: sticky goal column, clamped onto short lines' newline fn verticalMove(pane: *Pane, pl: PaneLines, text: []const u8, range: modal.HxRange, down: bool, cnt: usize) void { const cur = modal.hxCursor(text, range); const pos = panePos(pane, text, cur); const goal: usize = if (pane.sticky_col >= 0) @intCast(pane.sticky_col) else pos.col; // modal.hxVertTarget with the row we already have and the indexed // offset conversion — it would otherwise recount the buffer's newlines // and walk to the target line, two more full passes per j/k const t = if (indexOf(pane, text)) |idx| blk: { const nline = if (down) @min(pos.row + @max(1, cnt), idx.len - 1) else pos.row -| @max(1, cnt); break :blk paneOff(pane, text, .{ .row = nline, .col = goal }); } else modal.hxVertTarget(text, cur, down, cnt, goal); // extend mode never walks onto the empty trailing line (helix) if (pane.select and t == text.len and text.len > 0 and text[text.len - 1] == '\n') return; setPaneRange(pane, pl, text, modal.hxPutCursor(text, range, t, pane.select), false); pane.sticky_col = @intCast(goal); } /// Ctrl-d/u: scroll half a page AND move the cursor by the same rows fn halfPageMove(pane: *Pane, pl: PaneLines, text: []const u8, range: modal.HxRange, down: bool) void { const half: i32 = @max(1, @divTrunc(@as(i32, pane.rows), 2)); pane.scrollBy(if (down) half else -half); verticalMove(pane, pl, text, range, down, @intCast(half)); } /// helix `scroll` without cursor sync (Ctrl-f/b, PgUp/PgDn, zj/zk): shift /// the view, then snap a fallen-out cursor to the near scrolloff edge, col 0 fn scrollViewMove(pane: *Pane, pl: PaneLines, text: []const u8, range: modal.HxRange, delta: i32) void { const margin: i32 = @min(config.scroll_off, @divTrunc(@as(i32, pane.rows) - 1, 2)); pane.scrollBy(delta); const top = pane.scroll(); const last_row: i32 = @intCast((if (indexOf(pane, text)) |idx| idx.len else modal.hxLineCount(text)) - 1); const cur = modal.hxCursor(text, range); if (delta > 0) { const snap: i32 = @max(0, @min(top + margin, last_row)); const head = paneLineStart(pane, text, @intCast(snap)); if (head <= cur) return; const anchor = if (pane.select) range.anchor else head; setPaneRange(pane, pl, text, .{ .anchor = anchor, .head = head }, false); } else { const snap: i32 = @max(0, @min(top + @as(i32, pane.rows) - margin - 1, last_row)); const head = paneLineStart(pane, text, @intCast(snap)); if (head >= cur) return; const anchor = if (pane.select) range.anchor else head; setPaneRange(pane, pl, text, .{ .anchor = anchor, .head = head }, false); } } /// gt/gc/gb: view-relative rows, col 0, scrolloff clamped (helix goto_window) fn gotoWindow(pane: *Pane, pl: PaneLines, text: []const u8, range: modal.HxRange, which: enum { top, center, bottom }, cnt: usize) void { const margin: i32 = @min(config.scroll_off, @divTrunc(@as(i32, pane.rows) - 1, 2)); const top = pane.scroll(); const last_row: i32 = @intCast((if (indexOf(pane, text)) |idx| idx.len else modal.hxLineCount(text)) - 1); const last_vis: i32 = @min(@as(i32, pane.rows) - 1, last_row - top); const n: i32 = @intCast(cnt - 1); var vline: i32 = switch (which) { .top => top + margin + n, .center => top + @divTrunc(last_vis, 2), .bottom => top + last_vis - (margin + n), }; vline = @max(vline, top + margin); vline = @min(vline, top + last_vis - margin); const row: i32 = std.math.clamp(vline, 0, last_row); pointMove(pane, pl, text, range, paneLineStart(pane, text, @intCast(row))); } /// helix Range::line_range — the inclusive line span a range covers fn rangeLineSpan(text: []const u8, r: modal.HxRange) struct { start: usize, end: usize } { const from = @min(r.anchor, r.head); const to = @max(r.anchor, r.head); const to_adj = if (from == to) to else @max(modal.prevGrapheme(text, to), from); return .{ .start = modal.hxLineOf(text, from), .end = modal.hxLineOf(text, to_adj) }; } fn lineStartOrEof(text: []const u8, line: usize) usize { if (line >= modal.hxLineCount(text)) return text.len; return modal.lineStartOffset(text, line); } /// helix `x` extend_line_below: full lines incl. the newline, cursor ON /// the last one's '\n'; an already-line-bounded selection grows downward fn lineSelect(pane: *Pane, pl: PaneLines, text: []const u8, range: modal.HxRange, cnt: usize) void { const span = rangeLineSpan(text, range); const start = modal.lineStartOffset(text, span.start); const end = lineStartOrEof(text, span.end + 1); const full = @min(range.anchor, range.head) == start and @max(range.anchor, range.head) == end; const head = lineStartOrEof(text, span.end + cnt + @intFromBool(full)); setPaneRange(pane, pl, text, .{ .anchor = start, .head = head }, true); } /// helix `X` extend_to_line_bounds (direction kept) fn lineBoundsSelect(pane: *Pane, pl: PaneLines, text: []const u8, range: modal.HxRange) void { const span = rangeLineSpan(text, range); const start = modal.lineStartOffset(text, span.start); const end = lineStartOrEof(text, span.end + 1); const r: modal.HxRange = if (range.head < range.anchor) .{ .anchor = end, .head = start } else .{ .anchor = start, .head = end }; setPaneRange(pane, pl, text, r, true); } /// helix `Alt-x` shrink_to_line_bounds (single-line selections untouched) fn shrinkSelToLineBounds(pane: *Pane, pl: PaneLines, text: []const u8, range: modal.HxRange) void { const span = rangeLineSpan(text, range); if (span.start == span.end) return; const from = @min(range.anchor, range.head); const to = @max(range.anchor, range.head); var start = modal.lineStartOffset(text, span.start); var end = lineStartOrEof(text, span.end + 1); if (start != from) start = lineStartOrEof(text, span.start + 1); if (end != to) end = modal.lineStartOffset(text, span.end); const expl = (pane.vsel.active and pane.vsel.explicit) or pane.msel.active; const r: modal.HxRange = if (range.head < range.anchor) .{ .anchor = end, .head = start } else .{ .anchor = start, .head = end }; setPaneRange(pane, pl, text, r, expl); } /// Keys that are about the selection LIST, or about the session rather /// than the text: they act ONCE however many cursors there are. The /// multi-cursor family rewrites the list wholesale, and the rest would /// either fight the replay (Esc, undo) or fire n times (`:`, `/`, n/N, SPC). /// /// A key that turns one range into MANY belongs here and writes its result /// with setPaneRanges — `Alt-s` is the worked example and `s`/`S` are the /// same shape a regex later. A key that merely EDITS wants the opposite: /// leave it out and the replay runs it at every cursor for free. Ctrl-c is /// the exception that proves it: it edits, but comment-or-uncomment is ONE /// decision over every line every cursor touches, and a replay would take /// that decision n times. fn wholeKey(pane: *Pane, key: Key) bool { if (hit(key, config.escape)) return true; // Esc means the same whatever is pending if (pane.pending != 0) return false; // a prefix's char argument is literal return hit(key, config.copy_sel_below) or hit(key, config.copy_sel_above) or hit(key, config.keep_primary_sel) or hit(key, config.remove_primary_sel) or hit(key, config.rotate_sel_fwd) or hit(key, config.rotate_sel_back) or hit(key, config.split_sel_newline) or hit(key, config.merge_sels) or hit(key, config.merge_consecutive_sels) or hit(key, config.trim_sels) or hit(key, config.select_regex) or hit(key, config.split_regex) or hit(key, config.comment_toggle) or hit(key, config.undo) or hit(key, config.redo) or hit(key, config.command_line) or hit(key, config.search) or hit(key, config.search_next) or hit(key, config.search_prev) or hit(key, config.leader); } fn handleNormal(p: *Pardes, pane: *Pane, key: Key) void { if (pane.nsel == 0 or wholeKey(pane, key)) return p.normalKey(pane, key); p.replaySels(pane, .normal, key); } fn normalKey(p: *Pardes, pane: *Pane, key: Key) void { pane.pinCursor(); const pl = p.paneCursorLines(pane) catch return; const text = p.flatSurface(pane, pl) catch return; const lines = pl.lines; // Esc: the fast document<->terminal hop, running the SAME Toggleterm // builtin `SPC w t` does. Clear the normal-mode residue it used to // cancel first: select mode and pending prefixes/count end here, while // the selection itself SURVIVES (helix; `;` collapses it). Scoped Esc // meanings (leader/tag/topbar/search cancel, insert -> normal, raw tty // forwarding) are intercepted in handleKey before this body-normal // handler and therefore stay unchanged. if (hit(key, config.escape)) { pane.select = false; pane.pending = 0; pane.pending2 = 0; pane.pending_ch = 0; pane.count = 0; return p.runBuiltin(.Toggleterm, p.active, "", null); } // count prefix: digits accumulate while no prefix op is in flight // (`0` stays line-start until a count begins) if (pane.pending == 0 and !key.ctrl and !key.alt and key.cp >= '0' and key.cp <= '9' and !(key.cp == '0' and pane.count == 0)) { if (pane.count < 0xffff) pane.count = pane.count * 10 + (key.cp - '0'); return; } // any key but a prefix setter consumes the count (setters re-arm it) const cnt: usize = @max(1, pane.count); const has_count = pane.count != 0; const saved_count = pane.count; pane.count = 0; const range = paneRange(pane, text, pl.row0); const cur = modal.hxCursor(text, range); // block-cursor gap offset // prefix continuations if (pane.pending == config.goto_prefix) { pane.pending = 0; if (hit(key, config.goto_file_start)) { const line = if (has_count) @min(cnt - 1, maxLine(text)) else 0; return pointMove(pane, pl, text, range, modal.lineStartOffset(text, line)); } if (hit(key, config.goto_last_line)) return pointMove(pane, pl, text, range, modal.lineStartOffset(text, maxLine(text))); if (hit(key, config.goto_line_start)) return pointMove(pane, pl, text, range, modal.lineStartOffset(text, modal.hxLineOf(text, cur))); if (hit(key, config.goto_line_end)) { const line = modal.hxLineOf(text, cur); const ls = modal.lineStartOffset(text, line); return pointMove(pane, pl, text, range, @max(ls, modal.prevGrapheme(text, modal.hxLineEndIdx(text, line)))); } if (hit(key, config.goto_first_nonws)) { const line = modal.hxLineOf(text, cur); const ls = modal.lineStartOffset(text, line); const slice = text[ls..modal.hxLineEndIdx(text, line)]; const nw = modal.firstNonWs(slice); if (nw == slice.len) return; // all-whitespace line: no move (helix) return pointMove(pane, pl, text, range, ls + nw); } if (hit(key, config.goto_line_down)) return verticalMove(pane, pl, text, range, true, cnt); if (hit(key, config.goto_line_up)) return verticalMove(pane, pl, text, range, false, cnt); if (hit(key, config.goto_column)) { const line = modal.hxLineOf(text, cur); const ls = modal.lineStartOffset(text, line); return pointMove(pane, pl, text, range, @min(ls + (cnt - 1), modal.hxLineEndIdx(text, line))); } if (hit(key, config.goto_view_top)) return gotoWindow(pane, pl, text, range, .top, cnt); if (hit(key, config.goto_view_center)) return gotoWindow(pane, pl, text, range, .center, cnt); if (hit(key, config.goto_view_bottom)) return gotoWindow(pane, pl, text, range, .bottom, cnt); // helix's five LSP gotos, all under `g` and nowhere else. They ask // and return; the answer arrives later as an lsp_resp Event and // either jumps or opens a list (lspResponse). if (hit(key, config.goto_definition)) return p.lspRequest(p.active, .definition, ""); if (hit(key, config.goto_declaration)) return p.lspRequest(p.active, .declaration, ""); if (hit(key, config.goto_type_definition)) return p.lspRequest(p.active, .type_definition, ""); if (hit(key, config.goto_implementation)) return p.lspRequest(p.active, .implementation, ""); if (hit(key, config.goto_references)) return p.lspRequest(p.active, .references, ""); return; } if (pane.pending == config.view_prefix) { pane.pending = 0; if (hit(key, config.view_top)) { pane.scrollBy(pane.cur_row - pane.scroll()); pane.ensureCursorVisible(); return; } if (hit(key, config.view_center)) { pane.scrollBy(pane.cur_row - (pane.scroll() + @divTrunc(@as(i32, pane.rows), 2) - 1)); pane.ensureCursorVisible(); return; } if (hit(key, config.view_bottom)) { pane.scrollBy(pane.cur_row - (pane.scroll() + @as(i32, pane.rows) - 1)); pane.ensureCursorVisible(); return; } if (hit(key, config.view_scroll_down)) return scrollViewMove(pane, pl, text, range, 1); if (hit(key, config.view_scroll_up)) return scrollViewMove(pane, pl, text, range, -1); if (hit(key, config.half_page_down)) return halfPageMove(pane, pl, text, range, true); if (hit(key, config.half_page_up)) return halfPageMove(pane, pl, text, range, false); if (hit(key, config.page_down)) return scrollViewMove(pane, pl, text, range, @as(i32, pane.rows)); if (hit(key, config.page_up)) return scrollViewMove(pane, pl, text, range, -@as(i32, pane.rows)); return; } if (pane.pending == config.find_char_fwd or pane.pending == config.find_char_back or pane.pending == config.till_char_fwd or pane.pending == config.till_char_back) { const op: u8 = @intCast(pane.pending); pane.pending = 0; if (key.ctrl or key.alt or key.cp >= 0xF0000) return; // not a char pane.find_op = op; pane.find_ch = key.cp; return findMove(pane, pl, text, range, key.cp, op == config.find_char_fwd or op == config.till_char_fwd, op == config.till_char_fwd or op == config.till_char_back, cnt); } if (pane.pending == config.replace_prefix) { pane.pending = 0; if (key.ctrl or key.alt or key.cp >= 0xF0000 or key.cp > 0x7f) return; return p.normalReplaceChar(pane, @intCast(key.cp)); } if (pane.pending == config.match_prefix) { if (pane.pending2 == 0) { if (hit(key, config.match_bracket)) { pane.pending = 0; const mc = modal.matchBracket(lines, modal.hxPos(text, cur)) orelse return; return pointMove(pane, pl, text, range, modal.hxOff(text, mc)); } if (isPrefix(key, config.match_inside) or isPrefix(key, config.match_around) or isPrefix(key, config.surround_add) or isPrefix(key, config.surround_replace) or isPrefix(key, config.surround_delete)) { pane.pending2 = key.cp; // pending stays 'm': the char arg is next return; } pane.pending = 0; return; } const sub = pane.pending2; if (key.ctrl or key.alt or key.cp >= 0xF0000) { pane.pending = 0; pane.pending2 = 0; pane.pending_ch = 0; return; } if (sub == config.surround_replace and pane.pending_ch == 0) { pane.pending_ch = key.cp; // mr: hold , wait for return; } pane.pending = 0; pane.pending2 = 0; const from = pane.pending_ch; pane.pending_ch = 0; switch (sub) { config.match_inside, config.match_around => return p.textobjectSelect(pane, pl, key.cp, sub == config.match_around), config.surround_add => return p.surroundAdd(pane, key.cp), config.surround_delete => return p.surroundDelete(pane, pl, key.cp), config.surround_replace => return p.surroundReplace(pane, pl, from, key.cp), else => return, } } if (pane.pending == config.next_prefix or pane.pending == config.prev_prefix) { const fwd = pane.pending == config.next_prefix; pane.pending = 0; if (hit(key, config.goto_paragraph)) { const r2 = modal.hxParaMove(text, range, cnt, fwd, pane.select); return setPaneRange(pane, pl, text, r2, false); } if (hit(key, config.add_newline)) return p.addNewline(pane, fwd, cnt); // ]d / [d: step the diagnostics list, asking for one if it is not // up yet. The list is an ordinary results buffer, so "step" is the // n/N stepper — `d` differs from `n` only in what it guarantees is // showing. ]D / [D jump to the last/first, helix's pair. if (hit(key, config.goto_diagnostic)) { if (output_pane.resultsFrom(p, pane, .{ .query = .diagnostics }) and p.searchStep(p.active, if (fwd) 1 else -1)) return; return p.lspRequest(p.active, .diagnostics, ""); } if (hit(key, config.goto_diagnostic_end)) { if (!output_pane.resultsFrom(p, pane, .{ .query = .diagnostics })) return p.lspRequest(p.active, .diagnostics, ""); if (!fwd) { // [D = first 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"); // one past the last _ = p.searchStep(p.active, -1); return; } return; } // prefix setters (the count survives into the continuation) if (isPrefix(key, config.goto_prefix) or isPrefix(key, config.view_prefix) or isPrefix(key, config.find_char_fwd) or isPrefix(key, config.find_char_back) or isPrefix(key, config.till_char_fwd) or isPrefix(key, config.till_char_back) or isPrefix(key, config.replace_prefix) or isPrefix(key, config.next_prefix) or isPrefix(key, config.prev_prefix)) { pane.pending = key.cp; pane.count = saved_count; return; } if (isPrefix(key, config.match_prefix)) { pane.pending = config.match_prefix; pane.pending2 = 0; pane.pending_ch = 0; return; } // movement if (hit(key, config.move_left)) { var t = cur; for (0..cnt) |_| t = modal.prevGrapheme(text, t); return pointMove(pane, pl, text, range, t); } if (hit(key, config.move_right)) { var t = cur; for (0..cnt) |_| t = modal.nextGrapheme(text, t); return pointMove(pane, pl, text, range, t); } if (hit(key, config.move_down)) return verticalMove(pane, pl, text, range, true, cnt); if (hit(key, config.move_up)) return verticalMove(pane, pl, text, range, false, cnt); if (hit(key, config.next_word_start)) return wordMove(pane, pl, text, range, cnt, .next_word_start); if (hit(key, config.prev_word_start)) return wordMove(pane, pl, text, range, cnt, .prev_word_start); if (hit(key, config.next_word_end)) return wordMove(pane, pl, text, range, cnt, .next_word_end); if (hit(key, config.next_long_word_start)) return wordMove(pane, pl, text, range, cnt, .next_long_word_start); if (hit(key, config.prev_long_word_start)) return wordMove(pane, pl, text, range, cnt, .prev_long_word_start); if (hit(key, config.next_long_word_end)) return wordMove(pane, pl, text, range, cnt, .next_long_word_end); if (hit(key, config.repeat_find)) { // repeat the last f/F/t/T; a count repeats the motion N times if (pane.find_op == 0 or pane.find_ch > 0x7f) 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 r = range; var moved = false; for (0..cnt) |_| { const cc = modal.hxCursor(text, r); const t = modal.hxFindTarget(text, cc, @intCast(pane.find_ch), fwd, till, 1) orelse break; r = if (pane.select) modal.hxPutCursor(text, r, t, true) else modal.hxPutCursor(text, .{ .anchor = cc, .head = cc }, t, true); moved = true; } if (!moved) return; return setPaneRange(pane, pl, text, r, false); } if (hit(key, config.line_start)) return pointMove(pane, pl, text, range, modal.lineStartOffset(text, modal.hxLineOf(text, cur))); if (hit(key, config.line_end)) { const line = modal.hxLineOf(text, cur); const ls = modal.lineStartOffset(text, line); return pointMove(pane, pl, text, range, @max(ls, modal.prevGrapheme(text, modal.hxLineEndIdx(text, line)))); } if (hit(key, config.line_first_nonws)) { const line = modal.hxLineOf(text, cur); const ls = modal.lineStartOffset(text, line); const slice = text[ls..modal.hxLineEndIdx(text, line)]; const nw = modal.firstNonWs(slice); if (nw == slice.len) return; return pointMove(pane, pl, text, range, ls + nw); } if (hit(key, config.goto_line)) { // helix goto_line: only acts WITH a count (bare G is a no-op; // ge is goto-last-line) if (!has_count) return; const line = @min(cnt - 1, maxLine(text)); return pointMove(pane, pl, text, range, modal.lineStartOffset(text, line)); } if (hit(key, config.half_page_down)) return halfPageMove(pane, pl, text, range, true); if (hit(key, config.half_page_up)) return halfPageMove(pane, pl, text, range, false); if (hit(key, config.page_down)) return scrollViewMove(pane, pl, text, range, @as(i32, pane.rows)); if (hit(key, config.page_up)) return scrollViewMove(pane, pl, text, range, -@as(i32, pane.rows)); // insert entry if (hit(key, config.insert)) return p.enterInsert(pane, .at, cnt); if (hit(key, config.append)) return p.enterInsert(pane, .append, cnt); if (hit(key, config.insert_line_start)) return p.enterInsert(pane, .line_start, cnt); if (hit(key, config.insert_line_end)) return p.enterInsert(pane, .line_end, cnt); if (hit(key, config.open_below)) return p.enterInsert(pane, .open_below, cnt); if (hit(key, config.open_above)) return p.enterInsert(pane, .open_above, cnt); // selections if (hit(key, config.select_mode)) { if (pane.select) { pane.select = false; // exit select mode, selection kept (helix) } else { pane.select = true; if (pane.msel.active) { // adopt the legacy line highlight as a char range setPaneRange(pane, pl, text, 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; pane.pending = 0; return; } if (hit(key, config.select_line)) return lineSelect(pane, pl, text, range, cnt); if (hit(key, config.select_line_bounds)) return lineBoundsSelect(pane, pl, text, range); if (hit(key, config.shrink_to_line_bounds)) return shrinkSelToLineBounds(pane, pl, text, range); if (hit(key, config.collapse_selection)) return setPaneRange(pane, pl, text, .{ .anchor = cur, .head = cur }, false); if (hit(key, config.flip_selection)) { const expl = (pane.vsel.active and pane.vsel.explicit) or pane.msel.active; return setPaneRange(pane, pl, text, .{ .anchor = range.head, .head = range.anchor }, expl); } if (hit(key, config.select_all)) return setPaneRange(pane, pl, text, .{ .anchor = 0, .head = text.len }, false); // multiple cursors: `,` is the one that needs nothing but the list if (hit(key, config.keep_primary_sel)) { pane.nsel = 0; // helix Selection::single(primary) return; } if (hit(key, config.remove_primary_sel) or hit(key, config.rotate_sel_fwd) or hit(key, config.rotate_sel_back) or hit(key, config.copy_sel_below) or hit(key, config.copy_sel_above) or hit(key, config.split_sel_newline) or hit(key, config.merge_sels) or hit(key, config.merge_consecutive_sels) or hit(key, config.trim_sels)) return multiSelKey(pane, pl, text, key, cnt); // the two that need a REGEX first: arm the tag input and let the // pattern's own keystrokes drive the preview (startSelRegex) if (hit(key, config.select_regex)) return p.startSelRegex(pane, false); if (hit(key, config.split_regex)) return p.startSelRegex(pane, true); // edits if (hit(key, config.delete)) return p.normalDelete(pane, true); if (hit(key, config.delete_noyank)) return p.normalDelete(pane, false); if (hit(key, config.change)) return p.normalChange(pane); if (hit(key, config.yank)) return p.normalYank(pane); if (hit(key, config.replace_with_yank)) return p.normalReplaceYank(pane); if (hit(key, config.switch_case)) return p.normalCase(pane, .toggle); if (hit(key, config.to_lowercase)) return p.normalCase(pane, .lower); if (hit(key, config.to_uppercase)) return p.normalCase(pane, .upper); if (hit(key, config.join_lines)) return p.normalJoin(pane); if (hit(key, config.indent)) return p.normalIndent(pane, cnt, true); if (hit(key, config.unindent)) return p.normalIndent(pane, cnt, false); // helix's `=` (format_selections) — its neighbour on the keyboard and // in the keymap. Unbound in pardes until now. if (hit(key, config.format)) return p.lspRequest(p.active, .format, ""); if (hit(key, config.increment)) return p.normalAdjustNumber(pane, @intCast(cnt)); if (hit(key, config.decrement)) return p.normalAdjustNumber(pane, -@as(i64, @intCast(cnt))); if (hit(key, config.comment_toggle)) return p.normalToggleComment(pane); if (hit(key, config.undo)) return p.doUndo(pane); if (hit(key, config.redo)) return p.doRedo(pane); // SPC — the leader: a key path from here runs a BUILTIN with no // arguments, the same builtins the topbar and the tags hold (`?` at // any depth lists what the prefix can still reach). Helix spends Space // on pickers, which pardes does not have; acme's builtins are exactly // what a leader is for. Body normal mode only — a tag is always // insert, and a tty pane's keys belong to the program. if (hit(key, config.leader)) { p.leader_on = true; p.leader_n = 0; return; } // `:` — vim's command line with acme's vocabulary: focus the pane's own // tag in normal mode, parked at the tail's start. Motions walk the // words and the execute chord runs the one under the cursor, then // hands the body back (`:w` = Save). Always the start, not the // remembered tag column, so `:w` is the same keystrokes every time. if (hit(key, config.command_line)) { p.enterTagEdit(pane, -1); if (pane.tag_edit) pane.mode = .normal; return; } // search: `/` types a pattern into the tag, n/N walk the results. Same // keys on every kind of pane; a terminal with no search armed falls // back to n/N as a motion over the lookable tokens in its output. if (hit(key, config.search)) return p.startSearch(pane, config.search_marker); if (hit(key, config.search_next) or hit(key, config.search_prev)) { const delta: i32 = if (hit(key, config.search_next)) 1 else -1; if (p.searchStep(p.active, delta)) return; if (pane.isTerminal()) return p.lookStep(pane, pl, delta); return; } } // ---- search (`/`, Find, n/N): results into an output buffer, look the rows ---- const Search = enum { text, find, grep }; const SearchStart = enum { top, cursor }; fn paneDir(pane: *const Pane) []const u8 { 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(); } const PdfSearchResult = struct { rows: usize = 0, anchor: ?usize = null }; /// Ask MuPDF for each page's logical hits, so the result list and the /// quads used to highlight/reveal can never disagree. Locations spell /// `path:PAGE:HIT`, reusing Spot.col as a one-based page-local hit id. /// The compact snippet is the query itself: pairing MuPDF geometry back to /// extracted lines would be heuristic, and a plausible wrong line is less /// honest than the exact occurrence identity already in the address. fn searchPdf( p: *Pardes, pane: *Pane, pat: []const u8, start: SearchStart, out: *std.ArrayList(u8), ) PdfSearchResult { if (comptime !pdf_enabled) return .{}; const pv = &(pane.pdf orelse return .{}); p.setPdfSearchQuery(pv, pat); const arena = p.scratch.allocator(); const shown = std.fs.path.basename(pv.path); var result: PdfSearchResult = .{}; const max_hits = 512; var snippet_len = @min(pat.len, 200); while (snippet_len > 0 and snippet_len < pat.len and pat[snippet_len] & 0xc0 == 0x80) snippet_len -= 1; const snippet = pat[0..snippet_len]; for (0..pv.page_count) |page| { if (result.rows >= max_hits) break; var found = pv.document.search(p.gpa, page, pat) catch continue; defer found.deinit(p.gpa); // A native right-click selection identifies the occurrence the // user is already on. Compare hit centers against its oriented // selection quads; the next step then advances past that hit. const cursor_hit: ?usize = if (start == .cursor and page == pv.page) cursor: { const selection = pv.selection orelse break :cursor null; for (found.quads) |item| { const q = item.quad; const center: PdfPoint = .{ .x = (q.ul.x + q.ur.x + q.ll.x + q.lr.x) / 4, .y = (q.ul.y + q.ur.y + q.ll.y + q.lr.y) / 4, }; if (selection.contains(center)) break :cursor item.hit; } break :cursor null; } else null; for (0..found.hit_count) |hit_index| { if (result.rows >= max_hits) break; if (start == .cursor and (page < pv.page or (page == pv.page and cursor_hit != null and hit_index <= cursor_hit.?))) result.anchor = result.rows; const row = std.fmt.allocPrint(arena, "{s}:{d}:{d} {s}\n", .{ shown, page + 1, hit_index + 1, snippet, }) catch return result; out.appendSlice(arena, row) catch return result; result.rows += 1; } } return result; } /// `/` (and the Find builtin) on any pane: type the pattern into the tag /// tail after `marker` — the existing modal one-line editor, visible while /// typing, nothing that disappears. Enter/Esc are intercepted in handleKey. pub fn startSearch(p: *Pardes, pane: *Pane, marker: []const u8) void { if (!pane.tag_init) { pane.tag_tail.appendSlice(p.gpa, curTail(pane)) catch return; pane.tag_init = true; } pane.search_at = @intCast(pane.tag_tail.items.len); pane.tag_tail.appendSlice(p.gpa, marker) catch return; pane.search_edit = true; pane.tag_edit = true; pane.tag_sel = false; pane.mode = .insert; pane.pending = 0; // tag_col is a RENDERED-tag column, so the end of the tail is past the // prefix (search_at stays a tail offset — it slices tag_tail) pane.tag_col = @intCast((p.tagPrefix(pane) catch return).len + pane.tag_tail.items.len); } /// Enter on an armed input: the pattern is everything past the marker's /// `/` (so a pattern may itself contain slashes), and the marker names the /// search — " Find /" walks the filesystem for NAMES, " Grep /" for /// CONTENTS, " /" reads the pane's own text. No `/` left means the editor /// ate the marker: nothing to run. fn submitSearch(p: *Pardes, id: usize) void { const pane = p.panes[id] orelse return; // `s`/`S` have already applied themselves keystroke by keystroke; // Enter re-runs the final pattern so a submit is one code path with // the preview and cannot disagree with what is on screen. if (selRegexArmed(pane)) |a| return p.applySelRegex(pane, a.pat, a.split); const tail = pane.tag_tail.items; const armed = tail[@min(pane.search_at, 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); } /// Fill this pane's results buffer with everything matching `pat_raw` /// (plain substring, case-insensitive) — ONE function for both searches, /// because they differ only in where the rows come from and agree on every /// row being a LOOK TARGET, which is what makes n/N work: /// text (`/`) — the pane's own flat text, the same view Look and Execute /// read, so a terminal searches its scrollback exactly as a file /// searches its content. Rows are `location text`, the location ended /// by a SPACE (a trailing `:` would read as part of it): the pane's /// path when it has a real file, else `@pN`. /// find — the pane's DIRECTORY, walked like fd. Rows are bare paths. /// grep — the CONTENTS of every file under every pane's directory, /// walked like `grep -R`. Rows are `path:LINE:COL-ENDCOL text`, the /// path relative to THIS pane's directory (absolute for a hit outside /// it). /// Both searches that match TEXT name the match's whole span, so looking a /// row — which is all n/N do — selects what matched rather than parking on /// its first cell. Find's rows are bare paths and have nothing to span. /// No matches = an empty buffer. /// /// `start` is where the WALK begins, which belongs to the gesture and not /// to the search: a click POINTS at one of the hits, so its list is armed /// there and the first step goes to the next one (acme's button-3 walking a /// word). `/`, Find and Grep point at nothing, so their list is walked from /// the top, which is also the only place a list of OTHER files could start. 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); var out: std.ArrayList(u8) = .empty; var nrows: usize = 0; var anchor: ?usize = null; if (kind == .grep) { // One walk per PLACE the session is open on: every pane's // directory, minus the ones another pane's already contains, so a // tree two panes sit in is greped once and a pane deep inside // another's tree adds nothing. Slot order, so the same session // gives the same buffer twice running. 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| look.grep(arena, p.gpa, r, dir, pat, &out); } else if (kind == .find) look.find(arena, dir, pat, &out) else if (hasPdf(pane)) { const found = p.searchPdf(pane, pat, start, &out); nrows = found.rows; anchor = found.anchor; } else { const pl = p.paneCursorLines(pane) catch return; // 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; // the hit at or before the cursor is the one you are ON, so arming // there makes the first step land on the NEXT one: a click on the // second `foo` goes to the third, not back to the first. const cl: usize = @intCast(@max(0, pane.cur_row)); const cc: usize = @intCast(@max(0, pane.cur_col)); for (pl.lines, 0..) |ln, i| { const at = std.ascii.indexOfIgnoreCase(ln, pat) orelse continue; if (start == .cursor and (i < cl or (i == cl and at <= cc))) anchor = nrows; nrows += 1; // the row names the MATCH's span, not just its first cell, so // n/N land ON the hit with it selected (config.range_sep) const row = 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"), }) catch return; out.appendSlice(arena, row) catch return; } } const content = p.gpa.dupe(u8, out.items) catch return; // 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: output_pane.Origin = switch (kind) { .text => .search, .find => .{ .cmd = .Find }, .grep => .{ .cmd = .Grep }, }; // The SAME search asked again REFILLS the list it already opened — // right-clicking a word in four places is one +Search walked four // times, not four +Searches over identical rows. A different pattern // still gets its own buffer, and that IS the old rule: two searches are // two lists, both stay open at their sizes, and the new one stacks // directly below this pane (placeDoc). Focus stays here either way. // ...and it is ANY open list this search already filled, not only the // one n/N are armed on: search `foo`, then `bar`, then `foo` again and // the third search re-arms foo's own buffer rather than opening its // identical twin below it. Same directory only — the rows are written // relative to it, so another dir's list is a different list. Never the // searching pane itself (a `/` inside a +Search writes its own rows). for (p.panes, 0..) |slot, i| { if (i == id) continue; const rp = slot orelse continue; const rf = if (rp.file) |*f| f else continue; const o = rf.output orelse continue; if (!std.meta.eql(o.from, from) or !std.mem.eql(u8, o.arg(), pat)) continue; if (!std.mem.eql(u8, std.fs.path.dirname(rf.path) orelse "", dir)) continue; // a refill that changes NOTHING keeps its place: a right click on // an already-armed word is an `n`, and throwing the list back to // the top only to scroll down to the stepped row is a jump with no // information in it. const same = std.mem.eql(u8, rf.content, content); file_pane.setContent(p, rf, content); if (!same) rf.scroll = 0; pane.search_pane = i; pane.search_row = anchor; return; } const free = p.freeSlot() orelse { p.gpa.free(content); return; }; const np = output_pane.open(p, free, dir, from, pat, content) catch { p.gpa.free(content); return; }; p.placeDoc(id, free, np); p.active = id; pane.search_pane = free; pane.search_row = anchor; } /// n/N: step to the next/previous row of this pane's results buffer and /// ACT on it — which of the two acme verbs that is comes from the buffer's /// own traits, so this is one motion over "a list of things you can run", /// not two kinds of stepping. A location list (every search, the jumplist, /// every language answer) Looks the leading `path:LINE:COL` word; a command /// list (ThemeSel) Execs the whole row. False = no live search: a /// terminal's n/N falls back to lookStep, anything else stays put. 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 = output_pane.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")); const step: i64 = if (pane.search_row) |c| @as(i64, @intCast(c)) + delta else if (delta > 0) 0 else -1; if (step < 0 or step >= nrows) return true; // armed, nowhere left to go const r: i32 = @intCast(step); pane.search_row = @intCast(step); // select the result row in the results pane and keep it in view rp.msel = .{ .active = true, .r0 = r, .r1 = r }; rp.vsel.active = false; rp.nsel = 0; rp.cur_row = r; rp.cur_col = 0; rp.cur_pinned = true; // in view, but WITHOUT scrolloff: a results pane is short, and a // three-row margin on a seven-row one means every single n scrolls the // list out from under the eye. A row already on screen moves nothing. 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); const ln = modal.lineSlice(rf.content, @intCast(step)); // Both arms are the BUILTIN, run on the results pane — the same call a // middle or right click on that row would make, so a stepped row and a // clicked row can never drift apart. A command row goes whole (its // argument is the tail after the name); a location row is cut to the // leading file-ish word, since the rest of it is the matched text. if (tr.executes) { p.runBuiltin(config.exec_cmd, rid, "", std.mem.trim(u8, ln, " \t\r")); } else { var hi: usize = 0; while (hi < ln.len and config.isFileChar(ln[hi])) hi += 1; p.runBuiltin(config.look_cmd, rid, "", ln[0..hi]); } // the look may focus what it opened — a Find row opens a whole new // file pane every time — so focus comes back to the pane that owns the // search and the next n keeps stepping. A `/` row looks at the // searching pane itself, so this is what already happened there. p.active = id; return true; } /// Ask the backend something about the symbol under the cursor. Only a /// real file can be asked: a terminal's rows are a program's output and an /// output buffer is our own text, neither of which has a language behind /// it. Unsupported kinds never get here (the keymap drops them), so a /// backend that answers nothing simply never opens a buffer. 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; // `status` is about the BACKEND, not about a document, so it answers // from ANY pane — a terminal, a +Search, anywhere. That matters // precisely when the pane you are sitting in is the thing going wrong. // Every other kind needs a real file: a terminal's rows are a // program's output and an output buffer is our own text. 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) const off = if (pane.file) |f| modal.hxOff(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, .arg = .from(arg) }; p.emit(.{ .lsp = .{ .id = p.lsp_seq, .kind = kind, .pane = @intCast(id), .offset = @intCast(off), .arg = .from(arg), } }); } /// A worker answered. Rows are `+Search` format with ABSOLUTE paths, so /// both dispositions below are the ordinary look path: /// one row, a goto -> jump straight there (helix jumps on a single /// location and shows a picker on several) /// anything else -> an output buffer, which n/N already steps. That /// buffer IS the picker; there was never one to write. pub fn lspResponse(p: *Pardes, id: u32, rows: []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 pane = p.panes[w.pane] orelse return; if (rows.len == 0) return; const from: output_pane.Origin = .{ .query = w.kind }; const nrows = std.mem.count(u8, rows, "\n"); if (output_pane.traits(from).jumps and nrows == 1) { const ln = std.mem.trimEnd(u8, rows, "\n"); var hi: usize = 0; while (hi < ln.len and config.isFileChar(ln[hi])) hi += 1; // exactly what a `/` result row does when n steps onto it: look the // `path:LINE:COL` token from the pane that asked, so placement, // dedup-onto-an-open-pane and centering are the ONE look path. // (helix would also push its jumplist here; pardes has none, so // there is nothing to push — do not read this as one.) return p.lookAt(w.pane, ln[0..hi]); } 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; const free = p.freeSlot() orelse { p.gpa.free(content); return; }; const np = output_pane.open(p, free, dir, from, w.arg.slice(), content) catch { p.gpa.free(content); return; }; p.placeDoc(w.pane, free, np); p.active = w.pane; // prose is not a list of locations: n/N over a hover blurb would step // to nowhere, so only stepping buffers arm the stepper — and WHICH // query filled it is now the buffer's own record, not a field here. if (output_pane.traits(from).steps) { pane.search_pane = free; pane.search_row = null; } } /// n/N on a terminal pane: a MOTION over lookable tokens. Select the /// next/prev whitespace-separated token that look.resolve can turn into a /// file/dir (several per line: an ls row hops big.txt -> plain.txt), park /// the cursor at its start, open NOTHING — Enter's normal-mode handler /// looks the selection. Wraps around when nothing lies in the direction /// (fresh out of tty mode the cursor sits below the output, so the first /// n lands on the first token). Deliberately only THIS pane's directory, /// where the look itself walks every pane's: the motion resolves every /// token it steps over, so the other directories would cost tokens × /// panes realpaths on a single keystroke — and a token the motion skips /// is still openable by looking it, which is all n/N is a shortcut for. fn lookStep(p: *Pardes, pane: *Pane, pl: PaneLines, delta: i32) void { _ = p; // a pure motion now: Enter's normal-mode handler does the look pane.pinCursor(); // fresh out of tty mode the cursor still tracks the shell const nrows: i32 = @intCast(pl.lines.len); if (nrows == 0) return; var realbuf: [4096]u8 = undefined; var k: i32 = 0; while (k < nrows) : (k += 1) { const r = @mod(pane.cur_row + delta * k, nrows); const ln = pl.lines[@intCast(r)]; var hit_t0: usize = 0; var hit_t1: usize = 0; 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; // on the cursor's own row (no wrap yet) only tokens strictly // past the cursor count, in the motion's direction if (k == 0) { if (delta > 0 and @as(i32, @intCast(t0)) <= pane.cur_col) continue; if (delta < 0 and @as(i32, @intCast(t0)) >= pane.cur_col) continue; } if (look.resolve(ln[t0..i], pane.cwdSlice(), &realbuf) == .none) continue; hit_t0 = t0; hit_t1 = i; if (delta > 0) break; // first token forward; keep the last one backward } if (hit_t1 == 0) continue; // anchor the selection at the token's end, cursor at its START; // EXPLICIT: Enter's look chord acts on it pane.vsel = .{ .active = true, .row = r, .col = @intCast(hit_t1 - 1), .explicit = true }; pane.msel.active = false; pane.nsel = 0; pane.cur_row = r; pane.cur_col = @intCast(hit_t0); pane.cur_pinned = true; pane.ensureCursorVisible(); return; } // nothing lookable anywhere: stay put } const EditText = struct { text: []u8, row0: i32 }; /// The pane's editable text and the absolute surface row its first line /// sits at: a file's whole content at row 0, or a terminal's edit buffer /// at its anchor. Terminals materialize the buffer here on first use and /// grow it whenever an edit reaches past an end, seeding from — and from /// then on standing in for — the shell rows it swallows. /// `col` is the caller's cursor column: row `lo` is padded out to it with /// spaces, because a terminal cursor may sit past the end of a row (acme: /// you click anywhere and type there, which is what the insertion runs /// did by padding at render time). Insert-mode callers pass their column; /// everything else passes -1 and leaves the text alone. /// Null if the pane holds no editable text (an image or PDF projection). fn editText(p: *Pardes, pane: *Pane, lo: i32, hi: i32, col: i32) ?EditText { if (pane.file) |f| return .{ .text = f.content, .row0 = 0 }; if (pane.image != null or hasPdf(pane)) return null; const want_lo = @max(0, @min(lo, hi)); const want_hi = @max(want_lo, @max(lo, hi)); const fresh = pane.ovl == null; const old: Ovl = pane.ovl orelse .{ .row = want_lo, .rows = 1, .text = &.{} }; const lines: i32 = if (fresh) 1 else @intCast(modal.lineCount(old.text)); const up = old.row - want_lo; // shell rows to prepend const down = want_hi - (old.row + lines - 1); // surface rows past its end if (fresh or up > 0 or down > 0) { const rows = term_pane.shellRows(p, pane) catch return null; var buf: std.ArrayList(u8) = .empty; defer buf.deinit(p.gpa); const row0 = old.row - @max(0, up); var g: i32 = row0; while (g < old.row) : (g += 1) { buf.appendSlice(p.gpa, if (@as(usize, @intCast(g)) < rows.len) rows[@intCast(g)] else "") catch return null; buf.append(p.gpa, '\n') catch return null; } if (fresh) buf.appendSlice(p.gpa, if (@as(usize, @intCast(old.row)) < rows.len) rows[@intCast(old.row)] else "") catch return null else buf.appendSlice(p.gpa, old.text) catch return null; var k: i32 = 0; while (k < down) : (k += 1) { const src = old.row + old.rows + k; buf.append(p.gpa, '\n') catch return null; buf.appendSlice(p.gpa, if (@as(usize, @intCast(src)) < rows.len) rows[@intCast(src)] else "") catch return null; } const text = buf.toOwnedSlice(p.gpa) catch return null; if (pane.ovl) |o| p.gpa.free(o.text); pane.ovl = .{ .row = row0, .rows = old.rows + @max(0, up) + @max(0, down), .text = text }; } const o = &pane.ovl.?; const r: usize = @intCast(@max(0, want_lo - o.row)); const llen: i32 = @intCast(modal.lineSlice(o.text, r).len); if (col > llen) { const spaces = p.gpa.alloc(u8, @intCast(col - llen)) catch return null; defer p.gpa.free(spaces); @memset(spaces, ' '); const text = modal.insertAt(p.gpa, o.text, .{ .row = r, .col = @intCast(llen) }, spaces) catch return null; p.gpa.free(o.text); o.text = text; } return .{ .text = o.text, .row0 = o.row }; } /// editText for an op whose selection can END on a line's newline cell: /// eating that newline joins with the line BELOW, so a terminal's buffer /// has to cover that row too (a file's content always already does). fn editTextEol(p: *Pardes, pane: *Pane, b: Bounds) ?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 file_pane.setContent(p, f, new); if (pane.ovl) |*o| { p.gpa.free(o.text); o.text = new; } else p.gpa.free(new); } const InsertAt = enum { at, append, line_start, line_end, open_below, open_above }; fn enterInsert(p: *Pardes, pane: *Pane, where: InsertAt, cnt: usize) void { if (hasPdf(pane)) return; pane.pinCursor(); // 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 pl = p.paneCursorLines(pane) catch { pane.mode = .insert; pane.msel.active = false; pane.vsel.active = false; pane.pending = 0; return; }; const text = p.flatSurface(pane, pl) catch return; const cur = toModalCursor(pane, pl); const llen: usize = if (cur.row < pl.lines.len) pl.lines[cur.row].len else 0; // helix selection-aware entry: `i` to the selection's START (the // selection flips and survives until the first edit); `a` one past // its END, remembering the origin cell for the Esc restore 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.hxOff(text, .{ .row = @intCast(@max(0, hi_row)), .col = @intCast(@max(0, hi_col)) })); const gc = modal.hxPos(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 => { fromModalCursor(pane, pl, modal.firstNonWsOf(pl.lines, cur)); pane.vsel.active = false; }, .line_end => { pane.cur_col = @intCast(llen); pane.cur_pinned = true; pane.vsel.active = false; }, .open_below, .open_above => { pane.vsel.active = false; const below = where == .open_below; // helix `open`: the new line copies the current line's full // indent LEVELS; o opens n lines, cursor on the first 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.hxIndentString(modal.lineSlice(eb.text, row)); const arena = p.scratch.allocator(); var block: std.ArrayList(u8) = .empty; for (0..cnt) |_| { if (below) { block.append(arena, '\n') catch return; block.appendSlice(arena, ind) catch return; } else { block.appendSlice(arena, ind) catch return; block.append(arena, '\n') catch return; } } 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.items) 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; // helix `open` with a COUNT leaves one cursor per line it // opened — `3o` then typing fills all three. The lines are // consecutive and start at the cursor, so this is just cnt // points a row apart, the first one primary. // (Inside a replay pass the extra ranges cannot be created: // a pass produces exactly one range. `3o` at several cursors // therefore opens the lines but keeps the cursors it had.) if (cnt > 1 and !p.multi_on) opened: { const pl2 = p.paneCursorLines(pane) catch break :opened; const t2 = p.flatSurface(pane, pl2) catch break :opened; var rs: [MAX_SELS]modal.HxRange = undefined; const m = @min(cnt, MAX_SELS); for (0..m) |k| { const o = modal.hxOff(t2, .{ .row = @intCast(@max(0, pane.cur_row - pl2.row0) + @as(i32, @intCast(k))), .col = ind.len }); rs[k] = .{ .anchor = o, .head = o }; } setPaneRanges(pane, pl2, t2, rs[0..m], &.{}, 0, false); } }, } pane.mode = .insert; pane.msel.active = false; pane.pending = 0; pane.ensureCursorVisible(); } /// insert mode. ONE path for both pane kinds: a file edits its content, a /// terminal edits the buffer standing in for its shell rows (editText /// materializes and grows it), so typing, Enter, joins and the kill runs /// mean exactly the same thing in a shell pane as in a document. 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 }); // a selection carried into insert (i/a) survives only until the next // key: helix maps it through every edit, pardes drops it instead — // its only pardes use (the acme chords) needs explicit selections // anyway, and those never enter insert mode pane.vsel.active = false; if (!pane.cur_pinned) pane.pinCursor(); // arrows and paging are pure motion over the WHOLE surface, so they // run before editText — a terminal must not freeze shell rows into an // edit buffer just because you walked across them switch (key.cp) { Key.left, Key.right, Key.up, Key.down => { const pl = p.paneCursorLines(pane) catch return; const cur0 = toModalCursor(pane, pl); const nc = switch (key.cp) { Key.left => modal.charLeft(cur0), Key.right => modal.charRight(pl.lines, cur0), Key.up => modal.lineUp(pl.lines, cur0), Key.down => modal.lineDown(pl.lines, cur0), else => cur0, }; fromModalCursor(pane, pl, 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 pl = p.paneCursorLines(pane) catch return; const flat = p.flatSurface(pane, pl) catch return; const range = paneRange(pane, flat, pl.row0); const step: i32 = @intCast(@max(1, pane.rows)); scrollViewMove(pane, pl, 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 pl = p.paneCursorLines(pane) catch return; const cur0 = toModalCursor(pane, pl); pane.cur_col = @intCast(if (cur0.row < pl.lines.len) pl.lines[cur0.row].len else 0); 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)) { // helix delete_word_backward: to the previous word start — // crossing the newline at col 0, which takes the word before it // too, so on the buffer's first line a terminal grows up one row 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.hxOff(e2.text, c2); if (g == 0) return; const wr = modal.hxWordMove(e2.text, .{ .anchor = g, .head = g }, 1, .prev_word_start); const from = @min(wr.anchor, wr.head); const fc = modal.hxPos(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)) { // helix delete_word_forward: to the next word END (trailing // whitespace survives), crossing newlines at line ends — at the // buffer's last line a terminal grows down one row to allow it 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.hxOff(e2.text, c2); const wr = modal.hxWordMove(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.hxPos(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 => { // helix insert_newline: the new line copies the current // line's full indent LEVELS (plain-text heuristic) const ind = modal.hxIndentString(modal.lineSlice(text, c.row)); const arena = p.scratch.allocator(); const block = std.fmt.allocPrint(arena, "\n{s}", .{ind}) catch return; const new = modal.insertAt(p.gpa, text, c, block) catch return; p.setEditText(pane, new); pane.cur_row += 1; pane.cur_col = @intCast(ind.len); pane.cur_pinned = true; pane.ensureCursorVisible(); }, Key.backspace => { if (pane.cur_col > 0) { const new = modal.deleteChar(p.gpa, text, .{ .row = c.row, .col = c.col - 1 }) catch return; p.setEditText(pane, new); pane.cur_col -= 1; 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 => { // helix insert_tab with a Spaces indent style: spaces to // the next tab stop (smart-tab machinery skipped) const pad = modal.INDENT_W - (c.col % modal.INDENT_W); const spaces = " "; const new = modal.insertAt(p.gpa, text, c, spaces[0..pad]) catch return; p.setEditText(pane, new); pane.cur_col += @intCast(pad); pane.cur_pinned = true; pane.ensureCursorVisible(); }, else => {}, } } 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: 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 }); } /// the char selection as text. Read off the pane's SURFACE (file content / /// terminal shell rows + edit buffer), not the rendered body: a yank of a /// whole line has to carry its newline, the way a file's does, or p/P /// paste it charwise. Scratch-owned. fn vselText(p: *Pardes, pane: *Pane) []const u8 { const arena = p.scratch.allocator(); const b = vselBounds(pane); const text = p.surfaceText(pane) orelse 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 pl = p.paneCursorLines(pane) catch return ""; var out: std.ArrayList(u8) = .empty; var i = r0; while (i <= r1) : (i += 1) { if (i > r0) out.append(arena, '\n') catch return ""; if (i >= 0 and @as(usize, @intCast(i)) < pl.lines.len) out.appendSlice(arena, pl.lines[@intCast(i)]) catch return ""; } return out.items; } /// the whole pane surface as flat text, absolute rows from 0 (read-only: /// unlike editText it never materializes a terminal's edit buffer) fn surfaceText(p: *Pardes, pane: *Pane) ?[]const u8 { if (pane.file) |f| return f.content; const pl = p.paneCursorLines(pane) catch return null; return p.flatSurface(pane, pl) catch null; } // ---- 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; clampCursor(pane, 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; // helix yank exits select mode, selection KEPT if (pane.vsel.active) { // helix: yank moves neither the cursor nor the selection 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 { // helix: a bare cursor IS a 1-wide selection — yank the char // under it, file and terminal alike (the line-yank vim-ism died // with the motion model). vselText already speaks both. 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; } } /// helix p/P: a yank ending in '\n' pastes as whole lines below/above /// the SELECTION's line span; anything else splices inline at the /// selection's outer edge. The paste (repeated times) becomes /// the implicit selection, cursor on its last char (linewise: ON the /// last pasted line's newline). fn normalPaste(p: *Pardes, pane: *Pane, before: bool) void { const y0 = p.yank orelse return; if (y0.len == 0) return; p.pushUndo(pane); pane.select = false; pane.sticky_col = -1; const cnt: usize = @max(1, pane.count); pane.count = 0; const arena = p.scratch.allocator(); var y: []const u8 = y0; if (cnt > 1) { var buf: std.ArrayList(u8) = .empty; for (0..cnt) |_| buf.appendSlice(arena, y0) catch return; y = buf.items; } 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]; // a yanked BLANK line is "\n": the block is empty and lineCount // says 0 lines, but it still pastes as one (empty) line — without // the floor every `n - 1` below underflows and panics. 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 gcol = @as(usize, @intCast(@max(0, b.hi_col))) + 1; if (gcol > modal.lineSlice(eb.text, hrow).len) break :blk .{ .row = hrow + 1, .col = 0 }; 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 = y.len > 1, .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(end.col - 1); } 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; // helix: a LINEWISE selection (whole lines incl. their final // newline) deletes the lines and opens a fresh one above, // copying the indent of the line that moves up 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.hxIndentString(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.pending = 0; 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; clampCursor(pane, d.content, eb.row0); pane.mode = .insert; pane.pending = 0; 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; // keep one empty line at r0, drop the rest; yank the removed var buf: std.ArrayList(u8) = .empty; const arena = p.scratch.allocator(); const lo: usize = @intCast(@max(0, r0 - eb.row0)); const hi: usize = @intCast(@max(0, r1 - eb.row0)); var r: usize = lo; while (r <= hi) : (r += 1) { if (r > lo) buf.append(arena, '\n') catch break; buf.appendSlice(arena, modal.lineSlice(eb.text, r)) catch break; } p.setYank(buf.items); 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.pending = 0; 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.pending = 0; } } // ---- helix change ops (r R ~ ` J > < Ctrl-a m-mode ]space) ---- // Terminals go through the same edit buffer as files: since editText // materializes one over whatever rows the op names, every one of these // works the same in a shell pane as in a document. /// the selection as an inclusive cursor range in `content`, whose first /// line is absolute row `row0`: vsel span, msel line span, else the char /// under the cursor (helix's implicit 1-wide selection) 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 = llen -| 1 } }; } 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: u8) 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.replaceChars(p.gpa, eb.text, r.a, r.b, ch) catch return; p.setEditText(pane, new); } /// `R`: replace the selection (or the cursor char) with the yank register; /// the pasted text becomes the selection, head on its last char (which /// for a trailing-newline yank is the '\n' cell of the last full line) fn normalReplaceYank(p: *Pardes, pane: *Pane) void { const y = p.yank orelse return; 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 = y.len > 1, .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(end.col - 1); } 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 }; /// map an original-text offset through a change list (insertions AT a /// position push it right — helix Assoc::After; positions inside a /// deleted span collapse to its start) 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 out: std.ArrayList(u8) = .empty; errdefer out.deinit(p.gpa); var copied: usize = 0; for (chs) |ch| { try out.appendSlice(p.gpa, text[copied..ch.from]); try out.appendSlice(p.gpa, ch.ins); copied = ch.to; } try out.appendSlice(p.gpa, text[copied..]); return out.toOwnedSlice(p.gpa); } /// `J`: helix join_selections — join the selection's line span (a bare /// cursor joins with the next line): each '\n' + following indent become /// one space, EXCEPT before content-less lines (no space) — and on the /// buffer's last line the trailing newline is deleted. The selection and /// cursor map through the edit; the count is ignored (helix). 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 = paneRange(pane, text, eb.row0); const span = rangeLineSpan(text, range); const nlines = modal.hxLineCount(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(); var chs: std.ArrayList(TextChange) = .empty; var l = span.start; while (l < end) : (l += 1) { const from = modal.hxLineEndIdx(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.hxLineEndIdx(text, @min(l + 1, nlines - 1))) "" else " "; chs.append(arena, .{ .from = from, .to = to, .ins = sep }) catch return; } if (chs.items.len == 0) return; p.pushUndo(pane); const cur_off = modal.hxOff(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.hxOff(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.items) catch return; const nc = mapThroughChanges(chs.items, cur_off); const na = mapThroughChanges(chs.items, anc_off); p.setEditText(pane, new); const cc = modal.hxPos(new, nc); const ac = modal.hxPos(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(); } /// `>` / `<`: helix indent/unindent over the selection's line span. /// Blank (all-whitespace) lines are skipped; `>` inserts count levels /// realigned to the next INDENT_W stop; `<` removes up to count levels of /// leading whitespace (a tab advances to the next stop). Cursor and /// selection map through the edit. 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 = paneRange(pane, text, eb.row0); const span = 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, ' '); var chs: std.ArrayList(TextChange) = .empty; var l = span.start; while (l <= span.end) : (l += 1) { const ls = modal.lineStartOffset(text, l); const le = modal.hxLineEndIdx(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.append(arena, .{ .from = ls, .to = ls, .ins = pad[0..ins] }) catch return; } 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.append(arena, .{ .from = ls, .to = ls + pos, .ins = "" }) catch return; } } if (chs.items.len == 0) return; p.pushUndo(pane); const cur_off = modal.hxOff(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.hxOff(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.items) catch return; const nc = mapThroughChanges(chs.items, cur_off); const na = mapThroughChanges(chs.items, anc_off); p.setEditText(pane, new); const cc = modal.hxPos(new, nc); const ac = modal.hxPos(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(); } /// `Ctrl-c`: helix toggle_comments. Every line the selection touches gets /// the language's line-comment token put in front of it — or taken off, /// and WHICH of the two is decided once for the whole set: one uncommented /// non-blank line among them and everything gets commented. That single /// decision is why this is a wholeKey instead of a per-cursor replay; /// replayed, a half-commented block would end up half-commented the other /// way round. /// /// The rest is helix's find_line_comment, quirks included: the token goes /// in at the SHALLOWEST indent in the set (so a deeper line is commented /// mid-whitespace), all-blank lines are skipped entirely and do not vote, /// and uncommenting also eats one space after the token unless some line /// lacks it. 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)); } // ...and ONE ROW PAST them, like normalJoin: a selection may end on // its last line's newline cell, and a terminal buffer that stops at // that line has nowhere to put it (a file's content always does). // Never past the surface's own last row, though — materialising a row // that does not exist yet would ADD a blank line to the pane, and this // op may well decide to change nothing. const pl0 = p.paneCursorLines(pane) catch return; const last_row = pl0.row0 + @as(i32, @intCast(pl0.lines.len)) - 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: [MAX_SELS]modal.HxRange = undefined; const got = paneRanges(pane, text, eb.row0, &rs); const nlines = modal.hxLineCount(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) var lines: std.ArrayList(usize) = .empty; var next: usize = 0; for (rs[0..got.n]) |r| { const span = rangeLineSpan(text, r); var l = @max(span.start, next); const end = @min(span.end + 1, nlines); while (l < end) : (l += 1) lines.append(arena, l) catch return; next = @max(next, end); } // which token: the file's EXTENSION, which is the same thing // src/syntax.zig tells languages apart by, read off the one table in // config. A terminal and an output buffer have no extension and get // the default, which is what helix does for a buffer with no language. 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.items) |l| { const line = text[modal.lineStartOffset(text, l)..modal.hxLineEndIdx(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; var chs: std.ArrayList(TextChange) = .empty; for (lines.items) |l| { const ls = modal.lineStartOffset(text, l); const le = modal.hxLineEndIdx(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.append(arena, if (commented) .{ .from = at, .to = @min(at + token.len + margin, le), .ins = "" } else .{ .from = at, .to = at, .ins = ins }) catch return; } p.pushUndo(pane); // one edit, and the WHOLE selection rides through it (helix maps the // selection with the transaction) var cells: [MAX_SELS]SelRange = undefined; const new = p.applyChanges(text, chs.items) catch return; for (rs[0..got.n], 0..) |r, i| { const c = rangeCells(new, .{ .anchor = mapThroughChanges(chs.items, r.anchor), .head = mapThroughChanges(chs.items, r.head), }); const cc = modal.hxPos(new, c.cur); const ac = modal.hxPos(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 pl2 = p.paneCursorLines(pane) catch return; const t2 = p.flatSurface(pane, pl2) catch return; for (cells[0..got.n], 0..) |s, i| rs[i] = cellRange(t2, s.arow - pl2.row0, s.acol, s.row - pl2.row0, s.col); setPaneRanges(pane, pl2, t2, rs[0..got.n], &.{}, got.pri, expl); } /// `Ctrl-a` / `Ctrl-x`: increment/decrement the SELECTION as a decimal /// integer (helix: the selected fragment itself, no number scan around /// the cursor); a fragment that isn't an integer is a no-op. The new /// number becomes the selection, cursor on its last char. 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.hxIncrement(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.hxPos(new, start + rep.len - 1); pane.vsel = .{ .active = rep.len > 1, .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, }; } /// `mi` / `ma`: select inside/around a textobject (pure range /// math over the motion surface — works on terminals too). Word and /// paragraph objects are helix textobject.rs ports; pairs/quotes are the /// plain-text scans (quotes line-scoped — ponytail). fn textobjectSelect(p: *Pardes, pane: *Pane, pl: PaneLines, obj: u21, around: bool) void { const text = p.flatSurface(pane, pl) catch return; const range = paneRange(pane, text, pl.row0); switch (obj) { 'w', 'W' => { const r = modal.hxTextobjectWord(text, range, around, obj == 'W'); return setPaneRange(pane, pl, text, r, false); }, 'p' => { const r = modal.hxTextobjectParagraph(text, range, around, 1); return setPaneRange(pane, pl, text, r, false); }, else => {}, } const cur = modal.hxPos(text, modal.hxCursor(text, range)); const pair: ?modal.Range = switch (obj) { '\'', '"', '`' => modal.enclosingQuote(pl.lines, cur, @intCast(obj)), '(', ')' => modal.enclosingPair(pl.lines, cur, '(', ')'), '[', ']' => modal.enclosingPair(pl.lines, cur, '[', ']'), '{', '}' => modal.enclosingPair(pl.lines, cur, '{', '}'), '<', '>' => modal.enclosingPair(pl.lines, cur, '<', '>'), else => null, }; const pr = pair orelse return; var a = modal.hxOff(text, pr.a); var head = modal.nextGrapheme(text, modal.hxOff(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); } setPaneRange(pane, pl, text, .{ .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); var new = modal.insertAt(p.gpa, eb.text, .{ .row = r.b.row, .col = r.b.col + 1 }, &[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(r.b.col + 1 + @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, pl: PaneLines, ch: u21) void { const pr = pairFor(ch) orelse return; // the pair is found over the motion surface, whose rows are absolute; // the edit runs in the buffer covering those rows, and every offset // below is taken in THAT text so the cursor maps through it const cur = toModalCursor(pane, pl); const r = (if (pr.o == pr.c) modal.enclosingQuote(pl.lines, cur, pr.o) else modal.enclosingPair(pl.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 + pl.row0, hi + pl.row0, -1) orelse return; const text = eb.text; const drow = pl.row0 - 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.hxOff(text, ra); const b_off = modal.hxOff(text, rb); const cur_off = modal.hxOff(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.hxPos(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, pl: PaneLines, from: u21, to: u21) void { const fp = pairFor(from) orelse return; const tp = pairFor(to) orelse return; const cur = toModalCursor(pane, pl); const r = (if (fp.o == fp.c) modal.enclosingQuote(pl.lines, cur, fp.o) else modal.enclosingPair(pl.lines, cur, fp.o, fp.c)) orelse return; const eb = p.editText(pane, @as(i32, @intCast(r.a.row)) + pl.row0, @as(i32, @intCast(r.b.row)) + pl.row0, -1) orelse return; const drow = pl.row0 - 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 ---- /// pull the cursor back inside `text` after a rewrite; `row0` is the /// absolute surface row of its first line (0 for a file) fn clampCursor(pane: *Pane, text: []const u8, row0: i32) void { const n = modal.lineCount(text); const row: usize = @min(@as(usize, @intCast(@max(0, pane.cur_row - row0))), if (n == 0) 0 else n - 1); const llen = modal.lineSlice(text, row).len; pane.cur_row = @as(i32, @intCast(row)) + row0; pane.cur_col = @intCast(@min(@as(usize, @intCast(@max(0, pane.cur_col))), llen)); pane.cur_pinned = true; pane.vsel.active = false; pane.msel.active = false; pane.ensureCursorVisible(); } fn pushUndo(p: *Pardes, pane: *Pane) void { // one keystroke, one undo step — even when it edited at ten cursors. // (The content-equality guard below cannot do this on its own: by the // second pass the buffer HAS changed, so it would push again.) if (p.multi_on and !p.multi_first) return; if (pane.file) |*f| { if (f.undo.items.len > 0 and std.mem.eql(u8, f.undo.items[f.undo.items.len - 1].content, f.content)) return; const snap: FileSnap = .{ .content = p.gpa.dupe(u8, f.content) catch return, .cur_row = pane.cur_row, .cur_col = pane.cur_col, .vsel = pane.vsel, }; f.undo.append(p.gpa, snap) catch { p.gpa.free(snap.content); return; }; if (f.undo.items.len > UNDO_MAX) p.gpa.free(f.undo.orderedRemove(0).content); for (f.redo.items) |s| p.gpa.free(s.content); f.redo.clearRetainingCapacity(); } else { const cur = pane.ovl orelse Ovl{ .rows = 0 }; if (pane.ed_undo.getLastOrNull()) |top| { const same = if (top.ovl) |o| pane.ovl != null and o.row == cur.row and o.rows == cur.rows and std.mem.eql(u8, o.text, cur.text) else pane.ovl == null; if (same) return; } const snap = term_pane.snap(p, pane) orelse return; pane.ed_undo.append(p.gpa, snap) catch { if (snap.ovl) |o| p.gpa.free(o.text); return; }; if (pane.ed_undo.items.len > EDIT_UNDO_MAX) { const drop = pane.ed_undo.orderedRemove(0); if (drop.ovl) |o| p.gpa.free(o.text); } for (pane.ed_redo.items) |sn| if (sn.ovl) |o| p.gpa.free(o.text); pane.ed_redo.clearRetainingCapacity(); } } fn doUndo(p: *Pardes, pane: *Pane) void { if (pane.file) |*f| { if (f.undo.items.len == 0) return; const cur: FileSnap = .{ .content = p.gpa.dupe(u8, f.content) catch return, .cur_row = pane.cur_row, .cur_col = pane.cur_col, .vsel = pane.vsel, }; f.redo.append(p.gpa, cur) catch { p.gpa.free(cur.content); return; }; const prev = f.undo.pop() orelse return; file_pane.setContent(p, f, prev.content); file_pane.restoreSnap(pane, f, prev); return; } if (pane.ed_undo.items.len == 0) return; const cur = term_pane.snap(p, pane) orelse return; pane.ed_redo.append(p.gpa, cur) catch { if (cur.ovl) |o| p.gpa.free(o.text); return; }; term_pane.restoreSnap(p, pane, pane.ed_undo.pop() orelse return); } fn doRedo(p: *Pardes, pane: *Pane) void { if (pane.file) |*f| { if (f.redo.items.len == 0) return; const cur: FileSnap = .{ .content = p.gpa.dupe(u8, f.content) catch return, .cur_row = pane.cur_row, .cur_col = pane.cur_col, .vsel = pane.vsel, }; f.undo.append(p.gpa, cur) catch { p.gpa.free(cur.content); return; }; const next = f.redo.pop() orelse return; file_pane.setContent(p, f, next.content); file_pane.restoreSnap(pane, f, next); return; } if (pane.ed_redo.items.len == 0) return; const cur = term_pane.snap(p, pane) orelse return; pane.ed_undo.append(p.gpa, cur) catch { if (cur.ovl) |o| p.gpa.free(o.text); return; }; term_pane.restoreSnap(p, pane, pane.ed_redo.pop() orelse return); } /// Something outside pardes rewrote a file we have open. Commit the buffer /// we are holding — unsaved edits and all — onto the undo stack, then swap /// in the bytes from disk. /// /// THAT IS THE WHOLE DESIGN, not an implementation detail. An external /// update is pushed exactly like an edit the user typed, so `u` walks back /// to the buffer as it was a keystroke ago and unsaved work is never lost. /// It is also why the feature is nearly free: pardes never has to merge its /// buffer against the new file, never has to ask "reload? keep? diff?", and /// there is no third state between "what is on disk" and "what I typed" — /// the undo stack, which already exists, IS the history of both. fn fileChanged(p: *Pardes, id: u8, bytes: []const u8) void { const pane = p.panes[id] orelse return; if (pane.file == null) return; const f = &pane.file.?; // the disk arrived at what we already hold: an undo step that changes // nothing is worse than no undo step if (std.mem.eql(u8, f.content, bytes)) return; const new = p.gpa.dupe(u8, bytes) catch return; p.pushUndo(pane); file_pane.setContent(p, f, new); // keep the cursor where it was, clamped onto the new text: restoreSnap // reads only the cursor and selection out of the snapshot it is handed, // so handing it the pane's own is exactly "clamp what you have". file_pane.restoreSnap(pane, f, .{ .content = undefined, .cur_row = pane.cur_row, .cur_col = pane.cur_col, .vsel = pane.vsel, }); } fn handleMouse(p: *Pardes, m: Mouse) void { const mcol = @min(m.col, p.screen_w -| 1); const mrow = @min(m.row, p.screen_h -| 1); p.hover_col = mcol; p.hover_row = mrow; const hovered: ?usize = for (p.panes, 0..) |slot, i| { if (slot == null) continue; const r = p.rects[i]; if (mcol >= r.x and mcol < r.x + r.w and mrow >= r.y and mrow < r.y + r.h) break i; } else null; // 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; } } // ...and any press at all takes the keyboard back off the topbar: the // mouse names where focus goes, so leaving a cursor parked on row 0 // while you click into a pane would just be a lie if (m.kind == .press) p.topbar_col = null; switch (m.button) { .none => {}, // hover tracked above; motion has no other meaning .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 (hasPdf(pane)) p.pdfVerticalWheel(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; // a mostly-vertical two-finger swipe's sideways drift dies // here rather than sliding the view out from under a scroll. // Charged against the gesture, not the pane, so it runs before // we ask what is hovered. if (!config.wheelTick(&p.wheel_guard, false)) return; const id = hovered orelse return; const pane = p.panes[id].?; if (hasPdf(pane)) { p.pdfHorizontalWheel(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) { pane.hscroll = @max(0, pane.hscroll + (if (m.button == .wheel_right) config.wheel_cols else -config.wheel_cols)); } }, config.select_button => switch (m.kind) { .press => { // acme 2-1: a select press during an EXECUTE drag captures // a selection (heldSelection) as the execute's argument; // the execute drag keeps running to its release, which // consumes it. 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; p.drag = .none; return; } if (mrow < TOPBAR_H) return; // topbar: a select click is deliberately inert // the layout box (gutter cells of the tag row) wins over // the resize handles: a tag-only pane's single row IS its // pair's h-handle, and the box must stay grabbable to move if (hovered) |mid| { const mr = p.rects[mid]; if (mcol < mr.x + config.GUTTER and mrow < mr.y + BOX_H) { p.drag = .{ .move = .{ .id = mid, .cur_x = mcol, .cur_y = mrow } }; return; } } // resize handles next: a pane's own trailing edge (v: the // left column's last col; h: the upper pane's last row). // The v test still wins outright, but it now also asks // whether this same cell is one of ITS OWN column's h // handles — that cell is the corner where the two lines // cross, and grabbing it drags both boundaries at once // (see Drag.border_v for why only the left column counts). for (0..p.ncol -| 1) |c| { if (mcol == p.col_x[c] + p.col_w[c] -| 1) { var corner: ?usize = null; for (0..p.col_n[c] -| 1) |k| { const r = p.rects[p.col_terms[c][k]]; if (mrow == r.y + r.h -| 1) { corner = k; break; } } p.drag = .{ .border_v = .{ .left_col = c, .cur_x = mcol, .corner_idx = 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; for (0..p.col_n[cc] -| 1) |k| { const r = p.rects[p.col_terms[cc][k]]; if (mrow == r.y + r.h -| 1) { 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 (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, r.y + BOX_H))); } else if (mrow < r.y + BOX_H) { // left press on the tag row: focus the tag AT that // column (a rendered-tag column — the mouse and the tag // agree, no clamp into the tail) and anchor a sweep, so // dragging selects any span of it, path included p.active = id; p.enterTagEdit(pane, @as(i32, mcol) - @as(i32, r.x + config.GUTTER)); pane.tag_anchor = pane.tag_col; p.drag = .{ .tag = .{ .id = id } }; } else { p.active = id; exitTagEdit(pane); // clicking the body leaves tag editing const sc: i32 = @as(i32, mcol) - @as(i32, r.x + config.GUTTER); const v: i32 = @as(i32, mrow) - @as(i32, r.y); pane.sel[sel_slot] = .{ .state = .dragging, .c0 = sc, .c1 = sc, .r0 = v, .r1 = v }; const pdf_native = if (comptime pdf_enabled) p.pdfNativeReady(pane) else false; if (pdf_native) _ = p.beginPdfDrag(pane, mcol, mrow, false); // Ctrl rides on the drag rather than firing here: the // click does not place the modal cursor until RELEASE // (dragRelease), and a goto asked at press time would // answer about wherever the cursor happened to be. p.drag = .{ .select = .{ .id = id, .button = config.select_button, .ctrl = m.ctrl, .pdf = if (comptime pdf_enabled) .{ .native = pdf_native } else .{}, } }; } }, .drag => p.dragUpdate(mcol, mrow), .release => 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; p.drag = .none; return; } if (mrow < TOPBAR_H) { // global tagbar: EXECUTE runs the command under the // click (a stray select click must never Kill) if (m.button == config.exec_button) { var tb_buf: [1200]u8 = undefined; const word = wordAtCol(p.topbar(&tb_buf), mcol); // a topbar word runs on the PRESS — there is no // drag to chord into, so the argument is simply // whatever is selected right now: select a word, // then click Grep, and it greps that word. if (word.len > 0) _ = p.execute(p.active, p.withArg(word, p.heldSelection(p.active))); } return; } const id = hovered orelse return; const r = p.rects[id]; const pane = p.panes[id] orelse return; if (mcol < r.x + config.GUTTER and mrow >= r.y + BOX_H) { // gutter: right scrolls DOWN to here, mirroring left's up; // middle matches left. Right focuses (look lands you // there); middle does not. const local = @as(i32, mrow) - @as(i32, r.y + BOX_H); if (m.button == config.look_button) p.active = id; pane.scrollBy(if (m.button == config.look_button) local else -local); } else if (mcol >= r.x + config.GUTTER) { if (m.button == config.look_button) p.active = id; const c: i32 = @as(i32, mcol) - @as(i32, r.x + config.GUTTER); const v: i32 = @as(i32, mrow) - @as(i32, r.y); pane.sel[@intFromEnum(m.button)] = .{ .state = .dragging, .c0 = c, .c1 = c, .r0 = v, .r1 = v }; // Once a body gesture starts over a native PDF, keep // it native even when the point misses a letterbox or // selection fails. pdfDragRelease makes a null anchor // inert instead of executing hidden projected text. // Tag clicks remain ordinary text/builtin gestures. const pdf_native = if (comptime pdf_enabled) mrow >= r.y + BOX_H and p.pdfNativeReady(pane) else false; if (pdf_native) _ = p.beginPdfDrag(pane, mcol, mrow, true); p.drag = .{ .select = .{ .id = id, .button = m.button, .pdf = if (comptime pdf_enabled) .{ .native = pdf_native } else .{}, } }; } }, .drag => p.dragUpdate(mcol, mrow), .release => p.dragRelease(m.button), .motion => {}, }, } } fn dragUpdate(p: *Pardes, mcol: u16, mrow: u16) 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; // a corner also drives left_col's pane pair, off the SAME mouse // position but through its own clamp — the geometry a clamp // reads (widths for x, heights for y) is frozen for the whole // drag and never crosses axes, so one edge parked at its stop // leaves the other tracking the mouse if (d.corner_idx) |k| if (k + 1 < p.col_n[c]) { const a = p.rects[p.col_terms[c][k]]; const b = p.rects[p.col_terms[c][k + 1]]; d.cur_y = clampBorderRow(a.y, a.h, b.h, mrow); } 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_terms[cc][k]]; const b = p.rects[p.col_terms[cc][k + 1]]; d.cur_y = clampBorderRow(a.y, a.h, b.h, mrow); } else d.cur_y = mrow; }, .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); pane.sel[b].c1 = @as(i32, mcol) - @as(i32, r.x + config.GUTTER); pane.sel[b].r1 = @as(i32, mrow) - @as(i32, r.y); if (comptime pdf_enabled) { if (s.pdf.native) { if (p.updatePdfSelection(pane, mcol, mrow, false)) s.pdf.selection_changed = true; } } } }, .tag => |d| { // the tag cursor follows the mouse across the rendered tag; the // row is ignored (a tag is one line) and the anchor stays where // the press put it, so this is the mouse's `v` const pane = p.panes[d.id] orelse return; const end: i32 = @intCast((p.tagPrefix(pane) catch return).len + pane.tag_tail.items.len); const c = @as(i32, mcol) - @as(i32, p.rects[d.id].x + config.GUTTER); pane.tag_col = @intCast(std.math.clamp(c, 0, end)); pane.tag_sel = pane.tag_col != pane.tag_anchor; }, .none => {}, } } fn pdfDragRelease(p: *Pardes, pane: *Pane, s: @FieldType(Drag, "select")) void { if (comptime !pdf_enabled) return; const pv = &(pane.pdf orelse return); const b = @intFromEnum(s.button); const sl = pane.sel[b]; pane.sel[b].state = .none; // native quads, not the projected text grid // Every native drag keeps its geometry/text live while holding the // baked highlight stable. Commit before chord/Exec/Look dispatch; // those paths may clear or even delete the pane. if (s.pdf.selection_changed) p.invalidatePdfRaster(pv); if (s.chorded) { clearPdfDrag(pv); return; } if (s.button == config.select_button) { const dragged = sl.c0 != sl.c1 or sl.r0 != sl.r1; if (!dragged) { p.clearPdfSelection(pane); } pane.cur_pinned = true; pane.mode = .normal; pane.msel.active = false; pane.vsel.active = false; pane.pending = 0; pane.nsel = 0; clearPdfDrag(pv); return; } if (pv.drag_anchor == null or pv.selection_text.len == 0) { clearPdfDrag(pv); return; } const text = pv.selection_text; // Exec can run Del and free `pane`; end every pv access before the // builtin dispatch so no deferred cleanup writes through dead state. clearPdfDrag(pv); const arg = p.chord_arg; p.chord_arg = null; defer if (arg) |a| p.gpa.free(a); const cmd = if (s.button == config.look_button) config.look_cmd else config.exec_cmd; p.runBuiltin(cmd, s.id, "", p.withArg(text, arg)); } fn dragRelease(p: *Pardes, button: Mouse.Button) void { // a release of a button OTHER than the one driving a select drag is // the middle+left chord's left release: the middle drag keeps going if (p.drag == .select and button != p.drag.select.button) return; defer p.drag = .none; switch (p.drag) { .border_v => |d| { const c = d.left_col; if (c + 1 < p.ncol) { const combined: f32 = @floatFromInt(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: f32 = @floatFromInt((d.cur_x + 1) -| p.col_x[c]); nl = std.math.clamp(nl, @as(f32, config.MINW), @max(@as(f32, config.MINW), combined - config.MINW)); const pair = p.col_weight[c] + p.col_weight[c + 1]; p.col_weight[c] = pair * (nl / combined); p.col_weight[c + 1] = pair - p.col_weight[c]; } // and the corner's other half. The two commits are independent // — column weights are widths, pane vweights are heights, and // neither reads the other — so the order here does not matter // and a failed one cannot lose the other. if (d.corner_idx) |k| p.applyRowSplit(c, k, d.cur_y); }, .border_h => |d| p.applyRowSplit(d.col, d.top_idx, d.cur_y), .move => |d| { p.moveTerm(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 (p.layoutFindTerm(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) return p.pdfDragRelease(pane, s); } 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) { // keep a dragged selection highlighted; a plain click clears // it. Either way pin the cursor; files also enter normal // mode, terminals keep the mode they had (tty keeps the // mouse usable, insert keeps typing where you clicked). 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; const body_vis = sl.r1 - @as(i32, BOX_H); if (body_vis >= 0 and pane.mode != .tty) { pane.cur_row = body_vis + pane.scroll(); pane.cur_col = if (pane.file != null) @max(0, sl.c1 - @as(i32, config.PREFIX_W)) + pane.hscroll else sl.c1; pane.cur_pinned = true; if (!pane.isTerminal()) pane.mode = .normal; pane.msel.active = false; pane.pending = 0; // a chord/motion selection is residue: a fresh click // dismisses it rather than dragging the anchored span // to the click (explicit v/x keeps vim's click-extend) if (!pane.vsel.explicit) pane.vsel.active = false; pane.nsel = 0; // a click says WHERE the one cursor is // Ctrl-click IS `gd`, asked now that the cursor has // landed — the mouse spelling of the keyboard motion, // through the identical request. A ctrl-DRAG still // selects and still asks, about where it started, // which is the same thing `gd` would answer. if (s.ctrl) p.lspRequest(s.id, .definition, ""); } } else { // acme execute (middle) / look (right): a no-drag click // expands to the word under it first; a captured chord // argument rides along and is consumed here. // WHERE THE CLICK LANDED, before any expansion — as a body // position, the way the left button converts its drag end. const clk = pane.sel[b]; const cvis = clk.r0 - @as(i32, BOX_H); const crow = cvis + pane.scroll(); const ccol = if (pane.file != null) @max(0, clk.c0 - @as(i32, config.PREFIX_W)) + pane.hscroll else clk.c0; // A click INSIDE a live selection takes the WHOLE selection // and expands nothing: you already said what you meant, and // the file-ish word heuristic can only cut it down — a path // with a space in it, `git log -p`, half a URL. This is the // rule the keyboard chords follow (an explicit modal // selection wins over the word under the cursor), asked of // where the click landed rather than where the cursor is, // so a selection left lying in a pane never traps the // buttons: click OUTSIDE it and the ordinary word expansion // is back. A kept left sweep is screen-coordinate; the // modal one (v/x, n/N) is in absolute rows, hence the two // spellings of the same question. const ls = pane.sel[sel_slot]; const held: ?[]const u8 = if (ls.state == .done and spanHas(clk.r0, clk.c0, ls.r0, ls.c0, ls.r1, ls.c1)) (p.selectionText(pane, ls) catch null) else if (pane.vsel.active and pane.vsel.explicit) (if (spanHas(crow, ccol, pane.vsel.row, pane.vsel.col, pane.cur_row, pane.cur_col)) p.currentSelText(pane) else null) else if (pane.msel.active and crow >= @min(pane.msel.r0, pane.msel.r1) and crow <= @max(pane.msel.r0, pane.msel.r1)) p.currentSelText(pane) else null; if (held == null) p.expandSel(pane, &pane.sel[b]); // A look that names no file SEARCHES from where you are, so // the click has to say where that is and the search walks // forward from there. Not PINNED — a shell's cursor still // belongs to the shell, and the search reads this in the // same event. if (s.button == config.look_button and cvis >= 0 and pane.mode != .tty) { pane.cur_row = crow; pane.cur_col = ccol; } const txt = held orelse (p.selectionText(pane, pane.sel[b]) catch null); const arg = p.chord_arg; p.chord_arg = null; defer if (arg) |a| p.gpa.free(a); const cmd = if (s.button == config.look_button) config.look_cmd else config.exec_cmd; if (txt) |tx| p.runBuiltin(cmd, s.id, "", p.withArg(tx, arg)); } }, .tag => {}, // dragUpdate already left the tag cursor + selection set .none => {}, } } /// Commit a horizontal border to the pane weights: in column `cc`, the pane /// at index `k` and the one under it split their combined height at row /// `cur_y`. Shared by the plain h-drag and the h half of a corner drag — /// the same gesture landing on the same boundary must settle identically /// whether or not a column edge came along for the ride. fn applyRowSplit(p: *Pardes, cc: usize, k: usize, cur_y: u16) void { if (k + 1 >= p.col_n[cc]) return; const a = p.panes[p.col_terms[cc][k]] orelse return; const b = p.panes[p.col_terms[cc][k + 1]] orelse return; const ra = p.rects[p.col_terms[cc][k]]; const rb = p.rects[p.col_terms[cc][k + 1]]; const combined: f32 = @floatFromInt(ra.h + rb.h); var nt: f32 = @floatFromInt((cur_y + 1) -| ra.y); nt = std.math.clamp(nt, @as(f32, BOX_H), @max(@as(f32, BOX_H), combined - BOX_H)); const pair = a.vweight + b.vweight; a.vweight = pair * (nt / combined); b.vweight = pair - a.vweight; } /// acme 1-2 (Cut) / 1-3 (Paste): middle or right tapped while the left /// button holds a selection drag. The first tap converts the mouse /// selection into the modal one (vsel anchored at the drag start, cursor /// at its end; a plain click is a bare cursor). Cut yanks + deletes the /// selection; Paste replaces it with the yank register, or splices the /// register in literally at a bare cursor — so 1-2 then 1-3 in one hold /// nets out to Snarf (copy: the text returns, the register keeps it). /// In a tty-mode shell 1-3 instead pastes into the pty (forwarded click /// + bracketed paste) and 1-2 is inert. 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) { // tty: nothing can be cut — 1-2 stays inert; the pty owns the // screen. 1-3 pastes like a terminal emulator: the click is // forwarded first (only when the app listens for mouse) so // mouse-aware programs put their cursor under it, then the // register is typed — bracketed when the app set mode 2004 // (readline/vim/helix strip the markers), else with \n turned // to \r like any unbracketed paste. 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; const m = &pane.vt.modes; if (m.get(.mouse_event_normal) or m.get(.mouse_event_button) or m.get(.mouse_event_any)) { // dragUpdate keeps sel[sel_slot] tracking the held select button, so // the click lands where the mouse is at this tap; 1-based, // body-relative (the tag row is ours, not the app's) 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 (m.get(.mouse_format_sgr)) { 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 }); } } if (m.get(.bracketed_paste)) { p.emitWrite(s.id, "\x1b[200~"); p.emitWrite(s.id, y); p.emitWrite(s.id, "\x1b[201~"); } else { const cp = p.scratch.allocator().dupe(u8, y) catch return; for (cp) |*c| { if (c.* == '\n') c.* = '\r'; } p.emitWrite(s.id, cp); } 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) config.PREFIX_W else 0; const hs: i32 = if (pane.file != null) pane.hscroll else 0; const row0 = @max(0, sl.r0 - @as(i32, BOX_H)) + pane.scroll(); const row1 = @max(0, sl.r1 - @as(i32, BOX_H)) + pane.scroll(); const col0 = @max(0, sl.c0 - pfx) + hs; const col1 = @max(0, sl.c1 - pfx) + hs; pane.cur_row = row1; pane.cur_col = col1; pane.cur_pinned = true; pane.msel.active = false; pane.pending = 0; 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; file_pane.setContent(p, f, new); pane.vsel = .{ .active = y.len > 1, .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(end.col - 1); } 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.normalPaste(pane, true); // terminal: splice run text at the cursor } /// tty mode: encode the key the way a terminal would and send it to the pty. fn forwardKey(p: *Pardes, id: usize, key: Key) void { var cbuf: [1]u8 = undefined; const bytes: ?[]const u8 = blk: { if (key.ctrl) { if (key.cp >= 'a' and key.cp <= 'z') { cbuf[0] = @intCast(key.cp - 0x60); break :blk cbuf[0..1]; } if (key.cp == '@') { cbuf[0] = 0; break :blk cbuf[0..1]; } if (key.cp >= '[' and key.cp <= '_') { cbuf[0] = @intCast(key.cp - 0x40); break :blk cbuf[0..1]; } if (key.cp >= 'A' and key.cp <= 'Z') { cbuf[0] = @intCast(key.cp - 0x40); break :blk cbuf[0..1]; } } if (key.text.len > 0) break :blk key.text; break :blk switch (key.cp) { Key.enter => "\r", Key.backspace => "\x7f", Key.tab => "\t", Key.escape => "\x1b", Key.up => "\x1b[A", Key.down => "\x1b[B", Key.right => "\x1b[C", Key.left => "\x1b[D", Key.delete => "\x1b[3~", else => null, }; }; if (bytes) |b| p.emit(.{ .write = .{ .pane = @intCast(id), .bytes = .from(b) } }); } // ---- layout surgery ---- pub fn layoutFindTerm(p: *Pardes, id: usize) ?struct { col: usize, idx: usize } { for (0..p.ncol) |c| { for (0..p.col_n[c]) |k| { if (p.col_terms[c][k] == id) return .{ .col = c, .idx = k }; } } return null; } fn layoutInsert(p: *Pardes, c: usize, idx: usize, id: usize) void { var k = p.col_n[c]; while (k > idx) : (k -= 1) p.col_terms[c][k] = p.col_terms[c][k - 1]; p.col_terms[c][idx] = id; p.col_n[c] += 1; } /// Remove from the layout. An emptied column hands its width to a neighbor /// (else every surviving column reflows sideways) before shifting down. pub fn layoutRemove(p: *Pardes, id: usize) void { const f = p.layoutFindTerm(id) orelse return; const c = f.col; var k = f.idx; while (k + 1 < p.col_n[c]) : (k += 1) p.col_terms[c][k] = p.col_terms[c][k + 1]; p.col_n[c] -= 1; if (p.col_n[c] == 0) { if (p.ncol > 1) p.col_weight[if (c > 0) c - 1 else c + 1] += p.col_weight[c]; var j = c; while (j + 1 < p.ncol) : (j += 1) { p.col_terms[j] = p.col_terms[j + 1]; p.col_n[j] = p.col_n[j + 1]; p.col_weight[j] = p.col_weight[j + 1]; } p.ncol -= 1; } } pub fn layoutAppendColumn(p: *Pardes, id: usize) void { p.layoutInsertColumn(p.ncol, id); } /// a fresh single-pane column at index `at` (the columns from there on /// shift right); an equal weight, so the others narrow proportionally fn layoutInsertColumn(p: *Pardes, at: usize, id: usize) void { if (p.ncol >= MAX_COLS) return; const c = @min(at, p.ncol); var j = p.ncol; while (j > c) : (j -= 1) { p.col_terms[j] = p.col_terms[j - 1]; p.col_n[j] = p.col_n[j - 1]; p.col_weight[j] = p.col_weight[j - 1]; } p.col_weight[c] = 1; p.col_terms[c][0] = id; p.col_n[c] = 1; p.ncol += 1; } /// Snap every pane in column `c` to its on-screen row count so later /// weight edits move ONLY the panes they name: computeGeom rounds /// round(avail*w/vsum) per pane, and with fractional weights a split or /// absorb elsewhere in the column can jiggle a bystander by a row. fn snapColWeights(p: *Pardes, c: usize) void { for (0..p.col_n[c]) |k| { const pid = p.col_terms[c][k]; if (p.panes[pid]) |pp| pp.vweight = @floatFromInt(@max(1, p.rects[pid].h)); } } /// Hand a dying pane's rows to ONE sibling (the pane above, or below for /// the topmost) so the rest of the column keeps its sizes bit-identical — /// the deletion mirror of splitBelow. Call while `id` is still in the /// layout, with rects current. pub fn absorbVWeight(p: *Pardes, id: usize) void { const f = p.layoutFindTerm(id) orelse return; if (p.col_n[f.col] <= 1) return; p.snapColWeights(f.col); // the pane above, but never a result list: walking past a stack of them // lands on the pane that spawned it, so those keep their heights and // only the spawner grows. The topmost pane has only what is below it. var sib = if (f.idx > 0) p.col_terms[f.col][f.idx - 1] else p.col_terms[f.col][f.idx + 1]; var k = f.idx; while (k > 0) : (k -= 1) { sib = p.col_terms[f.col][k - 1]; if (p.panes[sib]) |pp| if (if (pp.file) |ff| output_pane.fileTraits(ff.output).doc else true) break; } if (p.panes[sib]) |s| s.vweight += @as(f32, @floatFromInt(@max(1, p.rects[id].h))); } /// The parent a new pane splits from must be tall enough that splitBelow /// leaves the NEW pane at least 2 body rows (the parent keeps tag+1 row, /// the new pane needs tag+2). A too-short choice is swapped for a /// qualifying pane (same column first, keeping the split local), else the /// tallest pane anywhere. fn splitParent(p: *Pardes, want: usize) usize { const need = 2 * BOX_H + 3; if (p.rects[want].h >= need) return want; if (p.layoutFindTerm(want)) |f| for (0..p.col_n[f.col]) |k| { if (p.rects[p.col_terms[f.col][k]].h >= need) return p.col_terms[f.col][k]; }; var tallest = want; for (0..p.ncol) |c| for (0..p.col_n[c]) |k| { const pid = p.col_terms[c][k]; if (p.rects[pid].h >= need) return pid; if (p.rects[pid].h > p.rects[tallest].h) tallest = pid; }; return tallest; } /// Open a window BELOW `src` (acme-style) without rebalancing the column: /// shrink ONLY src to its content height (cursor row kept visible) and hand /// the freed rows to `nw` — together they fill src's old slot and the other /// panes keep their sizes bit-identical (weights snap to row counts). fn splitBelow(p: *Pardes, src_id: usize, nw: *Pane) void { const src = p.panes[src_id] orelse return; const src_h = p.rects[src_id].h; const body: u16 = if (src_h > BOX_H) src_h - BOX_H else 1; const cur: u16 = if (!src.isTerminal()) body / 2 else @as(u16, @intCast(src.vt.screens.active.cursor.y)) + 1; // cap keep so a content-full source still leaves the new pane a tag + // a few body rows (an Alt-n from a full shell was born 0 rows tall) const keep = std.math.clamp(cur, 1, @max(1, body -| (BOX_H + 3))); if (p.layoutFindTerm(src_id)) |f| for (0..p.col_n[f.col]) |k| { const pid = p.col_terms[f.col][k]; if (p.panes[pid]) |pp| if (pp != nw) { pp.vweight = @floatFromInt(@max(1, p.rects[pid].h)); }; }; src.vweight = @floatFromInt(BOX_H + keep); nw.vweight = @floatFromInt(@max(1, src_h -| (BOX_H + keep))); // a non-doc buffer is worth exactly its own text: a three-hit +Search // is four rows, not half the source. Nothing else can want the rows, // so they go straight back to the pane they were taken from. if (nw.file) |f| if (!output_pane.fileTraits(f.output).doc) { // trimmed: every row ends in a newline, and the empty line after // the last one is not a result const want: f32 = @floatFromInt(BOX_H + file_pane.lineCount(std.mem.trimEnd(u8, f.content, "\n"))); if (want < nw.vweight) { src.vweight += nw.vweight - want; nw.vweight = want; } }; } /// a shell nobody has touched: no typing, cursor on the first prompt line, /// no scrollback — a throwaway placeholder a file may replace fn isPristineTty(pane: *Pane) bool { if (!pane.isTerminal()) return false; if (pane.ovl != null) return false; if (pane.vt.screens.active.cursor.y != 0) return false; return pane.vt.screens.active.pages.scrollbar().total <= pane.rows; } /// 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_terms[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; if (!isPristineTty(tt)) return; p.computeGeom(); // a just-stacked doc has no rect yet; absorb snaps to rows p.absorbVWeight(tty_id); p.layoutRemove(tty_id); p.deinitPane(tt); p.panes[tty_id] = null; if (p.active == tty_id) p.active = keep_id; } /// a terminal already in `dir`, else a fresh shell there at the bottom of /// the rightmost column. Backs middle-click send from a file pane. Does NOT /// focus (execute keeps you where you were; look focuses). fn ttyForDir(p: *Pardes, dir: []const u8) ?usize { for (p.panes, 0..) |slot, i| if (slot) |tt| { if (!tt.isTerminal()) continue; if (std.mem.eql(u8, tt.cwdSlice(), dir)) 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 = p.splitParent(p.col_terms[rc][p.col_n[rc] - 1]); const f = p.layoutFindTerm(src).?; p.splitBelow(src, nt); p.layoutInsert(f.col, f.idx + 1, free); } else p.layoutInsert(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 pane = try p.newDocPane(id); pane.file = .{ .path = path, .content = content }; pane.cur_pinned = true; return pane; } /// TEST-ONLY, called by test/hxdiff.zig (the helix differential harness): /// swap the tty_only boot pane for a file pane holding `content` verbatim /// — file_pane.open minus the disk read (cases carry their buffer inline). /// Unreachable from any shell; keep it dumb. pub fn hxOpenFileContent(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, "/hxcase.txt"); errdefer p.gpa.free(path); p.deinitPane(p.panes[0].?); p.panes[0] = null; const pane = try p.newDocPane(0); pane.file = .{ .path = path, .content = copy }; pane.cur_pinned = true; p.active = 0; p.sync(); return pane; } fn openImageView(p: *Pardes, id: usize, path: []const u8) !*Pane { const path_copy = try p.gpa.dupe(u8, path); errdefer p.gpa.free(path_copy); const pane = try p.newDocPane(id); // Native pixels are the preference, not a capability observed at open // time. drawImage dynamically falls back while a shell has no native // image support; this matters because kitty detection finishes after // argv images and restored panes have already been constructed. pane.image = .{ .path = path_copy }; return pane; } fn openPdfView(p: *Pardes, id: usize, path: []const u8, page_one_based: usize) !*Pane { if (comptime !pdf_enabled) return error.PdfDisabled; var document = try pdf_impl.Document.open(path); errdefer document.deinit(); const path_copy = try p.gpa.dupe(u8, path); errdefer p.gpa.free(path_copy); const pane = try p.newDocPane(id); const page = if (page_one_based > 0) @min(page_one_based - 1, document.pages - 1) else 0; pane.pdf = .{ .path = path_copy, .document = document, .page = page, .page_count = document.pages, }; pane.cur_pinned = true; return pane; } /// Mark same-page pixels stale while retaining the one visible RGBA and /// its dimensions. drawPdf swaps in a replacement only after MuPDF and the /// allocation both succeed, so a failed highlight rerender never blanks a /// page which was already visible. fn invalidatePdfRaster(p: *Pardes, pv: *PdfView) void { if (comptime !pdf_enabled) return; _ = p; pv.render_tried = false; } fn dropPdfRaster(p: *Pardes, pv: *PdfView) void { if (comptime !pdf_enabled) return; if (pv.rgba.len > 0) p.gpa.free(pv.rgba); pv.rgba = &.{}; pv.render_tried = false; pv.render_request_valid = false; } fn dropPdfSearchResults(p: *Pardes, pv: *PdfView) void { if (comptime !pdf_enabled) return; if (pv.search_results) |*results| results.deinit(p.gpa); pv.search_results = null; } fn dropPdfSelection(p: *Pardes, pv: *PdfView) void { if (comptime !pdf_enabled) return; if (pv.selection) |*selection| selection.deinit(p.gpa); pv.selection = null; if (pv.selection_text.len > 0) p.gpa.free(pv.selection_text); pv.selection_text = &.{}; pv.selection_anchor = null; pv.selection_head = null; } fn clearPdfDrag(pv: *PdfView) void { if (comptime !pdf_enabled) return; pv.drag_anchor = null; pv.drag_head = null; } fn clearPdfSelection(p: *Pardes, pane: *Pane) void { if (comptime !pdf_enabled) return; const pv = &(pane.pdf orelse return); const changed = pv.selection != null or pv.selection_text.len > 0; p.dropPdfSelection(pv); if (changed) p.invalidatePdfRaster(pv); } /// Drop everything derived from one PDF page. The document and full search /// query are semantic document state and survive; page search geometry, /// selection, pixels, projected text and pans do not. fn invalidatePdfPage(p: *Pardes, pv: *PdfView) void { if (comptime !pdf_enabled) return; p.dropPdfRaster(pv); p.dropPdfSearchResults(pv); p.dropPdfSelection(pv); clearPdfDrag(pv); if (pv.text.len > 0) p.gpa.free(pv.text); pv.iw = 0; pv.ih = 0; pv.text = &.{}; pv.text_tried = false; pv.text_scroll = 0; pv.pan_x = 0; pv.pan_y = 0; pv.highlights_dirty = pv.search_query.len > 0; pv.search_reveal_pending = pv.search_query.len > 0; pv.search_hit = 0; } /// Replace the full PDF query and discard only query-derived page state. /// Resolution is lazy in drawPdf; fit and pan remain placement-only state. fn setPdfSearchQuery(p: *Pardes, pv: *PdfView, query: []const u8) void { if (comptime !pdf_enabled) return; if (std.mem.eql(u8, pv.search_query, query)) return; const owned = p.gpa.dupe(u8, query) catch return; if (pv.search_query.len > 0) p.gpa.free(pv.search_query); pv.search_query = owned; pv.search_hit = 0; p.dropPdfSearchResults(pv); pv.highlights_dirty = query.len > 0; pv.search_reveal_pending = query.len > 0; p.invalidatePdfRaster(pv); } fn resolvePdfSearch(p: *Pardes, pv: *PdfView) void { if (comptime !pdf_enabled) return; if (!pv.highlights_dirty) return; pv.highlights_dirty = false; p.dropPdfSearchResults(pv); if (pv.search_query.len == 0) return; const results = pv.document.search(p.gpa, pv.page, pv.search_query) catch return; pv.search_hit = if (results.hit_count == 0) 0 else @min(pv.search_hit, results.hit_count - 1); pv.search_results = results; } fn normalizedPdfPixel(value: f32, dimension: usize) u32 { const scaled = std.math.clamp(value, 0, 1) * @as(f32, @floatFromInt(dimension)); return @intCast(@min(dimension - 1, @as(usize, @intFromFloat(scaled)))); } fn rearmPdfRevealForViewport(p: *Pardes, pane: *Pane, pv: *PdfView) void { if (comptime !pdf_enabled) return; if (pv.search_query.len == 0) return; const viewport = p.pdfViewport(pane) orelse return; if (!pv.reveal_viewport_valid or pv.reveal_viewport_w != viewport.pixel_w or pv.reveal_viewport_h != viewport.pixel_h or pv.reveal_fit != pv.fit) pv.search_reveal_pending = true; } fn revealPdfSearch(p: *Pardes, pane: *Pane, pv: *PdfView) void { if (comptime !pdf_enabled) return; if (!pv.search_reveal_pending) return; const viewport = p.pdfViewport(pane) orelse return; pv.reveal_viewport_w = viewport.pixel_w; pv.reveal_viewport_h = viewport.pixel_h; pv.reveal_fit = pv.fit; pv.reveal_viewport_valid = true; const results = pv.search_results orelse { pv.search_reveal_pending = false; return; }; if (results.hit_count == 0 or results.quads.len == 0) { pv.search_reveal_pending = false; return; } pv.search_hit = @min(pv.search_hit, results.hit_count - 1); const q = for (results.quads) |item| { if (item.hit == pv.search_hit) break item.quad; } else { pv.search_reveal_pending = false; return; }; const geometry = p.pdfGeometry(pane) orelse return; pv.search_reveal_pending = false; const center_x = (q.ul.x + q.ur.x + q.ll.x + q.lr.x) / 4; const center_y = (q.ul.y + q.ur.y + q.ll.y + q.lr.y) / 4; const full: u32 = @intCast(switch (pv.fit) { .width => pv.ih, .height => pv.iw, }); const at = switch (pv.fit) { .width => normalizedPdfPixel(center_y, pv.ih), .height => normalizedPdfPixel(center_x, pv.iw), }; const crop_at = switch (pv.fit) { .width => geometry.src.y, .height => geometry.src.x, }; const crop = switch (pv.fit) { .width => geometry.src.h, .height => geometry.src.w, }; if (at >= crop_at and at < crop_at + crop) return; const overflow = full -| crop; if (overflow == 0) return; const wanted = @min(overflow, at -| crop / 2); const pan: u16 = @intCast( (@as(u64, wanted) * std.math.maxInt(u16) + overflow / 2) / overflow, ); switch (pv.fit) { .width => pv.pan_y = pan, .height => pv.pan_x = pan, } } /// Change the visible PDF page and invalidate only page-derived caches. /// The document/context survives, so navigation never reparses the file. fn setPdfPage(p: *Pardes, pane: *Pane, page: usize) void { if (comptime !pdf_enabled) return; const pv = &(pane.pdf orelse return); const next = @min(page, pv.page_count -| 1); if (next == pv.page) return; p.invalidatePdfPage(pv); pv.page = next; pane.cur_row = 0; pane.cur_col = 0; pane.vsel.active = false; pane.msel.active = false; pane.nsel = 0; } /// Builtin entry point. Public only because builtins.zig is the command /// registry; the PdfView representation remains private to this module. pub fn togglePdfFit(p: *Pardes, pane: *Pane) void { if (comptime !pdf_enabled) return; _ = p; const pv = &(pane.pdf orelse return); pv.fit = if (pv.fit == .width) .height else .width; pv.pan_x = 0; pv.pan_y = 0; pv.search_reveal_pending = pv.search_query.len > 0; } fn stepPdfPage(p: *Pardes, pane: *Pane, delta: i64) void { if (comptime !pdf_enabled) return; const pv = pane.pdf orelse return; const current: i64 = @intCast(pv.page); const last: i64 = @intCast(pv.page_count -| 1); p.setPdfPage(pane, @intCast(std.math.clamp(current + delta, 0, last))); } const PdfViewport = struct { pixel_w: u32, pixel_h: u32, }; const PdfPanAxis = enum { horizontal, vertical }; const PdfPanResult = enum { moved, edge, unavailable }; /// The image shells receive a cell rectangle while NativeGeometry works /// in pixels. Keep this conversion in core state so navigation and drawing /// agree even when the font metrics change at runtime. fn pdfViewport(p: *const Pardes, pane: *const Pane) ?PdfViewport { const r = for (p.panes, 0..) |slot, i| { if (slot == pane) break p.rects[i]; } else return null; const cols = r.w -| config.GUTTER; const rows = r.h -| BOX_H; if (cols == 0 or rows == 0) return null; return .{ .pixel_w = @as(u32, cols) * @as(u32, p.cell_pixels.w), .pixel_h = @as(u32, rows) * @as(u32, p.cell_pixels.h), }; } fn pdfRenderRequest(p: *const Pardes, pane: *const Pane) ?pdf_impl.RenderRequest { if (comptime !pdf_enabled) return null; const viewport = p.pdfViewport(pane) orelse return null; return .{ .dpi = pdf_raster_policy.dpi, .minimum_width = if (pdf_raster_policy.match_viewport) viewport.pixel_w else 0, .minimum_height = if (pdf_raster_policy.match_viewport) viewport.pixel_h else 0, .max_dimension = pdf_raster_policy.max_dimension, }; } fn pdfGeometry(p: *const Pardes, pane: *const Pane) ?image.NativeGeometry { if (comptime !pdf_enabled) return null; const pv = pane.pdf orelse return null; const viewport = p.pdfViewport(pane) orelse return null; return image.nativeGeometry( pv.iw, pv.ih, viewport.pixel_w, viewport.pixel_h, switch (pv.fit) { .width => .width, .height => .height, }, pv.pan_x, pv.pan_y, ); } fn pdfNativeReady(p: *const Pardes, pane: *const Pane) bool { return p.native_images and p.pdfGeometry(pane) != null; } /// Convert a grid mouse position to normalized page space through the /// exact crop/destination transform used by both native backends. Presses /// in a letterbox return null; a live drag clamps to the page edge so a /// sweep can finish naturally outside the image. fn pdfPointAt( p: *const Pardes, pane: *const Pane, mcol: u16, mrow: u16, clamp_to_page: bool, ) ?PdfPoint { if (comptime !pdf_enabled) return null; const pv = pane.pdf orelse return null; const geometry = p.pdfGeometry(pane) orelse return null; const r = for (p.panes, 0..) |slot, i| { if (slot == pane) break p.rects[i]; } else return null; const body_x = @as(i64, r.x + config.GUTTER); const body_y = @as(i64, r.y + BOX_H); var px = (@as(i64, mcol) - body_x) * p.cell_pixels.w + p.cell_pixels.w / 2; var py = (@as(i64, mrow) - body_y) * p.cell_pixels.h + p.cell_pixels.h / 2; if (clamp_to_page) { px = std.math.clamp( px, @as(i64, geometry.dst.x), @as(i64, geometry.dst.x + geometry.dst.w - 1), ); py = std.math.clamp( py, @as(i64, geometry.dst.y), @as(i64, geometry.dst.y + geometry.dst.h - 1), ); } else if (px < 0 or py < 0 or px > std.math.maxInt(u32) or py > std.math.maxInt(u32)) return null; const source = geometry.sourcePoint(@intCast(px), @intCast(py)) orelse return null; const fw: f32 = @floatFromInt(pv.iw); const fh: f32 = @floatFromInt(pv.ih); return .{ .x = (@as(f32, @floatFromInt(source.x)) + 0.5) / fw, .y = (@as(f32, @floatFromInt(source.y)) + 0.5) / fh, }; } const PdfSelectionUpdate = enum { failed, unchanged, changed }; /// Transactionally replace the word-snapped selection and its owned text. /// Drags defer the expensive baked-highlight raster until release; /// snap-word execute/look presses may still request an immediate update. fn setPdfSelection( p: *Pardes, pane: *Pane, start: PdfPoint, end: PdfPoint, invalidate_raster: bool, ) PdfSelectionUpdate { if (comptime !pdf_enabled) return .failed; const pv = &(pane.pdf orelse return .failed); if (pv.selection != null and pv.selection_anchor != null and pv.selection_head != null and pv.selection_anchor.?.x == start.x and pv.selection_anchor.?.y == start.y and pv.selection_head.?.x == end.x and pv.selection_head.?.y == end.y) return .unchanged; var selection = pv.document.select(p.gpa, pv.page, start, end) catch return .failed; const text = pv.document.copySelection( p.gpa, pv.page, selection.start, selection.end, ) catch { selection.deinit(p.gpa); return .failed; }; p.dropPdfSelection(pv); pv.selection = selection; pv.selection_text = text; pv.selection_anchor = start; pv.selection_head = end; if (invalidate_raster) p.invalidatePdfRaster(pv); return .changed; } fn beginPdfDrag( p: *Pardes, pane: *Pane, mcol: u16, mrow: u16, snap_word: bool, ) bool { if (comptime !pdf_enabled) return false; const pv = &(pane.pdf orelse return false); clearPdfDrag(pv); const point = p.pdfPointAt(pane, mcol, mrow, false) orelse { return false; }; pv.drag_anchor = point; pv.drag_head = point; if (!snap_word) return true; if (pv.selection) |selection| if (selection.contains(point)) { return true; }; if (p.setPdfSelection(pane, point, point, true) == .failed) { // The old selection is intentionally transactional and survives, // but this outside click must not execute that stale text. clearPdfDrag(pv); return false; } return true; } fn beginPdfSelection(p: *Pardes, pane: *Pane, mcol: u16, mrow: u16) bool { return p.beginPdfDrag(pane, mcol, mrow, true); } fn updatePdfSelection( p: *Pardes, pane: *Pane, mcol: u16, mrow: u16, invalidate_raster: bool, ) bool { if (comptime !pdf_enabled) return false; const pv = &(pane.pdf orelse return false); const anchor = pv.drag_anchor orelse return false; const point = p.pdfPointAt(pane, mcol, mrow, true) orelse return false; if (pv.drag_head) |head| if (head.x == point.x and head.y == point.y) return false; const result = p.setPdfSelection(pane, anchor, point, invalidate_raster); if (result != .failed) { pv.drag_head = point; } return result == .changed; } fn pdfHighlights(p: *Pardes, pv: *PdfView) ![]const PdfHighlight { if (comptime !pdf_enabled) return &.{}; var highlights: std.ArrayList(PdfHighlight) = .empty; const arena = p.scratch.allocator(); if (pv.search_results) |results| { try highlights.ensureUnusedCapacity(arena, results.quads.len); for (results.quads) |item| highlights.appendAssumeCapacity(pdf_impl.Highlight.init( item.quad, .{ 0xff, 0xd5, 0x4f, 0x70 }, .search, )); } if (pv.selection) |selection| { try highlights.ensureUnusedCapacity(arena, selection.quads.len); for (selection.quads) |quad| highlights.appendAssumeCapacity(pdf_impl.Highlight.init( quad, .{ 0x55, 0xa7, 0xff, 0x78 }, .selection, )); } return highlights.items; } /// Move one normalized pan axis by a distance measured in DISPLAY pixels. /// NativeGeometry supplies both the displayed and cropped extents, so this /// remains exact for non-square cells and rounded page scaling. fn panPdfPixels( p: *Pardes, pane: *Pane, axis: PdfPanAxis, direction: i8, display_pixels: u32, ) PdfPanResult { if (comptime !pdf_enabled) return .unavailable; const geometry = p.pdfGeometry(pane) orelse return .unavailable; const pv = &(pane.pdf orelse return .unavailable); const source_full: u32 = @intCast(switch (axis) { .horizontal => pv.iw, .vertical => pv.ih, }); const crop = switch (axis) { .horizontal => geometry.src.w, .vertical => geometry.src.h, }; const source_at = switch (axis) { .horizontal => geometry.src.x, .vertical => geometry.src.y, }; const displayed = @max(@as(u32, 1), switch (axis) { .horizontal => geometry.dst.w, .vertical => geometry.dst.h, }); const overflow = source_full -| crop; if (overflow == 0 or (direction < 0 and source_at == 0) or (direction > 0 and source_at >= overflow)) return .edge; const source_step = @max( @as(u64, 1), (@as(u64, display_pixels) * @as(u64, crop) + displayed - 1) / displayed, ); const normalized_step: u32 = @intCast(@min( @as(u64, std.math.maxInt(u16)), @max( @as(u64, 1), (source_step * std.math.maxInt(u16) + overflow - 1) / overflow, ), )); const position = switch (axis) { .horizontal => &pv.pan_x, .vertical => &pv.pan_y, }; if (direction > 0) { position.* = @intCast(@min( @as(u32, std.math.maxInt(u16)), @as(u32, position.*) + normalized_step, )); } else { position.* -|= @intCast(normalized_step); } return .moved; } fn scaledPdfStep(base: u32, count: u32) u32 { return @intCast(@min( @as(u64, std.math.maxInt(u32)), @as(u64, base) * @max(@as(u64, 1), count), )); } /// Fit-width reads down a tall raster, then crosses pages at its extrema. /// Going backward deliberately enters the previous page at the bottom. fn panPdfWidth(p: *Pardes, pane: *Pane, direction: i8, pixels: u32) void { switch (p.panPdfPixels(pane, .vertical, direction, pixels)) { .moved => {}, // Before the first native frame (or after a render failure), do // not swallow navigation: page-wise is the useful fallback. .unavailable => p.stepPdfPage(pane, direction), .edge => { const pv = pane.pdf orelse return; if (direction > 0) { if (pv.page + 1 >= pv.page_count) return; p.setPdfPage(pane, pv.page + 1); } else { if (pv.page == 0) return; p.setPdfPage(pane, pv.page - 1); pane.pdf.?.pan_y = std.math.maxInt(u16); } }, } } fn pdfVerticalWheel(p: *Pardes, pane: *Pane, direction: i8) void { if (comptime !pdf_enabled) return; const pv = pane.pdf orelse return; if (!p.native_images or pv.fit == .height) { p.stepPdfPage(pane, direction); return; } const rows: u32 = @intCast(@max(1, config.wheel_rows)); p.panPdfWidth(pane, direction, scaledPdfStep(p.cell_pixels.h, rows)); } fn pdfHorizontalWheel(p: *Pardes, pane: *Pane, direction: i8) void { if (comptime !pdf_enabled) return; const pv = pane.pdf orelse return; if (!p.native_images or pv.fit != .height) return; const cols: u32 = @intCast(@max(1, config.wheel_cols)); _ = p.panPdfPixels(pane, .horizontal, direction, scaledPdfStep(p.cell_pixels.w, cols)); } /// In fit-height, vertical motions retain the old page-at-a-time model. /// In fit-width they pan by a row, half viewport or full viewport before /// crossing pages. Search, tags and global chords run before this hook. fn pdfNavigationKey(p: *Pardes, pane: *Pane, key: Key) bool { if (comptime !pdf_enabled) return false; if (pane.pdf == null or pane.pending != 0) return false; const row_down = hit(key, config.move_down); const row_up = hit(key, config.move_up); const half_down = hit(key, config.half_page_down); const half_up = hit(key, config.half_page_up); const page_down = hit(key, config.page_down); const page_up = hit(key, config.page_up); const down = row_down or half_down or page_down; const up = row_up or half_up or page_up; if (!down and !up) return false; const count = @max(1, pane.count); pane.count = 0; const direction: i8 = if (down) 1 else -1; if (!p.native_images or pane.pdf.?.fit == .height) { const pages: i64 = @intCast(count); p.stepPdfPage(pane, if (down) pages else -pages); return true; } const viewport = p.pdfViewport(pane); const base = if (row_down or row_up) @as(u32, p.cell_pixels.h) else if (half_down or half_up) if (viewport) |v| @max(@as(u32, 1), v.pixel_h / 2) else @as(u32, p.cell_pixels.h) else if (viewport) |v| v.pixel_h else @as(u32, p.cell_pixels.h); p.panPdfWidth(pane, direction, scaledPdfStep(base, count)); return true; } /// stack a fresh doc pane at the top of the LEFT column (acme convention: /// files left, terminals right), halving ONLY the old top pane's slot so /// the rest of the column keeps its sizes; then evict a leftover pristine /// shell. The placement of last resort — a first doc normally takes a /// column of its own (placeDoc), and this only runs when the column bar /// is full. fn stackDocLeft(p: *Pardes, free: usize, nt: *Pane) void { const lc = 0; if (p.col_n[lc] > 0) if (p.panes[p.col_terms[lc][0]]) |top| { p.snapColWeights(lc); const h = p.rects[p.col_terms[lc][0]].h; nt.vweight = @floatFromInt(@max(1, h / 2)); top.vweight = @floatFromInt(@max(1, h -| h / 2)); }; p.layoutInsert(lc, 0, free); p.active = free; p.evictLonePristineTty(lc, free); } // ---- the ONE dispatcher: look (right/Enter) and execute (middle/Tab) ---- /// Focus pane `id` and, for a nonzero 1-based `at.line`, put its modal /// cursor there (`at.col` likewise, 0 = line start): files recenter the /// view on it, terminals ride their scrollback to it. Both look targets /// that name a live pane land here — a path a pane already holds, and /// `@pN:LINE:COL`. A RANGED spot selects (selectSpan below). pub fn focusPaneLine(p: *Pardes, id: usize, at: look.Spot) void { if (id >= MAX_PANES) return; const pane = p.panes[id] orelse return; p.active = id; if (hasPdf(pane)) { if (comptime pdf_enabled) { if (at.line > 0) p.setPdfPage(pane, at.line - 1); const pv = &pane.pdf.?; // PDF search result rows use Spot.col as a one-based MuPDF // logical hit id, not a text column. Rearming reveal moves // only fit/pan; the page raster already contains every hit. if (at.col > 0 and pv.search_query.len > 0) { pv.search_hit = at.col - 1; pv.search_reveal_pending = true; } } return; } if (at.line == 0) return; if (pane.file) |*f| { if (at.line > file_pane.nlines(p.gpa, f)) return; const next = (at.line - 1) -| pane.rows / 2; // center, clamp at top if (next != f.scroll) { f.scroll = next; f.syntax_dirty = true; } } // land the modal cursor on the target line (and keep // ensureCursorVisible agreeing with the recenter — a stale cursor // would yank the view right back) 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(); } /// Select the span a RANGED look word names (config.range_sep): the two /// ends are block-cursor CELLS, so this is the same cellRange/setPaneRange /// pair every motion writes back through — the cursor lands on the span's /// last cell with the anchor on its first, which is where helix leaves you /// after a search too. /// /// EXPLICIT, so the acme chords act on it like a v/x selection: the whole /// point of `n` selecting a hit is being able to chord the match straight /// into the next command. /// /// Everything clamps, because a range is a claim about a file that may /// have changed underneath it: hxOff pins a row past the end to the last /// line and a column past the end to that line's terminator, so a stale /// row selects what is still there instead of crashing or highlighting /// garbage. A whole-lines range (no end column) runs to the terminator by /// asking for a column no line can have, which is helix's own `x`. fn selectSpan(p: *Pardes, pane: *Pane, at: look.Spot) void { const pl = p.paneCursorLines(pane) catch return; const text = p.flatSurface(pane, pl) 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 = cellRange(text, @intCast(at.line -| 1), acol, @intCast(at.end_line -| 1), ecol); setPaneRange(pane, pl, text, r, true); } /// focus the pane already loaded on `path` (exact match), if any: file /// panes recenter on a :NN line like the look dedup always has, image /// panes just focus. Returns false when no pane holds that path. fn focusPaneByPath(p: *Pardes, path: []const u8, at: look.Spot) bool { for (p.panes, 0..) |slot, i| { const tt = slot orelse continue; if (tt.image) |iv| if (std.mem.eql(u8, iv.path, path)) { p.active = i; return true; }; if (comptime pdf_enabled) if (tt.pdf) |pv| if (std.mem.eql(u8, pv.path, path)) { p.focusPaneLine(i, at); return true; }; const ff = if (tt.file) |*f| f else continue; if (!std.mem.eql(u8, ff.path, path)) continue; p.focusPaneLine(i, at); return true; } return false; } /// LOOK — the Look builtin's body, and so what a right click, an Enter and /// the word `Look` all end at. Resolve `txt` against the panes' directories /// and open (or focus) whatever it names; a word that names nothing is a /// search of the pane it came from, which is acme's button-3. pub fn lookAt(p: *Pardes, id: usize, txt: []const u8) void { const pane = p.panes[id] orelse return; const trimmed = std.mem.trim(u8, txt, " \t\r\n"); // `` @`ls -la` `` names a COMMAND, not a path: run it, and land in the // pane that answers — looking at a thing means being SHOWN it, and a // command's output is what there is to be shown. Looking at a // DIRECTORY has always been exactly this (below: focus a shell there // and make it `ls`); this is that rule spelled generally. if (config.commandWord(trimmed)) |cmd| { if (p.execute(id, cmd)) |dst| p.active = dst; return; } var realbuf: [4096]u8 = undefined; // an already-loaded pane wins BEFORE any filesystem resolve: the // web build has no fs (a look would otherwise be inert even for // panes sitting in the session), and native gets the same dedup // it always did, just without touching disk. Pane paths are // canonical (realpath'd or dump-given), so match the word as-is // here and joined onto each directory below. const pl = look.parsePathLine(trimmed); if (pl.path.len > 0 and p.focusPaneByPath(pl.path, pl.at)) return; // The word is resolved against the pane DIRECTORIES in access // order: the pane the click came from FIRST — its answer is the // one taken, so nothing that resolves today moves — then every // other live pane, most recently focused first (the jump stack runs // least-recent -> active, so it is that array backwards; the // clicked pane is not always `active`, a right click does not // focus). Only when ALL of them fail does the word fall through to // the search below: a name you can read in one window is openable // from any of them. Each attempt is a realpath + an open and // shells share cwds constantly, so a seen-list holds every // directory to one try; an absolute word — and `@pN`, which reads // no directory at all — answers the same everywhere and stops // after the first pass. 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.focusPaneByPath(j, pl.at)) return; } found = look.resolve(txt, dir, &realbuf); if (found != .none or pl.path.len == 0 or pl.path[0] == '/') break; } switch (found) { // acme button-3: a word that names no file/dir is a search of // the pane it was clicked in — exactly what `/` runs, and then a // step onto a hit, so a click GOES somewhere and clicking again // goes to the next one. Paths (src/a/b.rs:100) still resolve above // and open; only the non-file case falls through here. A shell // searches its scrollback like anything else, EXCEPT in tty // mode, where the click belongs to the program on the other // end; an image pane has no text to search either way. .none => { const bmode = if (pane.tag_edit) pane.tag_mode else pane.mode; if (pane.image != null or bmode == .tty) return; // ...and then STEP it, which is the other half of button-3: a // click does not merely LIST the hits, it goes to one — the // one AFTER the word clicked, since runSearch armed the walk // where the click put the cursor. Asking again is free: the // same pattern refills its own list rather than opening a // second, so clicking a word repeatedly walks its hits. p.runSearch(id, trimmed, .text, .cursor); const at = pane.search_row; _ = p.searchStep(id, 1); // past the last hit, back to the first: acme's search is a // RING, and a step that could not move left the row where it // was (searchStep clamps rather than wrapping, because n/N are // also how `]d`/`[d` walk to the end of a list and stop). if (at != null and pane.search_row == at) { pane.search_row = null; _ = p.searchStep(id, 1); } }, // `@p7:10:5`: pane 7, line 10, column 5 — how a search result // points at a terminal or an output buffer, neither of which // has a path. .pane => |t| p.focusPaneLine(t.id, t.at), .url => |u| if (u.len <= 256) p.emit(.{ .open_link = .from(u) }), .dir => |dir| { // focus an existing terminal on this dir, else fork one below for (p.panes, 0..) |slot, i| { if (slot) |tt| if (tt.isTerminal() and std.mem.eql(u8, tt.cwdSlice(), dir)) { p.active = i; p.emitWrite(i, "ls\r"); return; }; } const free = p.freeSlot() orelse return; const nt = p.newShell(free, dir) catch return; nt.greet = true; const src = p.splitParent(id); const f = p.layoutFindTerm(src).?; p.layoutInsert(f.col, f.idx + 1, free); p.splitBelow(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; const nt = p.openPdfView(free, target.path, target.at.line) catch return; 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; const nt = file_pane.open(p, free, target.path, target.at.line) catch return; if (target.at.col > 0) nt.cur_col = @intCast(target.at.col - 1); p.placeDoc(id, free, nt); // center the target line: the pane's real body height only // exists after placement, so lay out now and pull the // scroll up by half a body (line 0 opens stay at the top) p.computeGeom(); nt.file.?.scroll -|= @max(1, p.rects[free].h -| BOX_H) / 2; // ...and only THEN select a range, restoring that scroll: // setPaneRange keeps its cursor visible, and a pane this fresh // has no true geometry yet for it to judge against (pane.rows // is only refreshed in sync), so the centering just computed is // the answer and ensureCursorVisible's is not. 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; const nt = p.openImageView(free, target.path) catch return; p.placeDoc(id, free, nt); }, } } /// how deep `execute` may re-enter itself. Nothing can reach this today: /// every door back in strips at least one word (`Exec X` -> `X`) or one /// pair of delimiters (`` @`X` `` -> `X`), so the command line strictly /// shrinks and a cycle cannot close — executing the bare word `Exec` runs /// out of argument immediately. The counter is here so that a syntax added /// later which does NOT shrink (an alias, a macro) stops instead of hanging /// the editor, and the ceiling is small because a human nesting eight deep /// has made a different mistake. const max_exec_depth = 8; /// EXECUTE — the Exec builtin's body, and so what a middle click, a Tab /// and the word `Exec` all end at. A builtin's NAME runs the builtin; /// anything else is a command line typed at a shell. Returns the pane it /// was typed into, which is what Look focuses and an execute deliberately /// does not. 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.executeBuiltinLine(id, cmd)) return null; if (p.exec_depth >= max_exec_depth) return null; p.exec_depth += 1; defer p.exec_depth -= 1; // Anything not in the builtin vocabulary is a command line typed at // a shell. Startup config calls executeBuiltinLine directly and never // reaches this fallback. // A terminal runs in itself. Every document kind — real/output file, // image, or PDF — runs in a terminal for its directory (an existing // one when possible, otherwise a freshly forked shell). In particular, // never emit a PTY write addressed to an image's no-PTY pane slot. const dst = (if (pane.isTerminal()) id else p.ttyForDir(paneDir(pane))) orelse return null; term_pane.padOutputBelowEdits(p, 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; } /// Parse and dispatch exactly one builtin command. This is the canonical /// builtin path used both by ordinary Exec and by startup configuration; /// unlike execute(), it deliberately has no external-shell fallback. /// False means blank, malformed, unknown, or recursion-limited. 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; // The builtins that take an ARGUMENT match their name with a TAIL: // `Restore `, `Find `, `Grep `, `Rename `, // `WsSymbols `, `Theme `, `Font ` (gui only), and the // two verbs themselves — `Look `, `Exec `, which is what // makes `` @`Look .` `` nest (the tail goes straight back through // here). Every other name must match WHOLE, so `Kill foo` is a shell // command and not Kill. 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 switch (head) { .Restore, .Find, .Grep, .Rename, .WsSymbols, .Theme, .Look, .Exec => head, // Font is a BRANCH and not another name in that prong for the // reason output_pane.traits has the same shape: it is not in // the enum at all off the gui, and a comptime-false `if` is // the one form that is not analysed there. else => { if (platform == .gui) if (head == .Font) break :blk head; break :blk 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); } /// Run a builtin on pane `id`. `txt` is the executed text (Restore reads /// its path back out of it) and `arg` the builtin's ARGUMENT — the tail /// after the name, which is why Grep and Find run straight away when there /// is one instead of asking, and which for Look and Exec is the whole /// operand. A gesture that points at a word (a click, an Enter) passes it /// as `arg` with no `txt`: it named no builtin, config.look_cmd did. The /// leader passes "" and null: a key path names a builtin, never an /// argument. The topbar builtins are global; the pane-scoped ones (Save, /// Del, Delcol, the image toggles, and the window group, which moves focus /// relative to `id`) act on `id`. The null-pane check is the one guard /// every builtin used to share, so it stays here rather than in each. 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 }; inline for (comptime builtins.all(), 0..) |B, i| if (@intFromEnum(b) == i) return B.run(c); } /// place a fresh doc pane. An OUTPUT buffer (+Search/+Help) is NOT a /// document: it is the result list belonging to the pane that asked for it, /// so it never claims a column and is never anyone else's split parent — it /// lands right below `from_id`, be that a shell, a file or another list, /// and `from_id` alone pays the rows (several lists just stack there). /// A real doc joins the docs: any doc already open is the split parent (the /// source if it IS one, else the one most recently worked in) and the /// newcomer lands right below it, so docs share a column. The FIRST doc of /// the session instead gets a column of its own on the left (acme: files /// left, shells right) — nothing is displaced, the other columns just /// narrow. A full column bar, or a result list whose source died, stacks /// into the leftmost. pub fn placeDoc(p: *Pardes, from_id: usize, free: usize, nt: *Pane) void { const doc = if (nt.file) |f| output_pane.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| output_pane.fileTraits(f.output).doc else src.image != null or hasPdf(src)) { src_id = from_id; }; // Opened from somewhere that is NOT a doc (a shell, a results list): // the file joins the column it was last being READ in, which is the // newest doc on the jump stack. The stack already IS that record — it // is where the keyboard has been — so nothing new is remembered here; // asking it is the whole change. What this replaces was the first doc // in slot order, i.e. pane-id ALLOCATION order, so which column your // file landed in depended on how the session had handed out ids rather // than on where you were working. // // The answer wanted is the COLUMN, and it stays right once the pane // itself is closed: trackJump compacts dead entries out, so the walk // falls through to the next doc remembered in that same column. The // serial test is the one trackJump uses — slots are reused, so an entry // whose pane has been replaced names a pane that is gone, not the // newcomer sitting in its slot (`free`, this very pane, among them). 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| output_pane.fileTraits(f.output).doc else pp.image != null or hasPdf(pp)) { src_id = j.pane; break; } } } // A doc no jump remembers is still a doc: a restored session focuses // one pane, not each, and the stack is finite. The rule that a second // doc never claims a second column outranks knowing where you were, so // the old slot-order scan stays as the fallback. if (doc and src_id == null) for (p.panes, 0..) |sl, i| { if (sl) |pp| if (i != free and (if (pp.file) |f| output_pane.fileTraits(f.output).doc else pp.image != null or hasPdf(pp))) { src_id = i; break; }; }; // A result list belongs to its spawner: it lands directly BELOW it — // on top of the lists already there — and its rows come out of the // SPAWNER, never a bystander, so opening another list (or deleting // one, see absorbVWeight) leaves every other pane's height untouched. if (!doc) if (p.layoutFindTerm(from_id)) |sf| { p.layoutInsert(sf.col, sf.idx + 1, free); p.splitBelow(from_id, nt); // NOT splitParent: no bystander pays p.active = free; return; }; if (src_id) |sid| { const src = p.splitParent(sid); const sf = p.layoutFindTerm(src).?; p.layoutInsert(sf.col, sf.idx + 1, free); p.splitBelow(src, nt); p.active = free; return; } if (doc and p.ncol < MAX_COLS) { p.layoutInsertColumn(0, free); p.active = free; return; } p.stackDocLeft(free, nt); } // ---- 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 panes: std.ArrayList(dump.Pane) = .empty; for (p.panes, 0..) |slot, id| { const pane = slot orelse continue; slot_to_pane[id] = panes.items.len; const tag = try p.tagText(arena, pane); const body = try p.bodyText(arena, pane); const scroll: usize = @intCast(@max(0, pane.scroll())); // BY POINTER: `origin_arg` is a slice into the File's own inline // buffer, and a by-value capture would leave it pointing at a // stack copy that dies before the ZON is written. const dp: dump.Pane = if (pane.file) |*f| .{ .kind = .file, .tag = tag, .body = body, .scroll = scroll, .cols = pane.cols, .rows = pane.rows, .vweight = pane.vweight, .file = .{ .path = f.path, .content = f.content, .content_b64 = try dump.encodeBytes(arena, f.content), .origin = if (f.output) |o| output_pane.word(o.from) else "", .origin_arg = if (f.output) |*o| o.arg() else "", }, } else if (hasPdf(pane)) blk: { if (comptime !pdf_enabled) unreachable; const pv = pane.pdf.?; const bytes = look.readFile(arena, pv.path) catch ""; // Keep the dump schema backwards-compatible: a raster-backed // document rides the existing binary image record, while its // `scroll` field is the zero-based PDF page. Restore inspects // the extension and reconstructs the semantic PDF pane. break :blk .{ .kind = .image, .tag = tag, .body = body, .scroll = pv.page, .cols = pane.cols, .rows = pane.rows, .vweight = pane.vweight, .image = .{ .path = pv.path, .bytes_b64 = if (bytes.len > 0) try dump.encodeBytes(arena, bytes) else "", }, }; } else if (pane.image) |iv| blk: { const bytes = look.readFile(arena, iv.path) catch ""; break :blk .{ .kind = .image, .tag = tag, .body = body, .scroll = scroll, .cols = pane.cols, .rows = pane.rows, .vweight = pane.vweight, .image = .{ .path = iv.path, .bytes_b64 = if (bytes.len > 0) try dump.encodeBytes(arena, bytes) else "", }, }; } else blk: { // the stream text: whole history, prompts hidden outside tty // mode, the edit buffer standing in for the rows it covers const full = try pane.vt.screens.active.dumpStringAlloc(arena, .{ .screen = .{} }); var stream_text: std.ArrayList(u8) = .empty; var lines = std.mem.splitAny(u8, full, "\n"); var prompts = pane.vt.screens.active.pages.rowIterator(.right_down, .{ .screen = .{} }, null); var row: i32 = 0; var skip: i32 = 0; while (lines.next()) |raw| : (row += 1) { const is_prompt = if (pane.mode != .tty) if (prompts.next()) |pin| pin.rowAndCell().row.semantic_prompt != .none else false else false; if (skip > 0) { skip -= 1; continue; } if (row > 0) try stream_text.append(arena, '\n'); if (pane.mode != .tty) if (pane.ovl) |o| if (row == o.row) { try stream_text.appendSlice(arena, o.text); skip = o.rows - 1; continue; }; try stream_text.appendSlice(arena, if (is_prompt) "" else raw); } break :blk .{ .kind = .terminal, .tag = tag, .body = body, .scroll = scroll, .cols = pane.cols, .rows = pane.rows, .vweight = pane.vweight, .terminal = .{ .cwd = try arena.dupe(u8, pane.cwdSlice()), .stream = stream_text.items, .stream_b64 = try dump.encodeBytes(arena, pane.tty_stream.items), .cursor = .{ .col = pane.vt.screens.active.cursor.x, .row = pane.vt.screens.active.cursor.y, }, }, }; }; try panes.append(arena, dp); } var columns: std.ArrayList(dump.Column) = .empty; for (0..p.ncol) |c| { var ids: std.ArrayList(usize) = .empty; for (0..p.col_n[c]) |k| { if (slot_to_pane[p.col_terms[c][k]]) |compact| try ids.append(arena, compact); } if (ids.items.len > 0) try columns.append(arena, .{ .weight = p.col_weight[c], .panes = ids.items }); } 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, .theme = p.theme().name, .columns = columns.items, .panes = panes.items, }; 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); } /// Initialize from another instance's dump: panes reconstructed (terminals /// by replaying their raw VT streams into fresh emulators), no spawns — /// loaded terminals are dead replays, scrollable and selectable. pub fn initFromDump(gpa: std.mem.Allocator, opts: Options, zon_bytes: []const u8) !*Pardes { const p = try gpa.create(Pardes); p.* = .{ .gpa = gpa, .opts = opts, .screen_w = opts.cols, .screen_h = opts.rows, .scratch = .init(gpa) }; errdefer p.deinit(); const st = try dump.readZon(gpa, zon_bytes, "load"); defer dump.free(gpa, st); for (themes, 0..) |t, i| { if (std.mem.eql(u8, t.name, st.theme)) p.theme_idx = i; } for (st.panes, 0..) |src, i| { if (i >= MAX_PANES) break; switch (src.kind) { .terminal => { const t = src.terminal.?; const pane = try gpa.create(Pane); pane.* = .{ .vt = try ghostty_vt.Terminal.init(gpa, .{ .cols = @max(1, src.cols), .rows = @max(1, src.rows), .max_scrollback = 16 * 1024 * 1024, }), .stream = undefined, .gpa = gpa, .cols = @max(1, src.cols), .rows = @max(1, src.rows), }; pane.stream = pane.vt.vtStream(); pane.stream.handler.effects.write_pty = term_pane.ptyReport; pane.stream.handler.effects.device_attributes = term_pane.ptyDeviceAttrs; p.panes[i] = pane; if (t.stream_b64.len > 0) { const bytes = try dump.decodeBytes(p.scratch.allocator(), t.stream_b64); pane.tty_stream.appendSlice(gpa, bytes) catch {}; pane.stream.nextSlice(bytes); pane.vt.screens.active.scroll(.active); if (src.scroll > 0) pane.vt.screens.active.scroll(.{ .delta_row = -@as(isize, @intCast(src.scroll)) }); } p.setCwd(i, t.cwd); if (std.mem.startsWith(u8, src.tag, "TTY ")) pane.mode = .tty; p.restoreTail(pane, src.tag); }, .file => { const f = src.file.?; const content: []u8 = if (f.content_b64.len > 0) try dump.decodeBytes(gpa, f.content_b64) else try gpa.dupe(u8, f.content); errdefer gpa.free(content); const path = try gpa.dupe(u8, f.path); errdefer gpa.free(path); const pane = try p.newDocPane(i); // an unknown origin word is a dump from another build, not // a reason to refuse the load: it comes back as a file const out: ?output_pane.Output = if (output_pane.fromWord(f.origin)) |o| blk: { var ov: output_pane.Output = .{ .from = o }; output_pane.setArg(&ov, f.origin_arg); break :blk ov; } else null; pane.file = .{ .path = path, .content = content, .output = out, .scroll = src.scroll }; pane.cur_pinned = true; pane.cur_row = @intCast(src.scroll); pane.cols = @max(1, src.cols); pane.rows = @max(1, src.rows); // a restored file pane is watched like an opened one. Note // its content is the DUMP's, which may already differ from // disk; the shell stamps the watch from what it has, so the // first external write reconciles the pane to disk and the // dump's version lands one `u` away — which is the same // bargain as any other external update. if (out == null) p.emit(.{ .watch = .{ .pane = @intCast(i), .on = true } }); p.restoreTail(pane, src.tag); }, .image => restore_image: { const im = src.image.?; const raw: []u8 = if (im.bytes_b64.len > 0) dump.decodeBytes(gpa, im.bytes_b64) catch &.{} else &.{}; if (comptime pdf_enabled) if (look.isPdfPath(im.path)) { if (p.openPdfView(i, im.path, src.scroll + 1) catch null) |pane| { if (raw.len > 0) gpa.free(raw); pane.cols = @max(1, src.cols); pane.rows = @max(1, src.rows); break :restore_image; } // 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, ""); errdefer if (content.len > 0) gpa.free(content); const pane = try p.newDocPane(i); pane.file = .{ .path = path, .content = content }; pane.cur_pinned = true; pane.cols = @max(1, src.cols); pane.rows = @max(1, src.rows); p.emit(.{ .watch = .{ .pane = @intCast(i), .on = true } }); break :restore_image; }; const path = try gpa.dupe(u8, im.path); errdefer gpa.free(path); const pane = try p.newDocPane(i); pane.image = .{ .path = path, .raw = raw }; pane.cols = @max(1, src.cols); pane.rows = @max(1, src.rows); }, } p.panes[i].?.vweight = src.vweight; } p.ncol = @min(st.columns.len, MAX_COLS); for (st.columns[0..p.ncol], 0..) |col, c| { p.col_weight[c] = col.weight; p.col_n[c] = @min(col.panes.len, MAX_PANES); for (col.panes[0..p.col_n[c]], 0..) |pid, k| p.col_terms[c][k] = pid; } p.active = @min(st.active, MAX_PANES - 1); p.sync(); p.applyStartupConfig(); p.sync(); return p; } /// Restore a custom tag tail from a saved tag: whatever follows the live /// prefix, unless it is this build's default or either exact legacy /// default from before New appeared in every pane. Legacy defaults upgrade /// to the live default; genuinely edited tails remain byte-for-byte owned. fn restoreTail(p: *Pardes, pane: *Pane, saved_tag: []const u8) void { const pfx = p.tagPrefix(pane) catch return; if (!std.mem.startsWith(u8, saved_tag, pfx)) return; const rest = saved_tag[pfx.len..]; const legacy = if (pane.file) |f| if (output_pane.fileTraits(f.output).saves) legacy_file_pane_tail else legacy_pane_tail else legacy_pane_tail; if (std.mem.eql(u8, rest, curTail(pane)) or std.mem.eql(u8, rest, legacy)) return; pane.tag_tail.appendSlice(p.gpa, rest) catch return; pane.tag_init = true; } fn removePane(p: *Pardes, id: usize) void { const pane = p.panes[id] orelse return; p.absorbVWeight(id); p.layoutRemove(id); p.deinitPane(pane); p.panes[id] = null; if (p.active == id) p.active = p.prevFocus(id) orelse { p.quit = true; p.emit(.quit); return; }; } /// where focus falls when the active pane closes: the most recently /// focused pane still alive (else any live one). Null = nothing left. /// Walks the jump stack newest-first, so it answers exactly what it always /// did — a pane's newest entry sits where the old MRU put the pane. 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; } /// THE PUSH RULE, and the only place it is written down. /// /// A location is worth remembering when you cannot see it any more: focus /// ended this update in a DIFFERENT pane, or more than a bodyful of rows /// away in the same one. Anything closer is the cursor strolling, and the /// current entry just follows it — so h/j/k/w/b never grow the list, while /// a goto-line, a search hit, a goto-definition and every focus change do. /// (Vim's rule is a hand-kept list of "jump commands"; this one asks the /// question those commands are a proxy for, and needs no list.) /// /// It is read HERE, once per update, and nowhere else: what a call site /// does transiently is invisible, which is what keeps n/N over a results /// buffer — which focuses each hit and comes straight back — from pushing /// two entries per keystroke. That transparency is the whole reason the /// rule lives in sync() rather than at the sites that move focus, which is /// where the heuristics used to be scattered. fn trackJump(p: *Pardes) void { // dead entries first, in one compacting pass. A slot is reused, so the // test is the SERIAL: an entry whose pane has been replaced names a // pane that no longer exists, not the newcomer sitting in its slot. 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; // An UNPINNED cursor belongs to the program on the other end of the // pty, not to you, so such a pane is remembered as a place and not as // a spot: line 0 is the "no line" focusPaneLine already understands, // and going back there focuses the shell without dragging its view up // to scrollback row 0. It also has no line to be FAR from, which is // what keeps the first keypress in a shell (which pins the cursor // wherever the prompt is) from reading as a jump. 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) { const at = &p.jumps[p.jcur]; const body = @max(1, p.rects[p.active].h -| BOX_H); const far = at.line != 0 and now.line != 0 and @max(now.line, at.line) - @min(now.line, at.line) > body; if (at.pane == now.pane and !far) { at.* = 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 == 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; } /// Ctrl-o / Ctrl-i (the Back and Forward builtins): move the CURSOR into /// the stack and go to what it names. Nothing is pushed and nothing is /// dropped — walking history is not making it — and trackJump agrees, /// because after the move the live spot IS `jumps[jcur]` again. 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]; p.focusPaneLine(j.pane, .{ .line = j.line, .col = j.col }); } /// 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 { p.computeGeom(); p.trackJump(); for (&p.panes, 0..) |*slot, id| { const pane = slot.* orelse continue; if (pane.reply_len > 0) { var off: u16 = 0; while (off < pane.reply_len) { const n = @min(pane.reply_len - off, 64); p.emit(.{ .write = .{ .pane = @intCast(id), .bytes = .from(pane.reply[off .. off + n]) } }); off += n; } pane.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 // a pane shrunk to just its tag keeps its last real grid: no // pty/vt reflow while the body is hidden, so re-enlarging brings // it back exactly as it was 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()) { pane.vt.resize(p.gpa, cols, rows) catch {}; p.emit(.{ .resize_pty = .{ .pane = @intCast(id), .cols = cols, .rows = rows } }); } pane.cols = cols; pane.rows = rows; } // greet a fresh shell with `ls` once it has its real size (after // the first winsize) AND has drawn its first prompt (the pty has // produced output — bash sourced the rc and readline owns echo, so // the greeting echoes on an OSC 133-marked row and hides cleanly; // earlier, the kernel would echo it onto an unmarked row). if (pane.greet and pane.isTerminal() and p.resize_count > 0 and pane.tty_stream.items.len > 0) { 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; } } } fn computeGeom(p: *Pardes) void { if (p.ncol == 0) return; var wsum: f32 = 0; for (0..p.ncol) |c| wsum += p.col_weight[c]; if (wsum <= 0) wsum = 1; var x: u16 = 0; for (0..p.ncol) |c| { const last = c + 1 == p.ncol; const fw = @as(f32, @floatFromInt(p.screen_w)) * p.col_weight[c] / wsum; const wroom = p.screen_w -| x; // same clamp as the rows below const cw: u16 = if (last) wroom else @min(wroom, @max(1, @as(u16, @intFromFloat(@round(fw))))); p.col_x[c] = x; p.col_w[c] = cw; var vsum: f32 = 0; for (0..p.col_n[c]) |k| { if (p.panes[p.col_terms[c][k]]) |pane| vsum += pane.vweight; } if (vsum <= 0) vsum = 1; var y: u16 = TOPBAR_H; const avail_h = p.screen_h -| TOPBAR_H; for (0..p.col_n[c]) |k| { const id = p.col_terms[c][k]; const pane = p.panes[id] orelse continue; const lastk = k + 1 == p.col_n[c]; const fh = @as(f32, @floatFromInt(avail_h)) * pane.vweight / vsum; // every pane wants at least one row, so a column with more // panes than the window has rows would walk `y` off the bottom // and hand renderPane a rect outside the surface (assert, then // panic — shrink a window with a few stacked panes). Clamp to // what is left: the panes past the edge get h = 0 and render // nothing until the window grows back. const room = p.screen_h -| y; const ch: u16 = if (lastk) room else @min(room, @max(1, @as(u16, @intFromFloat(@round(fh))))); p.rects[id] = .{ .x = x, .y = y, .w = cw, .h = ch }; y +|= ch; } x +|= cw; } } pub fn theme(p: *const Pardes) *const Theme { return &themes[p.theme_idx]; } // ---- render: build the canonical surface ---- pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { file_pane.refreshHighlights(p); const s = &p.surface; const ncells = @as(usize, p.screen_w) * p.screen_h; if (s.cells.len != ncells) { p.gpa.free(s.cells); s.cells = try p.gpa.alloc(Cell, ncells); } s.cols = p.screen_w; s.rows = p.screen_h; s.cursor = null; s.images = @splat(null); s.nimages = 0; const th = p.theme(); // gaps between panes read as chrome (scrollbar track), not raw default s.fill(0, 0, s.cols, s.rows, .{ .bg = .{ .rgb = th.scroll_track } }); for (&p.panes, 0..) |*slot, id| { const pane = slot.* orelse continue; try p.renderPane(arena, pane, p.rects[id], id == p.active); } // global tagbar: full width, top row s.fill(0, 0, s.cols, TOPBAR_H, .{ .bg = .{ .rgb = th.tag_bg } }); var tb_buf: [1200]u8 = undefined; _ = s.print(0, 0, s.cols, p.topbar(&tb_buf), .{ .fg = .{ .rgb = th.tag_fg }, .bg = .{ .rgb = th.tag_bg } }); // the topbar's cursor, if it has the keyboard. AFTER the pane loop on // purpose: there is exactly one Surface cursor and the bar's must beat // the active pane's. Always a block — the bar has no insert mode. if (p.topbar_col) |c| if (c < s.cols) { s.cursor = .{ .x = c, .y = 0, .bar = false }; }; // resize-handle hint / drag previews: a dash overlay that keeps the // underlying colors (border drags + hover), or the move indicator. A // drag holds the coordinates of the last mouse event, so a resize // mid-drag (tiling WM, font-size change) can leave them off the new // surface — every arm below checks before it draws. 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, "╎"); } // a corner lights BOTH splits, which is the whole tell that you // grabbed the crossing and not an edge. The horizontal half // stops at the vertical preview rather than at the column's // current right edge, so the two dashes stay joined at the cell // under the mouse and together show the shape being committed. if (d.corner_idx != null and d.cur_y < s.rows) { var col = p.col_x[d.left_col]; while (col <= d.cur_x 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, "╌"); }, .move => |d| if (d.cur_x < s.cols) { if (p.movePlacement(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 = th.lineno } }); } s.set(d.cur_x, placement.row, "▌", .{ .fg = .{ .rgb = th.lineno } }); } }, .none, .select, .tag => { // 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; for (0..p.col_n[cc] -| 1) |k| { const r = p.rects[p.col_terms[cc][k]]; if (p.hover_row == r.y + r.h -| 1) { var col = p.col_x[cc]; while (col < p.col_x[cc] + p.col_w[cc]) : (col += 1) s.overlayDash(col, p.hover_row, "╌"); } } } }, } // debug overlay: a small stats box drawn last, top-right ("Debug") if (p.show_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 = file_pane.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 = at.vt.screens.active.pages.scrollbar(); sb_off = sb.offset; sb_total = sb.total; } var ov_buf: [192]u8 = undefined; const text = std.fmt.bufPrint( &ov_buf, "pardes resizes={d}\nterms {d} cols {d}\nactive #{d} {d}x{d}\nscroll {d}/{d}\npinch {d:6.4}\ntscroll {d:7.4}", .{ p.resize_count, nlive, p.ncol, p.active, at.cols, at.rows, sb_off, sb_total, p.ov_pinch_scale, p.ov_touch_scroll_delta }, ) catch ""; const ow: u16 = 30; const oh: u16 = 8; 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 = th.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 = th.tag_fg } }); } } }; return s; } fn renderPane(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, 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 tx = r.x + config.GUTTER; // text area (tag + body), right of the gutter const tw = r.w - config.GUTTER; // the pane's own background, for everything that has to read as "no // chrome here": the body text, and the blank right half of the // scrollbar's second column. `.default` means the host terminal's own // background, which is what a themeless dark pane wants. 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. s.clearRect(tx, r.y, tw, r.h); if (th.bg) |bg| s.fill(tx, r.y, tw, r.h, .{ .bg = .{ .rgb = bg } }); // the layout box: the pane's MODE, one character, in the gutter cells // of the tag row. Same box you drag a pane by — the whole GUTTER is // still painted and the `.move` press hit-test in handleMouse is // untouched — it just carries the one piece of state that used to cost // four columns of every tagline. // The glyph goes in column 0, directly above the scrollbar's ink // column below it, so a pane's chrome reads as one line down its left // edge; column 1 stays plain colour, and that blank half is what keeps // the thing reading as a BOX rather than as a letter someone dropped // in the gutter. // // The mode shown is the BODY's. A tag edit hijacks pane.mode to insert // (tags are always insert; the real one is parked in tag_mode), and a // badge that flipped every time you clicked a tagline would be // reporting the tag's mode on the pane's box. Reading tag_mode also // makes the parked value VISIBLE: a terminal being tag-edited still // shows `$`, which is exactly the invariant ttyclick.snap exists for. // // The ink is picked off the box's own brightness instead of being // named in the theme, because box/box_dim come from a generated // theme's cursor and selection colours — light on a light theme — and // a fixed white would vanish there. Rec.601-ish integer weights. // ponytail: two colours, black or white, chosen at a fixed threshold. // Upgrade to the theme's own fg/bg pair when a theme turns up whose // box wants a tint rather than a contrast. const box_bg = if (active) th.box else th.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 } }; s.fill(r.x, r.y, config.GUTTER, BOX_H, box_style); const box_mode = if (pane.tag_edit) pane.tag_mode else pane.mode; s.set(r.x, r.y, switch (box_mode) { .normal => config.tag_normal, .insert => config.tag_insert, .tty => config.tag_tty, }, box_style); // tag: live prefix (cwd/path) + editable tail, on a dim bar const tag_style: CellStyle = .{ .fg = .{ .rgb = th.tag_fg }, .bg = .{ .rgb = th.tag_bg } }; s.fill(tx, r.y, tw, BOX_H, .{ .bg = .{ .rgb = th.tag_bg } }); const tag = try p.tagText(arena, pane); _ = s.print(tx, r.y, tw, tag, tag_style); // pending SPC leader path, on the ACTIVE pane's tag at its RIGHT edge: // the same rule as the `/` input — state you can see, nothing that // disappears — but right-aligned, so it never collides with the // builtins in the tail. Printed AFTER the tag, so a tag long enough to // reach it loses its last columns instead: what you are about to press // beats the path, which is still readable in the prefix. if (active and p.leader_on) { 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, r.y, w, ibuf[0..iw], tag_style); } // tag char selection highlight (helix v/x, or a tagline sweep), // inclusive [lo, hi] — rendered-tag columns, so no conversion if (pane.tag_edit and pane.tag_sel) { const b = tagSelBounds(pane); var col: usize = b.lo; const end: usize = b.hi; while (col <= end and col < tw) : (col += 1) { const cell = s.at(tx + @as(u16, @intCast(col)), r.y); cell.default = false; cell.style.bg = .{ .rgb = msel_bg }; cell.style.fg = .{ .rgb = msel_fg }; } } // cursor while editing the tag: a rendered-tag column, verbatim if (active and pane.tag_edit) { // bar while typing, block for `:` normal mode (same rule as a body) if (pane.tag_col < tw) s.cursor = .{ .x = tx + pane.tag_col, .y = r.y, .bar = pane.mode == .insert }; } // A native PDF page uses the same backend-neutral pixel attachment as // an image. Without native pixels it deliberately falls through: its // extracted text projection becomes an ordinary readable body. if (hasPdf(pane) and p.native_images and p.drawPdf(pane, r, tx, tw)) { s.fill(r.x, r.y + BOX_H, 1, r.h -| BOX_H, .{ .bg = .{ .rgb = th.scroll_track } }); s.fill(r.x + 1, r.y + BOX_H, 1, r.h -| BOX_H, .{ .bg = pane_bg }); return; } // image pane: the picture fills the body — petscii glyph art into the // cells, or a pixel attachment the shell places (kitty). Plain // thumbless gutter so it reads like any other pane. if (pane.image != null) { p.drawImage(pane, r, tx, tw); // thumbless, but the same one column as the real scrollbar below — // that is the whole point of drawing it s.fill(r.x, r.y + BOX_H, 1, r.h -| BOX_H, .{ .bg = .{ .rgb = th.scroll_track } }); s.fill(r.x + 1, r.y + BOX_H, 1, r.h -| BOX_H, .{ .bg = pane_bg }); return; } // 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(); 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, r.y + BOX_H + i, tw, line, body_style); } // the two file-only passes and the terminal-only one: each is the // whole reason its pane kind renders differently at all, so each lives // with its kind. Order is load-bearing — gutter, then syntax over the // content, then the emulator's own colors — and the selection/cursor // passes below still win over all three. if (pane.file) |*f| { file_pane.drawGutter(p, pane, r, tx, tw, body_h, active); if (p.colors_on) file_pane.recolorSyntax(p, pane, f, r, tx, tw, body_h); } const off = pane.scroll(); if (p.colors_on and pane.isTerminal()) term_pane.recolorAnsi(p, pane, r, tx, tw, body_h, off); // mouse selections (pane-local coords), one pass per button — later // buttons win on overlap. A left .done stays highlighted after release; // middle/right .done are transient (they fire their action on release). for (pane.sel, 0..) |sl, b| { if (sl.state == .none) continue; if (sl.state == .done and b != 0) continue; 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; var col: i32 = if (pane.file != null) @max(c0, @as(i32, config.PREFIX_W)) else c0; while (col <= c1 and col < tw) : (col += 1) { const cell = s.at(tx + @as(u16, @intCast(col)), r.y + row); cell.default = false; cell.style.bg = .{ .rgb = SEL_BG[b] }; cell.style.fg = .{ .rgb = sel_fg }; } } } // modal line selection (helix `x`): whole body rows, normal mode only if (pane.mode == .normal and pane.msel.active) { const lo = @min(pane.msel.r0, pane.msel.r1) - off + @as(i32, BOX_H); const hi = @max(pane.msel.r0, pane.msel.r1) - off + @as(i32, BOX_H); var row: u16 = BOX_H; // never paint the tag row while (row < r.h) : (row += 1) { if (@as(i32, row) < lo or @as(i32, row) > hi) continue; var col: u16 = if (pane.file != null) config.PREFIX_W else 0; while (col < tw) : (col += 1) { const cell = s.at(tx + col, r.y + row); cell.default = false; cell.style.bg = .{ .rgb = msel_bg }; cell.style.fg = .{ .rgb = msel_fg }; } } } // modal char selection (helix `v`): stream-shaped anchor..head // highlight. The EXTRA cursors are the same shape drawn dimmer, and // each of them also paints its own cursor cell bright: there is one // hardware cursor and the primary owns it, so a secondary cursor has // to be a cell colour or it is invisible. // The extra cursors show in INSERT mode too — that is exactly when you // need to see where your typing is landing — while the primary's // selection highlight stays normal-mode-only, as it always was. // ...and an armed `s`/`S` shows the primary WHATEVER shape it is, even // though the pane is in insert mode for the tag and even when the match // is a single cell: the hardware cursor is off in the tag, so a preview // that leans on it shows every match except the one you are on. const preview = selRegexArmed(pane) != null; const show_prim = (pane.mode == .normal and pane.vsel.active) or preview; const show_extra = pane.mode != .tty and pane.nsel > 0; if (show_prim or show_extra) { const vpfx: i32 = if (pane.file != null) config.PREFIX_W else 0; const vhs: i32 = if (pane.file != null) pane.hscroll 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) 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 bg = if (primary) msel_bg else msel2_bg; const fg = if (primary) msel_fg else msel2_fg; var ar: i32 = bnd.lo_row; while (ar <= bnd.hi_row) : (ar += 1) { const prow = ar - off + @as(i32, BOX_H); if (prow < BOX_H or prow >= @as(i32, r.h)) continue; const cstart: i32 = if (ar == bnd.lo_row) bnd.lo_col - vhs + vpfx else vpfx; const cend: i32 = if (ar == bnd.hi_row) bnd.hi_col - vhs + 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)), r.y + @as(u16, @intCast(prow))); cell.default = false; cell.style.bg = .{ .rgb = bg }; cell.style.fg = .{ .rgb = fg }; } } if (primary and !preview) continue; // the hardware cursor IS the primary's const crow = sr.row - off + @as(i32, BOX_H); const ccol = sr.col - vhs + 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)), r.y + @as(u16, @intCast(crow))); cell.default = false; cell.style.bg = .{ .rgb = msel_fg }; cell.style.fg = .{ .rgb = msel_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) { const cur = pane.vt.screens.active.cursor; if (pane.mode != .tty) { const goff: i32 = @intCast(pane.vt.screens.active.pages.scrollbar().offset); 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 prow = crow - off + @as(i32, BOX_H); // files: cur_col is content-relative, add the lineno prefix const cx = if (pane.file != null) @as(i32, config.PREFIX_W) + ccol - pane.hscroll 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 = r.y + @as(u16, @intCast(prow)), .bar = pane.mode == .insert }; } else if (cur.y + BOX_H < r.h and cur.x < tw) { s.cursor = .{ .x = tx + cur.x, .y = r.y + cur.y + BOX_H, .bar = pane.mode == .insert }; } } // gutter below the tag row: scrollbar track + thumb. (The move box on // the tag row itself is drawn up with the tag, since it now carries // the mode and belongs with the rest of that row.) // // The bar is ONE column: column 1 is the track/thumb, column 2 is the // pane's own background. That second fill is not optional — render() // pre-fills the whole surface with scroll_track so the gaps between // panes read as chrome, so a column left unpainted here keeps the // track colour and the bar looks two wide again. // // The move box above stays the full GUTTER even though only half of it // has ink in it. It is a target you drag, not a gauge you read, and its // whole width is the affordance — the step where the box ends and the // narrower bar begins is the one place on screen that says those are // two different pieces of chrome. // // Nothing here touches layout or hit-testing: the gutter is still // config.GUTTER columns and the click handlers still scroll on any of // them, so the blank column is still live. Only the ink narrowed. if (r.h > BOX_H) { s.fill(r.x, r.y + BOX_H, 1, r.h - BOX_H, .{ .bg = .{ .rgb = th.scroll_track } }); s.fill(r.x + 1, r.y + BOX_H, 1, r.h - BOX_H, .{ .bg = pane_bg }); const sb: struct { total: usize, offset: usize, len: usize } = if (pane.file) |*f| .{ .total = file_pane.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 = pane.vt.screens.active.pages.scrollbar(); 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, r.y + BOX_H + @as(u16, @intCast(sy)), 1, 1, .{ .bg = .{ .rgb = th.scroll_thumb } }); } } } /// Render the current PDF page once per backend-quality request and attach /// its stable RGBA until pixels or physical viewport quality change. /// Fit/pan stay placement-only, while every request retains its backend's /// hard longest-side allocation ceiling. fn drawPdf(p: *Pardes, pane: *Pane, r: Rect, tx: u16, tw: u16) bool { if (comptime !pdf_enabled) return false; const pv = &(pane.pdf orelse return false); const request = p.pdfRenderRequest(pane) orelse return false; if (!pv.render_tried or !pv.render_request_valid or !pv.render_request.eql(request)) { pv.render_tried = true; pv.render_request = request; pv.render_request_valid = true; p.resolvePdfSearch(pv); const rendered = render: { const highlights = p.pdfHighlights(pv) catch break :render null; const fresh = if (highlights.len > 0) pv.document.renderWithHighlightsAt(p.gpa, pv.page, request, highlights) catch break :render null else pv.document.renderAt(p.gpa, pv.page, request) catch break :render null; break :render fresh; }; if (rendered) |fresh| { if (pv.rgba.len > 0) p.gpa.free(pv.rgba); pv.rgba = fresh.rgba; pv.iw = fresh.width; pv.ih = fresh.height; pv.render_revision +%= 1; if (pv.render_revision == 0) pv.render_revision = 1; } } p.rearmPdfRevealForViewport(pane, pv); p.revealPdfSearch(pane, pv); if (pv.rgba.len == 0 or r.h <= BOX_H) return false; const id: u8 = @intCast(for (p.panes, 0..) |slot, i| { if (slot == pane) break i; } else 0); p.surface.images[p.surface.nimages] = .{ .pane = id, .serial = pane.serial, .native = .{ .revision = pv.render_revision, .fit = switch (pv.fit) { .width => .width, .height => .height, }, .pan_x = pv.pan_x, .pan_y = pv.pan_y, }, .x = tx, .y = r.y + BOX_H, .w = tw, .h = r.h - BOX_H, .rgba = pv.rgba, .iw = pv.iw, .ih = pv.ih, }; p.surface.nimages += 1; return true; } /// Image pane body: decode once (path via look, or dump-loaded raw bytes), /// rebuild the petscii grid when the size/toggles changed, then either /// blit the glyph art into cells or attach the pixels for the shell. fn drawImage(p: *Pardes, pane: *Pane, r: Rect, tx: u16, tw: u16) void { const iv = &pane.image.?; const s = &p.surface; if (!iv.tried) { iv.tried = true; const bytes: []const u8 = if (iv.raw.len > 0) iv.raw else (look.readFile(p.scratch.allocator(), iv.path) catch ""); if (image.decode(p.gpa, bytes)) |d| { iv.rgba = d.rgba; iv.iw = d.w; iv.ih = d.h; } } if (iv.rgba.len == 0 or r.h <= BOX_H) return; const body_cols = tw; const body_rows = r.h - BOX_H; const use_petscii = iv.petscii or !p.native_images; if (!use_petscii) { s.images[s.nimages] = .{ .pane = @intCast(for (p.panes, 0..) |slot, i| { if (slot == pane) break i; } else 0), .serial = pane.serial, .x = tx, .y = r.y + BOX_H, .w = body_cols, .h = body_rows, .rgba = iv.rgba, .iw = iv.iw, .ih = iv.ih, }; s.nimages += 1; return; } // (re)build the cached glyph grid if the size or toggles changed if (iv.grid.len == 0 or iv.grid_w != body_cols or iv.grid_h != body_rows or iv.grid_mode != iv.pmode or iv.grid_ascii != iv.ascii) { if (iv.grid.len > 0) p.gpa.free(iv.grid); const pal = switch (iv.pmode) { .commodore => image.petscii.commodore, .terminal => image.ansiPalette(), }; const g = image.petscii.render(p.gpa, iv.rgba, iv.iw, iv.ih, body_cols, body_rows, pal, iv.ascii) catch image.petscii.Grid{ .cells = &.{}, .gw = 0, .gh = 0 }; iv.grid = g.cells; iv.gw = g.gw; iv.gh = g.gh; iv.grid_w = body_cols; iv.grid_h = body_rows; iv.grid_mode = iv.pmode; iv.grid_ascii = iv.ascii; } if (iv.grid.len == 0) return; const offx = if (body_cols > iv.gw) (@as(usize, body_cols) - iv.gw) / 2 else 0; const offy = if (body_rows > iv.gh) (@as(usize, body_rows) - iv.gh) / 2 else 0; var cy: usize = 0; while (cy < iv.gh) : (cy += 1) { var cx: usize = 0; while (cx < iv.gw) : (cx += 1) { const c = &iv.grid[cy * iv.gw + cx]; const fg: Color = switch (iv.pmode) { .commodore => .{ .rgb = image.petscii.commodore[c.fg] }, .terminal => .{ .index = c.fg }, }; const bg: Color = switch (iv.pmode) { .commodore => .{ .rgb = image.petscii.commodore[c.bg] }, .terminal => .{ .index = c.bg }, }; const sx = tx + @as(u16, @intCast(offx + cx)); const sy = r.y + BOX_H + @as(u16, @intCast(offy + cy)); if (sx < s.cols and sy < s.rows) s.set(sx, sy, c.glyph[0..c.glen], .{ .fg = fg, .bg = bg }); } } } /// The pane body as text. Image: blank rows (the picture draws over it). /// File: line-numbered content from f.scroll. /// Terminal: viewport rows, padded to the grid height, prompt rows blanked /// outside tty mode (OSC 133), the edit buffer standing in for the shell /// rows it covers — which is where a buffer holding more lines than those /// rows pushes the output below it down the screen. 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 (hasPdf(pane)) { const text = try p.ensurePdfText(pane); const scroll = if (comptime pdf_enabled) pane.pdf.?.text_scroll else 0; var out: std.ArrayList(u8) = .empty; var lines = std.mem.splitScalar(u8, text, '\n'); var row: usize = 0; var wrote: usize = 0; while (lines.next()) |line| : (row += 1) { if (row < scroll) continue; if (wrote >= pane.rows -| BOX_H) break; if (wrote > 0) try out.append(arena, '\n'); try out.appendSlice(arena, line); wrote += 1; } return out.items; } if (pane.file) |*f| return file_pane.bodyText(arena, pane, f); return term_pane.bodyText(arena, pane); } };