summaryrefslogtreecommitdiff
path: root/src/pardes.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-16 15:49:12 -0300
committerGabriel Schneider <[email protected]>2026-08-18 23:44:42 -0300
commit1551e409c31992437cb2fa864f576d45c8433801 (patch)
treee2fae8451f87b735a1360c7c2e383fdc40165789 /src/pardes.zig
parentbe2a9957708cbf0c478ca861c4a1f0f227bbfe10 (diff)
downloadpardes-1551e409c31992437cb2fa864f576d45c8433801.tar.gz
pardes-1551e409c31992437cb2fa864f576d45c8433801.zip
big slow change: prebuilt shaders (SPIR-V/Metal), core gui reflow, docs, web + snapshot refresh
Diffstat (limited to 'src/pardes.zig')
-rw-r--r--src/pardes.zig8281
1 files changed, 3059 insertions, 5222 deletions
diff --git a/src/pardes.zig b/src/pardes.zig
index 3c236f34..c5d06a43 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -1,10 +1,11 @@
//! 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:
+//! library. The platform shell owns the event loop and process-facing 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.
+//! Path-backed document and search operations may read synchronously; work
+//! which needs a host or event loop is emitted as an Effect.
//!
//! 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.
@@ -20,6 +21,7 @@
//! enumerates the leader.
const std = @import("std");
pub const animation = @import("animation.zig");
+pub const panel_animation = @import("panel_animation.zig");
const ghostty_vt = @import("ghostty-vt");
const uucode = @import("uucode");
const mvzr = @import("mvzr");
@@ -30,13 +32,15 @@ pub 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 image_pane = @import("image_pane.zig");
+pub const pdf_pane = @import("pdf_pane.zig");
const output_pane = @import("output_pane.zig");
const builtins = @import("builtins.zig");
+const runtime_cfg = @import("runtime_config.zig");
const selection_pipe = @import("selection_pipe.zig");
pub const config = @import("config.zig");
-pub const pdf_enabled = @import("pardes_config").mupdf;
-pub const pdf = if (pdf_enabled) @import("mupdf") else struct {};
-const pdf_impl = pdf;
+pub const pdf_enabled = pdf_pane.enabled;
+pub const pdf = pdf_pane.pdf;
pub const allocators = @import("allocators.zig");
pub const image = @import("image.zig");
pub const dump = @import("dump.zig");
@@ -60,18 +64,12 @@ pub const font_picker = platform == .gui or platform == .macos;
/// resolved, which the Debug overlay prints. Behind the same comptime shim
/// builtins.zig and macos.zig import this file with, so a tty or web binary
/// never analyses a font-directory walk it cannot use.
-const fonts = if (font_picker) @import("fonts.zig") else struct {
- pub const chosen: []const u8 = "";
-};
+const fonts = if (font_picker) @import("fonts.zig") else struct {};
/// 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 PdfRasterPolicy = pdf_pane.RasterPolicy;
pub const kitty_pdf_raster_policy: PdfRasterPolicy = .{
.dpi = 96,
@@ -91,22 +89,18 @@ pub const pdf_raster_policy: PdfRasterPolicy = switch (platform) {
.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 PDF_PAGE_GAP_PX: u32 = 8;
+pub const PDF_PAGE_GAP_PX = pdf_pane.page_gap_px;
pub const MAX_COLS = 6;
+/// 32 fractional bits leave ample precision for resize/restored ratios while
+/// allowing every possible column split to divide an initial weight exactly.
+const column_weight_unit: u64 = 1 << 32;
+const max_column_weight: u64 = std.math.maxInt(u64) / MAX_COLS;
/// 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.
@@ -135,1232 +129,16 @@ pub const Loc = struct {
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);
- }
-
- // SDL's raw dy path retains fractions in the placement itself; it does
- // not leave native pixels fixed while only the underlying cells slide.
- p.update(.{ .pdf_scroll = .{ .pane = 0, .delta_pixels = 0.25 } });
- try std.testing.expectEqual(@as(f64, 0.25), pane.pdf.?.document_scroll_y);
- _ = frame.reset(.retain_capacity);
- const fractional = try p.render(frame.allocator());
- try std.testing.expectEqual(first_place.native.revision, fractional.images[0].?.native.revision);
- try std.testing.expectEqual(@as(f32, -0.25), fractional.images[0].?.native.pixel_offset_y);
- pane.pdf.?.document_scroll_y = 0;
-
- // The default reading view moves one exact display-cell distance without
- // replacing page pixels. Document placement, not texture identity, moves.
- p.update(.{ .key = .{ .cp = 'j' } });
- try std.testing.expectEqual(@as(usize, 0), pane.pdf.?.page);
- try std.testing.expectEqual(@as(f64, p.cell_pixels.h), pane.pdf.?.document_scroll_y);
- 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.expect(panned.images[0].?.native.geometry.?.src.y > first_place.native.geometry.?.src.y);
-
- const row_scroll = pane.pdf.?.document_scroll_y;
- pane.pdf.?.document_scroll_y = 0;
- p.update(.{ .key = .{ .cp = '3' } });
- p.update(.{ .key = .{ .cp = 'j' } });
- try std.testing.expectEqual(@as(f64, p.cell_pixels.h * 3), pane.pdf.?.document_scroll_y);
-
- // Counts survive a shared multi-key prefix. An invalid continuation is
- // consumed and clears both prefix and count before the following action.
- pane.pdf.?.document_scroll_y = 0;
- p.update(.{ .key = .{ .cp = '2' } });
- p.update(.{ .key = .{ .cp = 'g' } });
- p.update(.{ .key = .{ .cp = 'j' } });
- try std.testing.expectEqual(@as(f64, p.cell_pixels.h * 2), pane.pdf.?.document_scroll_y);
- pane.pdf.?.document_scroll_y = 0;
- p.update(.{ .key = .{ .cp = '4' } });
- p.update(.{ .key = .{ .cp = 'g' } });
- p.update(.{ .key = .{ .cp = '?' } });
- try std.testing.expectEqual(@as(u21, 0), pane.pending);
- try std.testing.expectEqual(@as(u32, 0), pane.count);
- p.update(.{ .key = .{ .cp = 'j' } });
- try std.testing.expectEqual(row_scroll, pane.pdf.?.document_scroll_y);
-
- pane.pdf.?.document_scroll_y = 0;
- p.update(.{ .key = .{ .cp = 'd', .ctrl = true } });
- const half_scroll = pane.pdf.?.document_scroll_y;
- try std.testing.expect(half_scroll > row_scroll);
- pane.pdf.?.document_scroll_y = 0;
- p.update(.{ .key = .{ .cp = '2' } });
- p.update(.{ .key = .{ .cp = 'd', .ctrl = true } });
- const counted_half = pane.pdf.?.document_scroll_y;
- try std.testing.expectEqual(half_scroll * 2, counted_half);
- p.update(.{ .key = .{ .cp = '2' } });
- p.update(.{ .key = .{ .cp = 'u', .ctrl = true } });
- try std.testing.expectEqual(@as(f64, 0), pane.pdf.?.document_scroll_y);
- pane.pdf.?.document_scroll_y = @as(f64, p.cell_pixels.h) * 4;
- p.update(.{ .key = .{ .cp = '3' } });
- p.update(.{ .key = .{ .cp = 'k' } });
- try std.testing.expectEqual(row_scroll, pane.pdf.?.document_scroll_y);
- pane.pdf.?.document_scroll_y = 0;
- p.update(.{ .key = .{ .cp = 'f', .ctrl = true } });
- try std.testing.expect(pane.pdf.?.document_scroll_y >= half_scroll);
- pane.pdf.?.document_scroll_y = 0;
- p.update(.{ .key = .{ .cp = '2' } });
- p.update(.{ .key = .{ .cp = 'f', .ctrl = true } });
- const key_viewport = p.pdfViewport(pane).?;
- const max_key_scroll = @as(f64, @floatFromInt(pane.pdf.?.document_height -| key_viewport.pixel_h));
- const counted_full = @min(@as(f64, @floatFromInt(key_viewport.pixel_h * 2)), max_key_scroll);
- try std.testing.expectEqual(counted_full, pane.pdf.?.document_scroll_y);
- p.update(.{ .key = .{ .cp = '2' } });
- p.update(.{ .key = .{ .cp = 'b', .ctrl = true } });
- try std.testing.expectEqual(@max(@as(f64, 0), counted_full - @as(f64, @floatFromInt(key_viewport.pixel_h * 2))), pane.pdf.?.document_scroll_y);
- try std.testing.expectEqual(first_place.native.revision, pane.pdf.?.render_revision);
-
- // At a page boundary both page rasters coexist, the gap remains uncovered,
- // and a row step crosses it without snapping either page to an edge.
- //
- // This lands by ASSIGNMENT, which is a jump and not a fling — and the
- // travel counter has to say so: the keys above scrolled two screenfuls
- // without any frame in between to spend that distance, which no shell does
- // (every wheel batch is followed by a draw). Left unspent it would make the
- // frame below the first frame of a fling and hand it bands.
- const viewport = p.pdfViewport(pane).?;
- pane.pdf.?.scroll_travel = 0;
- pane.pdf.?.document_scroll_y = @floatFromInt(
- pane.pdf.?.page_starts[1] -| viewport.pixel_h / 2,
- );
- _ = frame.reset(.retain_capacity);
- const boundary = try p.render(frame.allocator());
- try std.testing.expectEqual(@as(usize, 2), boundary.nimages);
- try std.testing.expectEqual(@as(u32, 0), boundary.images[0].?.native.page);
- try std.testing.expectEqual(@as(u32, 1), boundary.images[1].?.native.page);
- const first_bottom = @as(f32, @floatFromInt(
- boundary.images[0].?.native.geometry.?.dst.y +
- boundary.images[0].?.native.geometry.?.dst.h,
- )) + boundary.images[0].?.native.pixel_offset_y;
- const second_top = @as(f32, @floatFromInt(
- boundary.images[1].?.native.geometry.?.dst.y,
- )) + boundary.images[1].?.native.pixel_offset_y;
- const visible_gap = second_top - first_bottom;
- try std.testing.expect(visible_gap >= @as(f32, PDF_PAGE_GAP_PX));
- try std.testing.expect(visible_gap <= @as(f32, PDF_PAGE_GAP_PX + 1));
- const before_boundary_scroll = pane.pdf.?.document_scroll_y;
- const page0_revision = boundary.images[0].?.native.revision;
- const page1_revision = boundary.images[1].?.native.revision;
-
- // A scroll can activate the already-cached neighbor before the shell's
- // next draw. Publish that page's aliases immediately so a queued click in
- // the same input batch cannot use page zero's dimensions.
- pane.pdf.?.document_scroll_y = @as(f64, @floatFromInt(pane.pdf.?.page_starts[1])) - 0.5;
- Pardes.syncPdfRasterAliases(&pane.pdf.?, Pardes.pdfRasterForPage(&pane.pdf.?, 0).?);
- try std.testing.expectEqual(page0_revision, pane.pdf.?.render_revision);
- try std.testing.expect(p.scrollPdfDocument(pane, 1));
- try std.testing.expectEqual(@as(usize, 1), pane.pdf.?.page);
- try std.testing.expectEqual(page1_revision, pane.pdf.?.render_revision);
- p.activatePdfPage(pane, 0, false);
- pane.pdf.?.document_scroll_y = before_boundary_scroll;
-
- p.update(.{ .key = .{ .cp = 'j' } });
- try std.testing.expectEqual(before_boundary_scroll + p.cell_pixels.h, pane.pdf.?.document_scroll_y);
- _ = frame.reset(.retain_capacity);
- const second = try p.render(frame.allocator());
- try std.testing.expectEqual(@as(usize, 2), second.nimages);
- try std.testing.expectEqual(page0_revision, second.images[0].?.native.revision);
- try std.testing.expectEqual(page1_revision, second.images[1].?.native.revision);
- p.update(.{ .key = .{ .cp = 'k' } });
- try std.testing.expectEqual(before_boundary_scroll, pane.pdf.?.document_scroll_y);
-
- // Once page zero is wholly outside the viewport, both its owned RGBA and
- // backend placement disappear; returning later renders a new raster. What
- // does NOT go back is the memory: the departing page's bytes are parked in
- // the relay and the arriving page of the same size takes them, so a fling
- // never asks the allocator (or the kernel's fault handler) for megabytes it
- // just gave up.
- p.setPdfPage(pane, 1);
- _ = frame.reset(.retain_capacity);
- const away = try p.render(frame.allocator());
- try std.testing.expect(away.nimages > 0);
- for (away.images[0..away.nimages]) |maybe| if (maybe) |place|
- try std.testing.expect(place.native.page != 0);
- try std.testing.expect(Pardes.pdfRasterForPage(&pane.pdf.?, 0) == null);
- try std.testing.expectEqual(@as(usize, 1), pane.pdf.?.spare_len);
- const retired = pane.pdf.?.spare[0];
- p.setPdfPage(pane, 0);
- _ = frame.reset(.retain_capacity);
- const returned = try p.render(frame.allocator());
- try std.testing.expectEqual(@as(u32, 0), returned.images[0].?.native.page);
- try std.testing.expectEqual(retired.ptr, returned.images[0].?.rgba.ptr);
- try std.testing.expect(returned.images[0].?.native.revision != page0_revision);
-
- // PdfFit exists as a real builtin in this build. It resets placement but
- // preserves the current page pixels; fit-height j/k remains continuous in
- // the same document-pixel coordinate space.
- 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 active_raster = Pardes.pdfRasterForPage(&pane.pdf.?, pane.pdf.?.page).?;
- const saved_iw = active_raster.iw;
- const saved_ih = active_raster.ih;
- active_raster.iw = 2000;
- active_raster.ih = 500;
- Pardes.syncPdfRasterAliases(&pane.pdf.?, active_raster);
- 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.?.pan_x = 0;
- p.update(.{ .key = .{ .cp = '2' } });
- p.update(.{ .key = .{ .cp = 'l' } });
- try std.testing.expect(pane.pdf.?.pan_x > 0);
- p.update(.{ .key = .{ .cp = '$' } });
- try std.testing.expectEqual(std.math.maxInt(u16), pane.pdf.?.pan_x);
- p.update(.{ .key = .{ .cp = '2' } });
- p.update(.{ .key = .{ .cp = 'h' } });
- try std.testing.expect(pane.pdf.?.pan_x < std.math.maxInt(u16));
- p.update(.{ .key = .{ .cp = '0' } });
- try std.testing.expectEqual(@as(u16, 0), pane.pdf.?.pan_x);
- p.update(.{ .key = .{ .cp = 'g' } });
- p.update(.{ .key = .{ .cp = 'l' } });
- try std.testing.expectEqual(std.math.maxInt(u16), pane.pdf.?.pan_x);
- p.update(.{ .key = .{ .cp = 'g' } });
- p.update(.{ .key = .{ .cp = 'h' } });
- try std.testing.expectEqual(@as(u16, 0), pane.pdf.?.pan_x);
- active_raster.iw = saved_iw;
- active_raster.ih = saved_ih;
- Pardes.syncPdfRasterAliases(&pane.pdf.?, active_raster);
- pane.pdf.?.pan_x = 0;
-
- const before_height_scroll = pane.pdf.?.document_scroll_y;
- p.update(.{ .key = .{ .cp = 'j' } });
- try std.testing.expect(pane.pdf.?.document_scroll_y > before_height_scroll);
-
- const revision_before_search = pane.pdf.?.render_revision;
- try 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);
- // n SELECTS the first result row and opens nothing: the walk's only list
- // here is the unlooked +Search buffer, so focus lands THERE. Enter is what
- // jumps, and the document query survives both.
- p.update(.{ .key = .{ .cp = 'n' } });
- try std.testing.expectEqual(results_id, p.active);
- const results_pane = p.panes[results_id].?;
- try std.testing.expect(results_pane.vsel.active and results_pane.vsel.explicit);
- try std.testing.expectEqual(@as(i32, 0), results_pane.cur_row);
- try std.testing.expectEqual(@as(i32, 0), results_pane.cur_col);
- p.update(.{ .key = .{ .cp = Key.enter } });
- try std.testing.expectEqual(@as(usize, 0), p.active);
- 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";
- try 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);
-
- // PDF dumps intentionally retain the existing image-compatible schema:
- // page/path are restored, while pane-local tint starts from the fresh-PDF
- // default rather than being serialized in the image record.
- p.togglePdfTint(pane);
- try std.testing.expectEqual(PdfTintMode.full, pane.pdf.?.tint);
- 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);
- try std.testing.expectEqual(PdfTintMode.filtered, restored.panes[0].?.pdf.?.tint);
-}
-
-test "a fling's banded pages show the reader exactly what whole pages would" {
- if (!pdf_enabled or platform == .web) return;
-
- // The contract fast scrolling is allowed to change: HOW pixels are carried
- // (a strip of a page instead of the page) but never WHICH pixels arrive. So
- // the same frame is drawn twice — once at fling speed, once at reading
- // speed — and every pixel inside every source rectangle must match, along
- // with where on screen it goes.
- const gpa = std.testing.allocator;
- const p = try Pardes.init(gpa, .{ .file = "docs/design.pdf", .cols = 120, .rows = 40 });
- defer p.deinit();
- p.native_images = true;
- const pane = p.panes[0].?;
- const pv = &pane.pdf.?;
- var frame = std.heap.ArenaAllocator.init(gpa);
- defer frame.deinit();
- _ = try p.render(frame.allocator());
-
- const viewport = p.pdfViewport(pane) orelse return error.MissingPdfViewport;
- // Land mid-page-boundary so the frame carries TWO pages, each showing a
- // fraction of itself — the shape a fling actually produces.
- const landing = @as(f64, @floatFromInt(pv.page_starts[1] -| viewport.pixel_h / 3));
- const Shot = struct {
- page: u32,
- dst: image.PixelRect,
- pixels: []u8,
- };
- var shots: [8]Shot = undefined;
- var shots_len: usize = 0;
- defer for (shots[0..shots_len]) |shot| gpa.free(shot.pixels);
-
- // A fling: one frame's worth of wheel travel carrying the viewport more
- // than a screenful, delivered through the real scroll path so the distance
- // is counted the way a wheel batch counts it.
- pv.document_scroll_y = 0;
- pv.scroll_travel = 0;
- try std.testing.expect(p.scrollPdfDocument(pane, landing));
- _ = frame.reset(.retain_capacity);
- const flung = try p.render(frame.allocator());
- try std.testing.expect(flung.nimages >= 2);
- var banded = false;
- for (flung.images[0..flung.nimages]) |maybe| {
- const place = maybe orelse continue;
- const geometry = place.native.geometry orelse return error.MissingPdfGeometry;
- const raster = Pardes.pdfRasterForPage(pv, place.native.page) orelse
- return error.MissingPdfRaster;
- if (raster.band_h < raster.ih) banded = true;
- try std.testing.expectEqual(place.iw * raster.band_h * 4, place.rgba.len);
- shots[shots_len] = .{
- .page = place.native.page,
- .dst = geometry.dst,
- .pixels = try copySourceRect(gpa, place, geometry.src),
- };
- shots_len += 1;
- }
- // ...and it really did band, or the comparison below is two identical
- // whole-page renders agreeing with each other.
- try std.testing.expect(banded);
- // ...at reading speed: no travel at all since the frame above, so every
- // page is rasterized whole again, and that is the picture the banded frame
- // has to have matched.
- _ = frame.reset(.retain_capacity);
- const rested = try p.render(frame.allocator());
- try std.testing.expectEqual(shots_len, rested.nimages);
- for (rested.images[0..rested.nimages], shots[0..shots_len]) |maybe, shot| {
- const place = maybe orelse return error.MissingPdfPlacement;
- const geometry = place.native.geometry orelse return error.MissingPdfGeometry;
- const raster = Pardes.pdfRasterForPage(pv, place.native.page) orelse
- return error.MissingPdfRaster;
- try std.testing.expectEqual(raster.ih, raster.band_h); // promoted at rest
- try std.testing.expectEqual(shot.page, place.native.page);
- try std.testing.expectEqual(shot.dst.x, geometry.dst.x);
- try std.testing.expectEqual(shot.dst.y, geometry.dst.y);
- try std.testing.expectEqual(shot.dst.w, geometry.dst.w);
- try std.testing.expectEqual(shot.dst.h, geometry.dst.h);
- const whole = try copySourceRect(gpa, place, geometry.src);
- defer gpa.free(whole);
- try std.testing.expectEqualSlices(u8, shot.pixels, whole);
- }
-}
-
-/// The pixels a backend samples out of one placement: the source rectangle,
-/// row by row, at the texture's own stride. Test-only, and the one operation
-/// that makes "same picture" mean something when the textures differ in shape.
-fn copySourceRect(
- gpa: std.mem.Allocator,
- place: ImagePlace,
- src: image.PixelRect,
-) ![]u8 {
- const stride = place.iw * 4;
- const row_len = @as(usize, src.w) * 4;
- const out = try gpa.alloc(u8, row_len * src.h);
- errdefer gpa.free(out);
- var row: usize = 0;
- while (row < src.h) : (row += 1) {
- const from = (@as(usize, src.y) + row) * stride + @as(usize, src.x) * 4;
- @memcpy(out[row * row_len ..][0..row_len], place.rgba[from..][0..row_len]);
- }
- return out;
-}
-
-test "PDF normal adapter consumes unsupported actions and navigates page fallback" {
- if (!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].?;
- const pv = &pane.pdf.?;
- try std.testing.expect(pv.page_count > 3);
- p.native_images = false;
-
- // Every vertical vocabulary falls back to counted page changes when the
- // shell cannot place native pixels.
- p.update(.{ .key = .{ .cp = '2' } });
- p.update(.{ .key = .{ .cp = 'j' } });
- try std.testing.expectEqual(@as(usize, 2), pv.page);
- p.update(.{ .key = .{ .cp = '2' } });
- p.update(.{ .key = .{ .cp = 'k' } });
- try std.testing.expectEqual(@as(usize, 0), pv.page);
- p.update(.{ .key = .{ .cp = '2' } });
- p.update(.{ .key = .{ .cp = 'd', .ctrl = true } });
- try std.testing.expectEqual(@as(usize, 2), pv.page);
- p.update(.{ .key = .{ .cp = '2' } });
- p.update(.{ .key = .{ .cp = 'u', .ctrl = true } });
- try std.testing.expectEqual(@as(usize, 0), pv.page);
- p.update(.{ .key = .{ .cp = '2' } });
- p.update(.{ .key = .{ .cp = 'f', .ctrl = true } });
- try std.testing.expectEqual(@as(usize, 2), pv.page);
- p.update(.{ .key = .{ .cp = '2' } });
- p.update(.{ .key = .{ .cp = 'b', .ctrl = true } });
- try std.testing.expectEqual(@as(usize, 0), pv.page);
-
- // Prefix actions and the counted text goto-line action map to pages.
- p.update(.{ .key = .{ .cp = 'g' } });
- p.update(.{ .key = .{ .cp = 'e' } });
- try std.testing.expectEqual(pv.page_count - 1, pv.page);
- p.update(.{ .key = .{ .cp = 'g' } });
- p.update(.{ .key = .{ .cp = 'g' } });
- try std.testing.expectEqual(@as(usize, 0), pv.page);
- p.update(.{ .key = .{ .cp = '3' } });
- p.update(.{ .key = .{ .cp = 'g' } });
- p.update(.{ .key = .{ .cp = 'g' } });
- try std.testing.expectEqual(@as(usize, 2), pv.page);
- p.update(.{ .key = .{ .cp = '2' } });
- p.update(.{ .key = .{ .cp = 'G' } });
- try std.testing.expectEqual(@as(usize, 1), pv.page);
-
- // Editing/selection actions are consumed no-ops: parser state clears,
- // placeholder cells never acquire a range, and PDF state stays intact.
- const page_before_noop = pv.page;
- const revision_before_noop = pv.render_revision;
- p.update(.{ .key = .{ .cp = '4' } });
- p.update(.{ .key = .{ .cp = 'd' } });
- p.update(.{ .key = .{ .cp = 'v' } });
- p.update(.{ .key = .{ .cp = '|' } });
- try std.testing.expectEqual(page_before_noop, pv.page);
- try std.testing.expectEqual(revision_before_noop, pv.render_revision);
- try std.testing.expectEqual(@as(u32, 0), pane.count);
- try std.testing.expectEqual(@as(u21, 0), pane.pending);
- try std.testing.expect(!pane.vsel.active and !pane.msel.active and pane.nsel == 0);
- try std.testing.expect(!pane.hasPipePrompt() and !pane.tag_edit);
-
- // Cross-pane BODY-NORMAL actions keep their established shared paths.
- p.update(.{ .key = .{ .cp = ':' } });
- try std.testing.expect(pane.tag_edit);
- try std.testing.expectEqual(Mode.normal, pane.mode);
- p.update(.{ .key = .{ .cp = Key.escape } });
- try std.testing.expect(!pane.tag_edit);
- p.update(.{ .key = .{ .cp = ' ' } });
- try std.testing.expect(p.leader_on);
- p.update(.{ .key = .{ .cp = '!' } });
- try std.testing.expect(!p.leader_on);
- p.update(.{ .key = .{ .cp = '/' } });
- try std.testing.expect(pane.hasSearchPrompt() and pane.tag_edit);
- p.update(.{ .key = .{ .cp = Key.escape } });
- try std.testing.expect(!pane.hasSearchPrompt() and !pane.tag_edit);
-}
-
-test "PDF continuous strip renders every intersecting short 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].?;
- const pv = &pane.pdf.?;
- try std.testing.expect(pv.page_count > 3);
- p.native_images = true;
-
- // A legal wide MediaBox can make more than three pages intersect one
- // viewport. Seed tiny matching rasters so this tests transport/cache
- // cardinality without spending the unit test rendering fake page sizes.
- for (pv.page_sizes) |*size| size.* = .{ .width = 100_000, .height = 1 };
- pv.layout_valid = false;
- pv.scroll_to_page_pending = true;
- const viewport = p.ensurePdfLayout(pane, pv).?;
- const visible = Pardes.pdfVisiblePages(pv, viewport);
- try std.testing.expectEqual(pv.page_count, visible.len);
- const request = p.pdfRenderRequest(pane).?;
- try std.testing.expect(pv.page_count <= pv.rasters.len);
- for (0..pv.page_count) |page| {
- const rgba = try gpa.alloc(u8, @as(usize, viewport.pixel_w) * 4);
- @memset(rgba, @intCast(page));
- pv.rasters[pv.rasters_len] = .{
- .valid = true,
- .page = page,
- .rgba = rgba,
- .iw = viewport.pixel_w,
- .ih = 1,
- .request = request,
- .request_valid = true,
- .tried = true,
- .tint_key = p.pdfTintKey(pv),
- .revision = @intCast(page + 1),
- };
- pv.rasters_len += 1;
- }
-
- var frame = std.heap.ArenaAllocator.init(gpa);
- defer frame.deinit();
- const surface = try p.render(frame.allocator());
- try std.testing.expectEqual(pv.page_count, surface.nimages);
- try std.testing.expectEqual(pv.page_count, pv.rasters_len);
- for (surface.images[0..surface.nimages], 0..) |maybe, page| {
- const place = maybe orelse return error.MissingShortPdfPage;
- try std.testing.expectEqual(@as(u32, @intCast(page)), place.native.page);
- try std.testing.expectEqual(@as(u32, 1), place.native.geometry.?.dst.h);
- }
-}
-
-test "PdfTint cycles pane-local state and exposes it in the live PDF tag" {
- if (!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].?;
- const pv = &pane.pdf.?;
- const tint_builtin = std.meta.stringToEnum(Builtin, "PdfTint") orelse
- return error.MissingPdfTintBuiltin;
-
- try std.testing.expectEqual(PdfTintMode.filtered, pv.tint);
- const initial_tag = try p.tagText(p.scratch.allocator(), pane);
- try std.testing.expect(std.mem.indexOf(
- u8,
- initial_tag,
- "width PdfFit filtered PdfTint",
- ) != null);
-
- p.runBuiltin(tint_builtin, 0, "", null);
- try std.testing.expectEqual(PdfTintMode.full, pv.tint);
- const full_tag = try p.tagText(p.scratch.allocator(), pane);
- try std.testing.expect(std.mem.indexOf(u8, full_tag, "full PdfTint") != null);
-
- // Running the same pane-scoped word in a terminal cannot mutate the PDF
- // next to it. A FILE boot is one pane now, so that terminal is asked for
- // here rather than inherited from init.
- _ = try p.newPane(1);
- p.layoutAppendColumn(1);
- p.sync(); // rects for the new column, exactly as the two-pane boot did
- try std.testing.expect(!hasPdf(p.panes[1].?));
- p.runBuiltin(tint_builtin, 1, "", null);
- try std.testing.expectEqual(PdfTintMode.full, pv.tint);
-
- p.runBuiltin(tint_builtin, 0, "", null);
- try std.testing.expectEqual(PdfTintMode.disabled, pv.tint);
- const disabled_tag = try p.tagText(p.scratch.allocator(), pane);
- try std.testing.expect(std.mem.indexOf(u8, disabled_tag, "disabled PdfTint") != null);
- p.runBuiltin(tint_builtin, 0, "", null);
- try std.testing.expectEqual(PdfTintMode.filtered, pv.tint);
- try std.testing.expectEqual(PdfFitMode.width, pv.fit);
-
- // `dark` intentionally leaves page bg/fg null. PDF tint resolves those
- // deterministically to its chrome colors rather than host defaults.
- const dark_index = for (themes, 0..) |th, i| {
- if (std.mem.eql(u8, th.name, "dark")) break i;
- } else return error.MissingDarkTheme;
- p.setThemeIndex(dark_index);
- const colors = p.pdfTintColors();
- try std.testing.expectEqual(p.theme().tag_bg, colors.background);
- try std.testing.expectEqual(p.theme().tag_fg, colors.foreground);
-}
-
-test "PDF tint and tinted theme changes rebuild every visible raster only" {
- 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.?;
- p.native_images = true;
- var frame = std.heap.ArenaAllocator.init(gpa);
- defer frame.deinit();
-
- _ = try p.render(frame.allocator());
- const viewport = p.pdfViewport(pane).?;
- pv.document_scroll_y = @floatFromInt(pv.page_starts[1] -| viewport.pixel_h / 2);
- _ = frame.reset(.retain_capacity);
- const default_surface = try p.render(frame.allocator());
- try std.testing.expectEqual(PdfTintMode.filtered, pv.tint);
- try std.testing.expectEqual(@as(usize, 2), default_surface.nimages);
- try std.testing.expectEqual(@as(usize, 2), pv.rasters_len);
-
- const Snapshot = struct {
- page: u32,
- revision: u32,
- geometry: image.NativeGeometry,
- pixel_offset_y: f32,
- iw: usize,
- ih: usize,
- checksum: u64,
- };
- const Capture = struct {
- fn get(surface: *const Surface) ![2]Snapshot {
- if (surface.nimages != 2) return error.UnexpectedVisiblePdfCount;
- var out: [2]Snapshot = undefined;
- for (&out, 0..) |*snapshot, i| {
- const place = surface.images[i] orelse return error.MissingVisiblePdf;
- snapshot.* = .{
- .page = place.native.page,
- .revision = place.native.revision,
- .geometry = place.native.geometry orelse return error.MissingPdfGeometry,
- .pixel_offset_y = place.native.pixel_offset_y,
- .iw = place.iw,
- .ih = place.ih,
- .checksum = std.hash.Wyhash.hash(0x5044_4654_494e_5421, place.rgba),
- };
- }
- return out;
- }
-
- fn expectGeometry(before: [2]Snapshot, after: [2]Snapshot) !void {
- for (before, after) |old, new| {
- try std.testing.expectEqual(old.page, new.page);
- try std.testing.expectEqual(old.geometry, new.geometry);
- try std.testing.expectEqual(old.pixel_offset_y, new.pixel_offset_y);
- try std.testing.expectEqual(old.iw, new.iw);
- try std.testing.expectEqual(old.ih, new.ih);
- }
- }
- };
- const default_filtered = try Capture.get(default_surface);
- for (pv.rasters[0..pv.rasters_len]) |raster|
- try std.testing.expectEqual(PdfTintMode.filtered, raster.tint_key.?.mode);
-
- const dark_index = for (themes, 0..) |th, i| {
- if (std.mem.eql(u8, th.name, "dark")) break i;
- } else return error.MissingDarkTheme;
- p.setThemeIndex(dark_index);
- for (pv.rasters[0..pv.rasters_len]) |raster| try std.testing.expect(!raster.tried);
- _ = frame.reset(.retain_capacity);
- const themed_surface = try p.render(frame.allocator());
- const themed = try Capture.get(themed_surface);
- try Capture.expectGeometry(default_filtered, themed);
- for (default_filtered, themed) |old, new| {
- try std.testing.expect(new.revision > old.revision);
- try std.testing.expect(new.checksum != old.checksum);
- }
-
- // Chrome animation reads a separate palette. Its ticks must never disturb
- // the target-theme tint key or ask MuPDF for the same pixels again.
- try std.testing.expect(p.themeAnimationActive());
- for (0..animation.transition_steps) |_| {
- p.update(.tick);
- _ = frame.reset(.retain_capacity);
- const tick_surface = try p.render(frame.allocator());
- const ticked = try Capture.get(tick_surface);
- try Capture.expectGeometry(themed, ticked);
- for (themed, ticked) |once, after_tick| {
- try std.testing.expectEqual(once.revision, after_tick.revision);
- try std.testing.expectEqual(once.checksum, after_tick.checksum);
- }
- }
- try std.testing.expect(!p.themeAnimationActive());
-
- p.togglePdfTint(pane);
- try std.testing.expectEqual(PdfTintMode.full, pv.tint);
- _ = frame.reset(.retain_capacity);
- const full_surface = try p.render(frame.allocator());
- const full = try Capture.get(full_surface);
- try Capture.expectGeometry(themed, full);
- for (themed, full) |old, new| {
- try std.testing.expect(new.revision > old.revision);
- }
- for (pv.rasters[0..pv.rasters_len]) |raster|
- try std.testing.expectEqual(PdfTintMode.full, raster.tint_key.?.mode);
-
- p.togglePdfTint(pane);
- try std.testing.expectEqual(PdfTintMode.disabled, pv.tint);
- _ = frame.reset(.retain_capacity);
- const disabled_surface = try p.render(frame.allocator());
- const disabled = try Capture.get(disabled_surface);
- try Capture.expectGeometry(full, disabled);
- for (full, disabled) |old, source| {
- try std.testing.expect(source.revision > old.revision);
- try std.testing.expect(source.checksum != old.checksum);
- }
-
- const acme_index = for (themes, 0..) |th, i| {
- if (std.mem.eql(u8, th.name, "acme")) break i;
- } else return error.MissingAcmeTheme;
- for (pv.rasters[0..pv.rasters_len]) |raster| try std.testing.expect(raster.tried);
- p.setThemeIndex(acme_index);
- // Disabled keys ignore theme colors, so neither explicit invalidation nor
- // the per-raster key comparison asks MuPDF for replacement pixels.
- for (pv.rasters[0..pv.rasters_len]) |raster| try std.testing.expect(raster.tried);
- _ = frame.reset(.retain_capacity);
- const unchanged_surface = try p.render(frame.allocator());
- const unchanged = try Capture.get(unchanged_surface);
- try Capture.expectGeometry(disabled, unchanged);
- for (disabled, unchanged) |old, new| {
- try std.testing.expectEqual(old.revision, new.revision);
- try std.testing.expectEqual(old.checksum, new.checksum);
- }
- try std.testing.expectEqual(@as(usize, 2), pv.rasters_len);
-}
-
-test "PDF resize preserves page-relative document position" {
- 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.?;
- p.native_images = true;
- var frame = std.heap.ArenaAllocator.init(gpa);
- defer frame.deinit();
-
- p.setPdfPage(pane, 1);
- _ = try p.render(frame.allocator());
- const fraction: f64 = 0.375;
- pv.document_scroll_y = @as(f64, @floatFromInt(pv.page_starts[1])) +
- fraction * @as(f64, @floatFromInt(pv.page_heights[1]));
- p.update(.{ .resize = .{
- .cols = 100,
- .rows = 24,
- .cell_pixels = p.cell_pixels,
- } });
- try std.testing.expect(!pv.layout_valid);
- try std.testing.expect(pv.layout_anchor_pending);
- _ = frame.reset(.retain_capacity);
- _ = try p.render(frame.allocator());
- const resized_fraction = (pv.document_scroll_y -
- @as(f64, @floatFromInt(pv.page_starts[1]))) /
- @as(f64, @floatFromInt(pv.page_heights[1]));
- try std.testing.expectApproxEqAbs(fraction, resized_fraction, 0.000_001);
- try std.testing.expectEqual(@as(usize, 1), pv.page);
-
- const held = pv.document_scroll_y;
- p.update(.{ .resize = .{
- .cols = 100,
- .rows = 24,
- .cell_pixels = p.cell_pixels,
- } });
- try std.testing.expectEqual(held, pv.document_scroll_y);
- try std.testing.expect(pv.layout_valid);
-}
-
-test "PDF pane geometry change preserves its page-relative position" {
- 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();
- // A FILE boot is one pane now and a lone column always fills the window,
- // so the divider drag below needs a second column to take the width from.
- _ = try p.newPane(1);
- p.layoutAppendColumn(1);
- p.sync(); // rects for the new column, exactly as the two-pane boot did
- const pane = p.panes[0].?;
- const pv = &pane.pdf.?;
- p.native_images = true;
- var frame = std.heap.ArenaAllocator.init(gpa);
- defer frame.deinit();
-
- p.setPdfPage(pane, 1);
- _ = try p.render(frame.allocator());
- const fraction: f64 = 0.625;
- pv.document_scroll_y = @as(f64, @floatFromInt(pv.page_starts[1])) +
- fraction * @as(f64, @floatFromInt(pv.page_heights[1]));
- const old_width = pv.layout_viewport_w;
-
- // A divider/split changes rects through computeGeom without emitting a
- // shell resize. ensurePdfLayout must anchor against the old valid layout
- // before rebuilding it for this wider pane.
- p.col_weight[0] = 3;
- p.computeGeom();
- try std.testing.expect(p.pdfViewport(pane).?.pixel_w != old_width);
- try std.testing.expect(pv.layout_valid);
- _ = frame.reset(.retain_capacity);
- _ = try p.render(frame.allocator());
- const changed_fraction = (pv.document_scroll_y -
- @as(f64, @floatFromInt(pv.page_starts[1]))) /
- @as(f64, @floatFromInt(pv.page_heights[1]));
- try std.testing.expectApproxEqAbs(fraction, changed_fraction, 0.000_001);
- try std.testing.expectEqual(@as(usize, 1), pv.page);
-
- // An explicit pending reveal wins over an implicit layout anchor.
- pv.page = 2;
- pv.scroll_to_page_pending = true;
- p.col_weight[0] = 1;
- p.computeGeom();
- _ = frame.reset(.retain_capacity);
- _ = try p.render(frame.allocator());
- try std.testing.expectEqual(
- @as(f64, @floatFromInt(pv.page_starts[2])),
- pv.document_scroll_y,
- );
-}
-
-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);
-
- try 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_scroll = pv.document_scroll_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.document_scroll_y != first_scroll);
- 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;
- const max_document_scroll = @as(f64, @floatFromInt(
- pv.document_height -| p.pdfViewport(pane).?.pixel_h,
- ));
- pv.document_scroll_y = max_document_scroll;
- p.revealPdfSearch(pane, pv);
- const revealed_scroll = pv.document_scroll_y;
- try std.testing.expect(revealed_scroll != max_document_scroll);
- try std.testing.expectEqual(revision_before_reveal, pv.render_revision);
- pv.document_scroll_y = max_document_scroll;
- p.revealPdfSearch(pane, pv); // one shot: subsequent manual pan stays put
- try std.testing.expectEqual(max_document_scroll, pv.document_scroll_y);
- pv.document_scroll_y = revealed_scroll;
- _ = 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());
-
- // Unsupported text-selection actions are consumed by the PDF adapter:
- // they neither invent a range over placeholder cells nor disturb the
- // native MuPDF selection. The select-button's no-drag click still clears.
- p.update(.{ .key = .{ .cp = 'v' } });
- try std.testing.expect(pv.selection != null);
- try std.testing.expect(!pane.vsel.active);
- 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));
+test {
+ _ = @import("pdf_pane_integration_test.zig");
+ _ = @import("output_pane_integration_test.zig");
}
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.
+// Version-1 dumps originally persisted only the whole rendered tag. These were
+// the two canonical tails before New joined every pane; the compatibility
+// parser recognizes them as defaults while new dumps carry an explicit tail.
const legacy_pane_tail = " Del";
const legacy_file_pane_tail = " Save Del";
@@ -1377,338 +155,39 @@ const legacy_file_pane_tail = " Save Del";
// sits with the other two comptime folds (builtin_rows, the topbar check).
const Builtin = builtins.registry.Builtin();
-test "PDF builtins and leader paths follow the MuPDF feature gate" {
- const maybe_fit = std.meta.stringToEnum(Builtin, "PdfFit");
- const maybe_tint = std.meta.stringToEnum(Builtin, "PdfTint");
- const maybe_sections = std.meta.stringToEnum(Builtin, "PdfSections");
- try std.testing.expectEqual(pdf_enabled, maybe_fit != null);
- try std.testing.expectEqual(pdf_enabled, maybe_tint != null);
- try std.testing.expectEqual(pdf_enabled, maybe_sections != null);
- if (pdf_enabled) {
- const fit_path = config.leader_path.get(maybe_fit.?) orelse
- return error.MissingPdfFitLeaderPath;
- const tint_path = config.leader_path.get(maybe_tint.?) orelse
- return error.MissingPdfTintLeaderPath;
- const sections_path = config.leader_path.get(maybe_sections.?) orelse
- return error.MissingPdfSectionsLeaderPath;
- try std.testing.expectEqualStrings("tz", fit_path);
- try std.testing.expectEqualStrings("ti", tint_path);
- try std.testing.expectEqualStrings("ts", sections_path);
- const traits = output_pane.traits(.{ .cmd = maybe_sections.? });
- try std.testing.expectEqualStrings(config.pdf_sections_buffer, traits.name);
- try std.testing.expect(traits.steps);
- try std.testing.expect(!traits.commands);
- const help_row = for (builtin_rows) |row| {
- if (row.cmd == maybe_sections.?) break row;
- } else return error.MissingPdfSectionsHelpRow;
- try std.testing.expectEqualStrings("ts", help_row.path.?);
- try std.testing.expect(std.mem.indexOf(u8, help_row.line, "PdfSections") != null);
+/// The PDF integration suite lives beside the PDF implementation instead of
+/// making the core's first sixteen hundred lines pane-specific. These direct
+/// test-only calls reach the few intentionally private core transactions that
+/// the suite must observe; the declaration is empty in every non-test build.
+pub const pdf_test = if (@import("builtin").is_test) struct {
+ pub fn tagText(p: *Pardes, arena: std.mem.Allocator, pane: *Pane) ![]u8 {
+ return p.tagText(arena, pane);
}
-}
-test "PDF section rows preserve DFS ordinals and sanitise hierarchy" {
- if (!pdf_enabled) return;
- const E = pdf_impl.OutlineEntry;
- const entries = [_]E{
- .{ .depth = 0, .title = null, .is_open = true, .flags = 0, .color = @splat(0), .destination = .none },
- .{ .depth = 1, .title = " Child\n\tTitle\x01 Café \u{2028} Next ", .is_open = false, .flags = 0, .color = @splat(0), .destination = .{ .internal = .{ .page = 2, .x = 12, .y = 34 } } },
- .{ .depth = 0, .title = "", .is_open = false, .flags = 0, .color = @splat(0), .destination = .{ .internal = .{ .page = 0, .x = null, .y = null } } },
- .{ .depth = 0, .title = "External", .is_open = false, .flags = 0, .color = @splat(0), .destination = .{ .external = "https://example.com/manual" } },
- .{ .depth = 0, .title = "Dead branch", .is_open = false, .flags = 0, .color = @splat(0), .destination = .none },
- .{ .depth = 0, .title = "Unsafe", .is_open = false, .flags = 0, .color = @splat(0), .destination = .{ .external = "javascript:alert" } },
- .{ .depth = 0, .title = "Linked branch", .is_open = true, .flags = 0, .color = @splat(0), .destination = .none },
- .{ .depth = 1, .title = "Deep link", .is_open = false, .flags = 0, .color = @splat(0), .destination = .{ .external = "https://example.com/deep" } },
- };
- const rows = try PdfSectionRows.render(std.testing.allocator, "/tmp/manual.pdf", &entries);
- defer std.testing.allocator.free(rows);
- try std.testing.expectEqualStrings(
- "manual.pdf:3:1 [untitled]\n" ++
- "manual.pdf:3:2 Child Title Café Next\n" ++
- "manual.pdf:1:3 [empty title]\n" ++
- "https://example.com/manual External\n" ++
- "https://example.com/deep Linked branch\n" ++
- "https://example.com/deep Deep link\n",
- rows,
- );
- try std.testing.expect(PdfSectionRows.resolve(&entries, 4) == null);
- try std.testing.expect(PdfSectionRows.resolve(&entries, 5) == null);
- const resolved = try PdfSectionRows.resolveOrdinals(std.testing.allocator, &entries);
- defer std.testing.allocator.free(resolved);
- for (entries, 0..) |_, ordinal| {
- const single = PdfSectionRows.resolve(&entries, ordinal);
- if (resolved[ordinal] == std.math.maxInt(usize)) {
- try std.testing.expect(single == null);
- } else {
- const bulk = PdfSectionRows.usableDestination(entries[resolved[ordinal]].destination) orelse
- return error.MissingBulkPdfSectionDestination;
- try std.testing.expect(single != null);
- try std.testing.expect(std.meta.eql(single.?, bulk));
- }
+ pub fn runBuiltin(
+ p: *Pardes,
+ name: []const u8,
+ id: usize,
+ txt: []const u8,
+ arg: ?[]const u8,
+ ) bool {
+ const command = std.meta.stringToEnum(Builtin, name) orelse return false;
+ p.runBuiltin(command, id, txt, arg);
+ return true;
}
- // Every allocation site in the ordinal table and output growth remains
- // atomic: the testing allocator sees each induced failure cleaned up
- // before the first index at which the whole render can succeed.
- var rendered = false;
- for (0..64) |fail_index| {
- var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{
- .fail_index = fail_index,
- });
- const failure_gpa = failing.allocator();
- const attempt = PdfSectionRows.render(failure_gpa, "/tmp/manual.pdf", &entries) catch |err| {
- try std.testing.expectEqual(error.OutOfMemory, err);
- continue;
- };
- failure_gpa.free(attempt);
- rendered = true;
- break;
+ pub fn sync(p: *Pardes) void {
+ p.sync();
}
- try std.testing.expect(rendered);
-}
-test "bulk PDF section resolution matches single Look policy across DFS boundaries" {
- if (!pdf_enabled) return;
- const E = pdf_impl.OutlineEntry;
- const entries = [_]E{
- .{ .depth = 0, .title = "Resolved root", .is_open = true, .flags = 0, .color = @splat(0), .destination = .none },
- .{ .depth = 1, .title = "Unsafe branch", .is_open = true, .flags = 0, .color = @splat(0), .destination = .{ .external = "javascript:unsafe" } },
- .{ .depth = 2, .title = "Safe grandchild", .is_open = false, .flags = 0, .color = @splat(0), .destination = .{ .internal = .{ .page = 1, .x = 4, .y = 8 } } },
- .{ .depth = 1, .title = "Unresolved child", .is_open = false, .flags = 0, .color = @splat(0), .destination = .none },
- .{ .depth = 1, .title = "Sibling", .is_open = false, .flags = 0, .color = @splat(0), .destination = .{ .internal = .{ .page = 2, .x = null, .y = null } } },
- .{ .depth = 0, .title = "Unresolved root", .is_open = false, .flags = 0, .color = @splat(0), .destination = .none },
- .{ .depth = 0, .title = "Next root", .is_open = false, .flags = 0, .color = @splat(0), .destination = .{ .internal = .{ .page = 3, .x = null, .y = null } } },
- };
- const bulk = try PdfSectionRows.resolveOrdinals(std.testing.allocator, &entries);
- defer std.testing.allocator.free(bulk);
- const none = std.math.maxInt(usize);
- try std.testing.expectEqualSlices(usize, &.{ 2, none, 2, none, 4, none, 6 }, bulk);
- for (entries, 0..) |_, ordinal| {
- const single = PdfSectionRows.resolve(&entries, ordinal);
- if (bulk[ordinal] == none) {
- try std.testing.expect(single == null);
- } else {
- const destination = PdfSectionRows.usableDestination(entries[bulk[ordinal]].destination) orelse
- return error.MissingBoundaryPdfSectionDestination;
- try std.testing.expect(single != null);
- try std.testing.expect(std.meta.eql(single.?, destination));
- }
+ pub fn searchStep(p: *Pardes, id: usize, delta: i32) bool {
+ return p.searchStep(id, delta);
}
-}
-
-test "PdfSections output, Look, and n/N share exact cached outline destinations" {
- if (!pdf_enabled or platform == .web) return;
- const gpa = std.testing.allocator;
- var tmp = std.testing.tmpDir(.{});
- defer tmp.cleanup();
- const fixture = try pdf_impl.makeOutlineTestPdf(gpa);
- defer gpa.free(fixture);
- try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "outline.pdf", .data = fixture });
- var path_buf: [256]u8 = undefined;
- const path = try std.fmt.bufPrint(&path_buf, ".zig-cache/tmp/{s}/outline.pdf", .{tmp.sub_path});
-
- const p = try Pardes.init(gpa, .{ .file = path, .cols = 80, .rows = 28 });
- defer p.deinit();
- // A FILE boot is one pane now, so the launch shell this leans on twice has
- // to be asked for: an empty slot 1 would satisfy the inertness check below
- // for the wrong reason (runBuiltin drops a null pane before any builtin
- // runs) AND would then be handed to the +PdfSections output, which is the
- // one pane the look at the end must NOT come from.
- _ = try p.newPane(1);
- p.layoutAppendColumn(1);
- p.sync(); // rects for the new column, exactly as the two-pane boot did
- while (p.nextEffect()) |_| {}
- const pdf_pane = p.panes[0].?;
- const sections = std.meta.stringToEnum(Builtin, "PdfSections") orelse
- return error.MissingPdfSectionsBuiltin;
- // Pane-scoped: a shell invocation is inert; the PDF invocation opens one
- // output immediately below it and leaves focus/step ownership on the PDF.
- p.runBuiltin(sections, 1, "", null);
- try std.testing.expect(pdf_pane.search_pane == null);
- p.runBuiltin(sections, 0, "", null);
- const output_id = pdf_pane.search_pane orelse return error.MissingPdfSectionsOutput;
- const output = p.panes[output_id].?;
- try std.testing.expectEqual(@as(usize, 0), p.active);
- const pdf_layout = p.layoutFindTerm(0) orelse return error.MissingPdfLayout;
- const output_layout = p.layoutFindTerm(output_id) orelse return error.MissingPdfSectionsLayout;
- try std.testing.expectEqual(pdf_layout.col, output_layout.col);
- try std.testing.expectEqual(pdf_layout.idx + 1, output_layout.idx);
- try std.testing.expect(Pardes.isPdfSectionsOutput(output));
- try std.testing.expect(std.mem.endsWith(u8, output.file.?.path, "/+PdfSections"));
- const tag = try p.tagText(p.scratch.allocator(), pdf_pane);
- try std.testing.expect(std.mem.indexOf(u8, tag, "PdfSections") != null);
-
- const expected = try std.fmt.allocPrint(
- gpa,
- "{s}:2:1 [untitled]\n" ++
- "{s}:2:2 Café 子\n" ++
- "{s}:3:3 [empty title]\n" ++
- "https://example.com/manual External\n",
- .{ "outline.pdf", "outline.pdf", "outline.pdf" },
- );
- defer gpa.free(expected);
- try std.testing.expectEqualStrings(expected, output.file.?.content);
-
- // The outline is loaded once and a clean live output is reused in place.
- try std.testing.expect(pdf_pane.pdf.?.outline_tried);
- const outline_ptr = pdf_pane.pdf.?.outline.?.entries.ptr;
- const output_serial = output.serial;
- const output_revision = output.file.?.revision;
- p.runBuiltin(sections, 0, "", null);
- try std.testing.expectEqual(output_id, pdf_pane.search_pane.?);
- try std.testing.expectEqual(output_serial, p.panes[output_id].?.serial);
- try std.testing.expectEqual(output_revision, p.panes[output_id].?.file.?.revision);
- try std.testing.expectEqual(outline_ptr, pdf_pane.pdf.?.outline.?.entries.ptr);
-
- // The clean remembered path is a pure re-arm: even an allocator which
- // rejects its first request is never consulted, and the outline/content
- // identities remain untouched.
- var failing = std.testing.FailingAllocator.init(gpa, .{ .fail_index = 0 });
- const ordinary_gpa = p.gpa;
- p.gpa = failing.allocator();
- p.openPdfSections(0);
- p.gpa = ordinary_gpa;
- try std.testing.expectEqual(@as(usize, 0), failing.alloc_index);
- try std.testing.expect(!failing.has_induced_failure);
- try std.testing.expectEqual(outline_ptr, pdf_pane.pdf.?.outline.?.entries.ptr);
- try std.testing.expectEqual(output_revision, p.panes[output_id].?.file.?.revision);
-
- // n SELECTS the generated row and Enter runs it through ordinary Look, so
- // the pair is one step of what used to be one keypress. The look is made
- // FROM the sections buffer, which is what keeps that buffer at the head of
- // the walk: the next n resumes there even though the jump left focus on
- // the PDF. The root and child intentionally resolve to the same exact XYZ
- // destination.
- p.active = 0;
- p.update(.{ .key = .{ .cp = 'n' } });
- try std.testing.expectEqual(output_id, p.active);
- try std.testing.expectEqual(@as(i32, 0), output.cur_row);
- p.update(.{ .key = .{ .cp = Key.enter } });
- try std.testing.expectEqual(@as(usize, 1), pdf_pane.pdf.?.page);
- const child_destination = pdf_pane.pdf.?.outline.?.entries[1].destination.internal;
- const expected_y = @as(f64, @floatFromInt(pdf_pane.pdf.?.page_starts[1])) +
- @as(f64, child_destination.y.?) / @as(f64, pdf_pane.pdf.?.page_sizes[1].height) *
- @as(f64, @floatFromInt(pdf_pane.pdf.?.page_heights[1]));
- try std.testing.expectApproxEqAbs(expected_y, pdf_pane.pdf.?.document_scroll_y, 0.001);
- p.update(.{ .key = .{ .cp = 'n' } });
- try std.testing.expectEqual(@as(i32, 1), output.cur_row);
- p.update(.{ .key = .{ .cp = Key.enter } });
- try std.testing.expectApproxEqAbs(expected_y, pdf_pane.pdf.?.document_scroll_y, 0.001);
- p.update(.{ .key = .{ .cp = 'n' } });
- p.update(.{ .key = .{ .cp = Key.enter } });
- try std.testing.expectEqual(@as(usize, 2), pdf_pane.pdf.?.page);
- // ...and N is the exact inverse of n: the row it walks back onto is the
- // one n came from, and looking it lands on the same destination again.
- p.update(.{ .key = .{ .cp = 'N' } });
- try std.testing.expectEqual(@as(i32, 1), output.cur_row);
- p.update(.{ .key = .{ .cp = Key.enter } });
- try std.testing.expectEqual(@as(usize, 1), pdf_pane.pdf.?.page);
- try std.testing.expectApproxEqAbs(expected_y, pdf_pane.pdf.?.document_scroll_y, 0.001);
-
- // Re-looking the same ordinal changes placement only, never pixel identity.
- p.native_images = true;
- var frame = std.heap.ArenaAllocator.init(gpa);
- defer frame.deinit();
- _ = try p.render(frame.allocator());
- const revision = pdf_pane.pdf.?.render_revision;
- const child_row = modal.lineSlice(output.file.?.content, 1);
- const child_target = child_row[0 .. std.mem.indexOfScalar(u8, child_row, ' ') orelse child_row.len];
- p.lookAt(output_id, child_target);
- try std.testing.expectEqual(revision, pdf_pane.pdf.?.render_revision);
- _ = frame.reset(.retain_capacity);
- _ = try p.render(frame.allocator());
- try std.testing.expectEqual(revision, pdf_pane.pdf.?.render_revision);
-
- // The same path:page:col token from any non-PdfSections pane retains the
- // established PDF-search meaning for col; no outline reveal is installed.
- try p.setPdfSearchQuery(&pdf_pane.pdf.?, "ordinary search semantics");
- p.lookAt(1, child_target);
- try std.testing.expectEqual(@as(usize, 1), pdf_pane.pdf.?.search_hit);
- try std.testing.expect(pdf_pane.pdf.?.outline_reveal_pending == null);
- try p.setPdfSearchQuery(&pdf_pane.pdf.?, "");
-
- // Step forward onto the external row: two n's reach it, Enter takes the
- // existing open_link effect, and a URL look moves focus nowhere — so the
- // walk is still standing in the buffer, ready for the next n.
- //
- // Asked FROM the sections buffer rather than from the PDF, because the two
- // direct lookAt calls just above put the shell at the head of the ring and
- // this is a test about the rows, not about the ordering.
- p.active = output_id;
- p.update(.{ .key = .{ .cp = 'n' } });
- p.update(.{ .key = .{ .cp = 'n' } });
- try std.testing.expectEqual(@as(i32, 3), output.cur_row);
- p.update(.{ .key = .{ .cp = Key.enter } });
- var opened = false;
- while (p.nextEffect()) |effect| switch (effect) {
- .open_link => |uri| opened = std.mem.eql(u8, uri.slice(), "https://example.com/manual"),
- else => {},
- };
- try std.testing.expect(opened);
- try std.testing.expectEqual(output_id, p.active);
-
- // Missing/hostile coordinates clamp to page space. x affects only a
- // layout with horizontal overflow and still does not touch raster state.
- const saved_size = pdf_pane.pdf.?.page_sizes[1];
- pdf_pane.pdf.?.fit = .width;
- pdf_pane.pdf.?.layout_valid = false;
- pdf_pane.pdf.?.pan_x = 123;
- p.revealPdfOutlineDestination(pdf_pane, .{ .page = 1, .x = 190, .y = null });
- try std.testing.expectEqual(@as(u16, 123), pdf_pane.pdf.?.pan_x);
- try std.testing.expectEqual(@as(f64, @floatFromInt(pdf_pane.pdf.?.page_starts[1])), pdf_pane.pdf.?.document_scroll_y);
- pdf_pane.pdf.?.page_sizes[1] = .{ .width = 400, .height = 100 };
- pdf_pane.pdf.?.fit = .height;
- pdf_pane.pdf.?.layout_valid = false;
- pdf_pane.pdf.?.pan_x = 0;
- p.revealPdfOutlineDestination(pdf_pane, .{ .page = 1, .x = 390, .y = -500 });
- try std.testing.expect(pdf_pane.pdf.?.pan_x > 0);
- try std.testing.expectEqual(@as(f64, @floatFromInt(pdf_pane.pdf.?.page_starts[1])), pdf_pane.pdf.?.document_scroll_y);
- p.revealPdfOutlineDestination(pdf_pane, .{ .page = 1, .x = std.math.inf(f32), .y = std.math.inf(f32) });
- try std.testing.expectEqual(@as(f64, @floatFromInt(pdf_pane.pdf.?.page_starts[1])), pdf_pane.pdf.?.document_scroll_y);
- try std.testing.expectEqual(revision, pdf_pane.pdf.?.render_revision);
- pdf_pane.pdf.?.page_sizes[1] = saved_size;
-
- // An edited output is preserved. Closing the replacement and reusing its
- // slot for an unrelated pane cannot trick the remembered id into a refill.
- file_pane.setContent(p, &output.file.?, try gpa.dupe(u8, "edited by user\n"));
- p.openPdfSections(0);
- const replacement_id = pdf_pane.search_pane orelse return error.MissingReplacementSections;
- try std.testing.expect(replacement_id != output_id);
- try std.testing.expectEqualStrings("edited by user\n", output.file.?.content);
- const replacement_serial = p.panes[replacement_id].?.serial;
- p.runBuiltin(.Del, replacement_id, "", null);
- try std.testing.expect(p.panes[replacement_id] == null);
- const unrelated = try p.openTutorView(replacement_id);
- p.placeDoc(0, replacement_id, unrelated);
- try std.testing.expect(unrelated.serial != replacement_serial);
- p.openPdfSections(0);
- try std.testing.expect(pdf_pane.search_pane.? != replacement_id);
- try std.testing.expect(Pardes.isPdfSectionsOutput(p.panes[pdf_pane.search_pane.?].?));
-}
-
-test "PdfSections opens an empty cached output for a PDF without an outline" {
- if (!pdf_enabled or platform == .web) return;
- const gpa = std.testing.allocator;
- var tmp = std.testing.tmpDir(.{});
- defer tmp.cleanup();
- const fixture = try pdf_impl.makeNoOutlineTestPdf(gpa);
- defer gpa.free(fixture);
- try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "plain.pdf", .data = fixture });
- var path_buf: [256]u8 = undefined;
- const path = try std.fmt.bufPrint(&path_buf, ".zig-cache/tmp/{s}/plain.pdf", .{tmp.sub_path});
- const p = try Pardes.init(gpa, .{ .file = path });
- defer p.deinit();
- p.openPdfSections(0);
- const pane = p.panes[0].?;
- const output_id = pane.search_pane orelse return error.MissingEmptyPdfSections;
- try std.testing.expectEqual(@as(usize, 0), p.panes[output_id].?.file.?.content.len);
- try std.testing.expect(pane.pdf.?.outline_tried);
- try std.testing.expectEqual(@as(usize, 0), pane.pdf.?.outline.?.entries.len);
- const serial = p.panes[output_id].?.serial;
- p.openPdfSections(0);
- try std.testing.expectEqual(output_id, pane.search_pane.?);
- try std.testing.expectEqual(serial, p.panes[output_id].?.serial);
-}
+ pub fn heldSelection(p: *Pardes, first: usize) ?[]const u8 {
+ return p.heldSelection(first);
+ }
+} else struct {};
fn nextPipeEffect(p: *Pardes) ?u32 {
while (p.nextEffect()) |effect| switch (effect) {
@@ -1896,7 +375,7 @@ test "startup config runs builtin lines in order and isolates bad lines" {
// 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.themeAnimationActive());
+ try std.testing.expect(!p.animationActive());
try std.testing.expectEqual(ChromeTheme.fromTheme(p.theme()), p.chromeTheme().*);
try std.testing.expect(!p.quit);
for (0..p.effects_len) |i| switch (p.effects[(p.effects_head + i) % p.effects.len]) {
@@ -1908,48 +387,26 @@ test "startup config runs builtin lines in order and isolates bad lines" {
};
}
-test "Config prints the resolved startup config path and refreshes one buffer" {
- const path = "/home/pardes-test/.config/pardes";
- // `startup_config` stays null: the file is MISSING and the path still
- // resolves, which is the case this builtin exists to answer.
- const p = try Pardes.init(std.testing.allocator, .{ .startup_config_path = path });
- defer p.deinit();
-
- try std.testing.expect(p.executeBuiltinLine(0, "Config"));
- const opened = p.active;
- const out = p.panes[opened].?.file.?;
- try std.testing.expectEqualStrings(path ++ "\n", out.content);
- try std.testing.expectEqualStrings(config.config_buffer, std.fs.path.basename(out.path));
- try std.testing.expectEqual(output_pane.Origin{ .cmd = .Config }, out.output.?.from);
-
- // Asking again refreshes the buffer already open rather than stacking a
- // byte-identical twin beside it — Help's rule, and for the same reason.
- try std.testing.expect(p.executeBuiltinLine(0, "Config"));
- try std.testing.expectEqual(opened, p.active);
- var buffers: usize = 0;
- for (p.panes) |slot| {
- const sp = slot orelse continue;
- const f = sp.file orelse continue;
- const o = f.output orelse continue;
- if (std.meta.eql(o.from, output_pane.Origin{ .cmd = .Config })) buffers += 1;
- }
- try std.testing.expectEqual(@as(usize, 1), buffers);
-}
-
-test "Config says so when there is no per-user config path" {
- const p = try Pardes.init(std.testing.allocator, .{});
+test "scene effects keep the idle frontend animation clock active" {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true });
defer p.deinit();
+ try std.testing.expect(!p.animationActive());
- try std.testing.expect(p.executeBuiltinLine(0, "Config"));
- const out = p.panes[p.active].?.file.?;
- try std.testing.expect(std.mem.indexOf(u8, out.content, "no per-user config path") != null);
+ p.settings.scene_effects.crt = true;
+ try std.testing.expect(p.animationActive());
+ p.settings.scene_effects = .{ .ripple = true };
+ try std.testing.expect(p.animationActive());
+ p.settings.scene_effects = .{ .glitch = true };
+ try std.testing.expect(p.animationActive());
+ p.settings.scene_effects = .{};
+ try std.testing.expect(!p.animationActive());
}
test "runtime theme changes animate chrome and retarget without a jump" {
const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true });
defer p.deinit();
const first = p.chromeTheme().*;
- try std.testing.expect(!p.themeAnimationActive());
+ try std.testing.expect(!p.animationActive());
const dark_index = for (themes, 0..) |th, i| {
if (std.mem.eql(u8, th.name, "dark")) break i;
@@ -1963,7 +420,7 @@ test "runtime theme changes animate chrome and retarget without a jump" {
// the exact previous frame.
try std.testing.expectEqualStrings("dark", p.theme().name);
try std.testing.expectEqual(first, p.chromeTheme().*);
- try std.testing.expect(p.themeAnimationActive());
+ try std.testing.expect(p.animationActive());
p.update(.tick);
p.update(.tick);
p.update(.tick);
@@ -1973,9 +430,9 @@ test "runtime theme changes animate chrome and retarget without a jump" {
p.setThemeIndex(acme_index);
try std.testing.expectEqualStrings("acme", p.theme().name);
try std.testing.expectEqual(midflight, p.chromeTheme().*);
- try std.testing.expect(p.themeAnimationActive());
+ try std.testing.expect(p.animationActive());
for (0..animation.transition_steps) |_| p.update(.tick);
- try std.testing.expect(!p.themeAnimationActive());
+ try std.testing.expect(!p.animationActive());
try std.testing.expectEqual(ChromeTheme.fromTheme(p.theme()), p.chromeTheme().*);
// Extra ticks are inert at the exact endpoint.
p.update(.tick);
@@ -1999,15 +456,30 @@ test "render uses target content colors while chrome is in flight" {
Color{ .rgb = source_chrome.tag_bg },
surface.at(0, 0).style.bg,
);
+ const topbar_cell = surface.at(0, 0);
+ try std.testing.expect(!std.mem.eql(u8, topbar_cell.grapheme(), " "));
+ try std.testing.expectEqual(FontRole.tagline, topbar_cell.style.font_role);
+ const r = p.rects[0];
+ const tag_cell = find_tag_cell: {
+ var x = r.x + config.GUTTER;
+ while (x < r.x + r.w) : (x += 1) {
+ const cell = surface.at(x, r.y);
+ if (!std.mem.eql(u8, cell.grapheme(), " ")) break :find_tag_cell cell;
+ }
+ return error.MissingTagText;
+ };
+ try std.testing.expectEqual(FontRole.tagline, tag_cell.style.font_role);
+ try std.testing.expectEqual(FontRole.tagline, surface.at(r.x, r.y).style.font_role);
+ try std.testing.expectEqual(FontRole.tagline, surface.at(r.x + r.w - 1, r.y).style.font_role);
// The document/terminal page does not ride the chrome animation: acme's
// target background is already present in the same render.
- const r = p.rects[0];
const body = surface.at(r.x + r.w - 1, r.y + r.h - 1);
+ try std.testing.expectEqual(FontRole.body, body.style.font_role);
try std.testing.expectEqual(
Color{ .rgb = p.theme().bg.? },
body.style.bg,
);
- try std.testing.expect(p.themeAnimationActive());
+ try std.testing.expect(p.animationActive());
}
test "pane-tag Exec prefers New and argument builtins before shell fallback" {
@@ -2015,6 +487,7 @@ test "pane-tag Exec prefers New and argument builtins before shell fallback" {
const p = try Pardes.init(gpa, .{ .cols = 100, .rows = 30 });
defer p.deinit();
while (p.nextEffect()) |_| {} // initial shell spawn/geometry
+ p.acknowledgeShell(0, "/bin/sh", false);
const pane = p.panes[0].?;
try std.testing.expectEqualStrings(" New Del", Pardes.curTail(pane));
@@ -2165,6 +638,17 @@ test "legacy default tag tails upgrade while custom tails remain owned" {
try std.testing.expect(terminal.tag_init);
try std.testing.expectEqualStrings(" Keep Del", Pardes.curTail(terminal));
+ // This happens to be the historical FILE default, but on a terminal it is
+ // user-owned text and must not be swallowed by migration.
+ terminal.tag_tail_len = 0;
+ terminal.tag_init = false;
+ const terminal_fileish = try std.fmt.allocPrint(p.scratch.allocator(), "{s} Save Del", .{
+ try p.tagPrefix(terminal),
+ });
+ p.restoreTail(terminal, terminal_fileish);
+ try std.testing.expect(terminal.tag_init);
+ try std.testing.expectEqualStrings(" Save Del", Pardes.curTail(terminal));
+
// A savable file has a distinct old default. Save remains first after the
// migration so the tag's established `:w<Tab>` route is unchanged.
const file = try p.hxOpenFileContent("");
@@ -2182,6 +666,30 @@ test "legacy default tag tails upgrade while custom tails remain owned" {
p.restoreTail(file, file_custom);
try std.testing.expect(file.tag_init);
try std.testing.expectEqualStrings(" Save Mine Del", Pardes.curTail(file));
+
+ // Conversely the generic pane's current default is a custom edit on a
+ // savable file. Legacy recognition is scoped to the source pane class.
+ file.tag_tail_len = 0;
+ file.tag_init = false;
+ const file_generic = try std.fmt.allocPrint(p.scratch.allocator(), "{s} New Del", .{
+ try p.tagPrefix(file),
+ });
+ p.restoreTail(file, file_generic);
+ try std.testing.expect(file.tag_init);
+ try std.testing.expectEqualStrings(" New Del", Pardes.curTail(file));
+
+ // Before renderer choices appeared in the live prefix, image dumps began
+ // with only `img PATH`. Their custom tails still migrate through the
+ // pane-specific legacy-prefix recognizer.
+ const image_doc = try image_pane.create(p, 1, "/tmp/legacy image.ppm", &.{});
+ p.restoreDumpTail(image_doc, .{
+ .kind = .image,
+ .tag = "img /tmp/legacy image.ppm Keep Del",
+ .body = "",
+ .image = .{ .path = "/tmp/legacy image.ppm" },
+ });
+ try std.testing.expect(image_doc.tag_init);
+ try std.testing.expectEqualStrings(" Keep Del", image_doc.tagSlice());
}
test "unknown Exec from an image writes to a terminal in the image directory" {
@@ -2189,12 +697,12 @@ test "unknown Exec from an image writes to a terminal in the image directory" {
const p = try Pardes.init(gpa, .{});
defer p.deinit();
while (p.nextEffect()) |_| {}
+ p.acknowledgeShell(0, "/bin/sh", false);
p.setCwd(0, "/tmp/pardes-image-dir");
- const image_pane = try p.newDocPane(1);
- image_pane.image = .{ .path = try gpa.dupe(u8, "/tmp/pardes-image-dir/pic.ppm") };
- image_pane.kind = .image;
- try std.testing.expectEqualStrings("/tmp/pardes-image-dir", Pardes.paneDir(image_pane));
+ const image_doc = try p.newDocPane(1);
+ image_doc.image = .{ .path = try gpa.dupe(u8, "/tmp/pardes-image-dir/pic.ppm") };
+ try std.testing.expectEqualStrings("/tmp/pardes-image-dir", Pardes.paneDir(image_doc));
try std.testing.expectEqual(@as(?usize, 0), p.execute(1, "echo image-fallback"));
var sent: [256]u8 = undefined;
@@ -2266,9 +774,72 @@ test "Exec in a terminal whose tty is taken spawns a shell instead of typing at
try std.testing.expectEqual(@as(u8, @intCast(dst)), sp.pane);
// ...in the directory the command was about, which is the taken pane's own
try std.testing.expectEqualStrings("/tmp/pardes-taken", sp.cwd.slice());
+ // A plain/unsupported shell has no OSC 133 B to wait for. Successful
+ // fork acknowledgement opens the gate and the pty itself buffers input
+ // until that child reads it.
+ try std.testing.expectEqual(@as(usize, 0), sent_len);
+ p.acknowledgeShell(dst, "/bin/sh", false);
+ while (p.nextEffect()) |effect| switch (effect) {
+ .write => |w| {
+ try std.testing.expectEqual(@as(u8, @intCast(dst)), w.pane);
+ @memcpy(sent[sent_len..][0..w.bytes.slice().len], w.bytes.slice());
+ sent_len += w.bytes.slice().len;
+ },
+ else => {},
+ };
try std.testing.expectEqualStrings("echo taken-fallback\r", sent[0..sent_len]);
}
+test "image dump restores source bytes renderer choices and exact custom tail" {
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .tty_only = true });
+ defer p.deinit();
+ p.deinitPane(p.panes[0].?);
+ p.panes[0] = null;
+
+ const source = "\x00embedded image bytes\xff";
+ const raw = try p.image_gpa.dupe(u8, source);
+ const pane = image_pane.create(p, 0, "/missing/restored-image.ppm", raw) catch |err| {
+ p.image_gpa.free(raw);
+ return err;
+ };
+ pane.image.?.petscii = true;
+ pane.image.?.pmode = .terminal;
+ pane.image.?.ascii = false;
+ try std.testing.expect(pane.appendTag(" Keep Del"));
+ pane.tag_init = true;
+ try p.dumpState();
+
+ const first = try dump.readZon(gpa, p.dump_out.?, "image-first-dump");
+ defer dump.free(gpa, first);
+ try std.testing.expect(first.panes[0].image.?.petscii);
+ try std.testing.expectEqual(dump.ImagePalette.terminal, first.panes[0].image.?.palette);
+ try std.testing.expect(!first.panes[0].image.?.ascii);
+ try std.testing.expectEqualStrings(" Keep Del", first.panes[0].tag_tail.?);
+
+ const restored = try Pardes.initFromDump(gpa, .{ .tty_only = true }, p.dump_out.?);
+ defer restored.deinit();
+ const restored_pane = restored.panes[0].?;
+ try std.testing.expect(restored_pane.image.?.petscii);
+ try std.testing.expectEqual(image.PaletteMode.terminal, restored_pane.image.?.pmode);
+ try std.testing.expect(!restored_pane.image.?.ascii);
+ try std.testing.expect(restored_pane.tag_init);
+ try std.testing.expectEqualStrings(" Keep Del", restored_pane.tagSlice());
+ try std.testing.expectEqualSlices(u8, source, restored_pane.image.?.raw);
+
+ try restored.dumpState();
+ const redump = try dump.readZon(gpa, restored.dump_out.?, "image-redump");
+ defer dump.free(gpa, redump);
+ const encoded = redump.panes[0].image.?.bytes_b64;
+ const decoded = try dump.decodeBytes(gpa, encoded);
+ defer gpa.free(decoded);
+ try std.testing.expectEqualSlices(u8, source, decoded);
+ try std.testing.expect(redump.panes[0].image.?.petscii);
+ try std.testing.expectEqual(dump.ImagePalette.terminal, redump.panes[0].image.?.palette);
+ try std.testing.expect(!redump.panes[0].image.?.ascii);
+ try std.testing.expectEqualStrings(" Keep Del", redump.panes[0].tag_tail.?);
+}
+
test "Exec from a document pane skips an occupied terminal in its directory and spawns" {
// The test above this pair ("unknown Exec from an image...") is the same
// setup with the terminal at its prompt, and it reuses pane 0. The single
@@ -2279,9 +850,8 @@ test "Exec from a document pane skips an occupied terminal in its directory and
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") };
- image_pane.kind = .image;
+ const image_doc = try p.newDocPane(1);
+ image_doc.image = .{ .path = try gpa.dupe(u8, "/tmp/pardes-image-dir/pic.ppm") };
var host: FakeTtyQuery = .{};
host.install(p);
host.taken[0] = true;
@@ -2305,6 +875,16 @@ test "Exec from a document pane skips an occupied terminal in its directory and
};
const sp = (spawned orelse return error.NoShellForTheOccupiedTerminal).spawn;
try std.testing.expectEqualStrings("/tmp/pardes-image-dir", sp.cwd.slice());
+ try std.testing.expectEqual(@as(usize, 0), sent_len);
+ p.acknowledgeShell(dst, "/bin/sh", false);
+ while (p.nextEffect()) |effect| switch (effect) {
+ .write => |w| {
+ try std.testing.expectEqual(@as(u8, @intCast(dst)), w.pane);
+ @memcpy(sent[sent_len..][0..w.bytes.slice().len], w.bytes.slice());
+ sent_len += w.bytes.slice().len;
+ },
+ else => {},
+ };
try std.testing.expectEqualStrings("echo image-fallback\r", sent[0..sent_len]);
}
@@ -2355,6 +935,7 @@ test "a terminal its program gave back takes command lines again" {
const p = try Pardes.init(gpa, .{});
defer p.deinit();
while (p.nextEffect()) |_| {}
+ p.acknowledgeShell(0, "/bin/sh", false);
p.setCwd(0, "/tmp/pardes-taken-flip");
var host: FakeTtyQuery = .{};
@@ -2599,7 +1180,7 @@ test "Msg writes the transient row by hand, bare or with text, and input ends it
try std.testing.expectEqual(p.panes[2].?.msg.len, row(p, 2).len);
}
-test "Font records the face it chose, which is the name the Debug overlay prints" {
+test "Font keeps requested and acknowledged faces as separate plain state" {
if (comptime !font_picker) return; // there is no Font builtin on tty or web
const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24 });
defer p.deinit();
@@ -2608,25 +1189,61 @@ test "Font records the face it chose, which is the name the Debug overlay prints
const installed = fonts.list(arena.allocator(), null);
if (installed.len == 0) return; // a machine with no monospace face at all
- // module state, and the shell takes it on its next pass: leave neither the
- // request nor the name behind for whatever test runs after this one
- defer {
- fonts.want = null;
- fonts.chosen = "";
- }
var line: [512]u8 = undefined;
const cmd = try std.fmt.bufPrint(&line, "Font {s}", .{installed[0].name});
try std.testing.expect(p.executeBuiltinLine(p.active, cmd));
- // the PATH is what the shell needs and the NAME is what a person reads;
- // one is the other's stem, so the two can never name different files
- try std.testing.expect(fonts.want != null);
- try std.testing.expectEqualStrings(installed[0].name, fonts.chosen);
- try std.testing.expectEqualStrings(fonts.chosen, std.fs.path.stem(fonts.want.?));
+ try std.testing.expect(p.settings.font.pending);
+ try std.testing.expectEqualStrings(installed[0].name, p.settings.font.requested_name.get());
+ try std.testing.expectEqualStrings(installed[0].path, p.settings.font.requested_path.get());
+ try std.testing.expectEqualStrings("", p.settings.font.effective_name.get());
- // ...and a name nothing answers to changes neither, so the overlay goes on
- // naming the face that is actually on
+ // Boot/zoom/display-scale reports describe the face already being drawn;
+ // they must neither answer nor reject the new request waiting beside it.
+ try std.testing.expect(p.observeFont("Embedded Mono", 1200, .pixels));
+ try std.testing.expectEqualStrings("Embedded Mono", p.settings.font.effective_name.get());
+ try std.testing.expect(p.settings.font.pending);
+ try std.testing.expect(!p.acknowledgeFont("Stray Ack", 1250, .pixels));
+ p.rejectFont();
+ try std.testing.expect(p.settings.font.pending);
+
+ const request = p.takeFontRequest() orelse return error.MissingFontRequest;
+ try std.testing.expectEqualStrings(installed[0].path, request);
+ try std.testing.expect(p.settings.font.pending);
+ try std.testing.expect(p.takeFontRequest() == null);
+ const fake_track: panel_animation.Track = .{
+ .serial = p.panes[p.active].?.serial,
+ .pane = @intCast(p.active),
+ .effect = .slide,
+ .from = .{ .w = 20, .h = 10 },
+ .to = .{ .x = 10, .w = 20, .h = 10 },
+ };
+ p.panel_tracks[p.active] = fake_track;
+ p.presented_panel_tracks[p.active] = fake_track;
+ p.panel_presentation_ready = true;
+ try std.testing.expect(p.acknowledgeFont(installed[0].name, 1375, .pixels));
+ try std.testing.expect(!p.settings.font.pending);
+ try std.testing.expectEqualStrings(installed[0].name, p.settings.font.effective_name.get());
+ try std.testing.expectEqual(@as(u16, 1375), p.settings.font.effective_size_hundredths);
+ try std.testing.expectEqual(FontSizeUnit.pixels, p.settings.font.effective_size_unit);
+ try std.testing.expect(p.panel_tracks[p.active] == null);
+ try std.testing.expect(p.panel_presentation_pending);
+ try std.testing.expect(!p.acknowledgeFont("Duplicate Ack", 1400, .pixels));
+
+ // Asking for the face already on screen is still a real host round trip,
+ // but it does not invalidate the frozen layer: its effective tuple did
+ // not change. A rejected request likewise never reaches acknowledgeFont.
+ p.acknowledgePanelPresentation(&.{});
+ try std.testing.expect(p.executeBuiltinLine(p.active, cmd));
+ _ = p.takeFontRequest() orelse return error.MissingRepeatedFontRequest;
+ p.panel_tracks[p.active] = fake_track;
+ try std.testing.expect(p.acknowledgeFont(installed[0].name, 1375, .pixels));
+ try std.testing.expect(p.panel_tracks[p.active] != null);
+ try std.testing.expect(!p.panel_presentation_pending);
+
+ // A name nothing answers to changes neither request nor effective state.
try std.testing.expect(p.executeBuiltinLine(p.active, "Font zzz-no-such-face"));
- try std.testing.expectEqualStrings(installed[0].name, fonts.chosen);
+ try std.testing.expectEqualStrings(installed[0].name, p.settings.font.requested_name.get());
+ try std.testing.expectEqualStrings(installed[0].name, p.settings.font.effective_name.get());
}
test "startup config overrides restored styling before initFromDump returns" {
@@ -2642,12 +1259,72 @@ test "startup config overrides restored styling before initFromDump returns" {
);
defer restored.deinit();
try std.testing.expectEqualStrings("acme", restored.theme().name);
- try std.testing.expect(!source.themeAnimationActive());
- try std.testing.expect(!restored.themeAnimationActive());
+ try std.testing.expect(!source.animationActive());
+ try std.testing.expect(!restored.animationActive());
try std.testing.expectEqual(ChromeTheme.fromTheme(source.theme()), source.chromeTheme().*);
try std.testing.expectEqual(ChromeTheme.fromTheme(restored.theme()), restored.chromeTheme().*);
}
+test "restored sessions animate layout changes after bootstrap" {
+ if (platform == .web) return;
+ const source = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 16 });
+ defer source.deinit();
+ try source.dumpState();
+
+ const restored = try Pardes.initFromDump(
+ std.testing.allocator,
+ .{ .tty_only = true, .cols = 80, .rows = 16, .startup_config = "PanelSlide\n" },
+ source.dump_out.?,
+ );
+ defer restored.deinit();
+ try std.testing.expectEqual(panel_animation.Transition.slide, restored.settings.panel_transition);
+ try std.testing.expect(!restored.animationActive());
+
+ var frame: std.heap.ArenaAllocator = .init(std.testing.allocator);
+ defer frame.deinit();
+ const initial = try restored.render(frame.allocator());
+ restored.acknowledgePanelPresentation(initial.panelTracks());
+ _ = try restored.newShell(1, "");
+ try std.testing.expect(restored.layoutSplitColumn(0, 1, false));
+ restored.sync();
+
+ try std.testing.expect(restored.panel_tracks[0] != null);
+ try std.testing.expect(restored.panel_tracks[1] != null);
+ try std.testing.expect(restored.animationActive());
+}
+
+test "restored terminals keep monotonic nonzero pane identities" {
+ const gpa = std.testing.allocator;
+ const source = try Pardes.init(gpa, .{ .shells = 3 });
+ defer source.deinit();
+ try source.dumpState();
+
+ const restored = try Pardes.initFromDump(gpa, .{}, source.dump_out.?);
+ defer restored.deinit();
+
+ var ids: [MAX_PANES]usize = undefined;
+ var serials: [MAX_PANES]u32 = undefined;
+ var n: usize = 0;
+ for (restored.panes, 0..) |slot, id| {
+ const pane = slot orelse continue;
+ if (!pane.isTerminal()) continue;
+ try std.testing.expect(pane.serial != 0);
+ for (serials[0..n]) |seen| try std.testing.expect(pane.serial != seen);
+ ids[n] = id;
+ serials[n] = pane.serial;
+ n += 1;
+ }
+ try std.testing.expectEqual(@as(usize, 3), n);
+ for (ids[0..n], serials[0..n]) |id, serial|
+ try std.testing.expectEqual(id, restored.paneBySerial(serial).?);
+
+ const before = restored.next_serial;
+ const free = restored.freeSlot() orelse return error.NoFreePane;
+ const fresh = try restored.newShell(free, "");
+ try std.testing.expectEqual(before + 1, fresh.serial);
+ try std.testing.expectEqual(fresh.serial, restored.next_serial);
+}
+
/// 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
@@ -2878,15 +1555,7 @@ fn mix(a: [3]u8, b: [3]u8) [3]u8 {
return out;
}
-const UNDO_MAX = 256;
-const EDIT_UNDO_MAX = 64; // terminal snapshots copy the edit buffer; cap tighter
-const PDF_RASTER_MAX = 256; // enough for every visible page in a pathological tiny-page viewport
-// How many retired page buffers one PDF pane may hold at once while a frame
-// swaps its visible set. Four covers a viewport straddling a page boundary
-// with slack; beyond that the bytes go back to the allocator. See
-// PdfView.spare — this is a within-frame relay, not a cache.
-const PDF_RASTER_SPARE = 4;
-const TAG_TAIL_CAP = 4096; // one editable command line; extra input is refused
+const TAG_TAIL_CAP = dump.max_tag_tail; // one editable command line; extra input is refused
const TTY_REPLAY_CAP = 1024 * 1024; // oldest bytes are evicted from the dump/replay record
const EFFECT_CAP = 4096; // one update may queue 256 KiB of ordered 64-byte writes
@@ -2969,6 +1638,11 @@ fn fold(comptime C: type) [@typeInfo(C).@"struct".decls.len]Theme {
/// exists — NextColor is a browse, not a way to arrive anywhere in particular.
pub const themes = fold(curated) ++ fold(@import("generated_themes"));
+comptime {
+ if (themes.len > std.math.maxInt(u16) + 1)
+ @compileError("runtime config theme index no longer fits u16");
+}
+
// Two themes answering to one name is a bug: `Theme <name>` 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
@@ -3023,6 +1697,9 @@ const initial_chrome = ChromeTheme.fromTheme(&themes[0]);
pub const Color = union(enum) { default, index: u8, rgb: [3]u8 };
+pub const FontRole = enum(u8) { body, tagline };
+pub const FontSizeUnit = runtime_cfg.FontSizeUnit;
+
pub const CellStyle = struct {
fg: Color = .default,
bg: Color = .default,
@@ -3034,6 +1711,7 @@ pub const CellStyle = struct {
invisible: bool = false,
strikethrough: bool = false,
ul: enum { off, single, double, curly, dotted, dashed } = .off,
+ font_role: FontRole = .body,
};
/// One surface cell. `default = true` means "never painted this frame": the
@@ -3047,8 +1725,33 @@ pub const Cell = struct {
pub fn grapheme(c: *const Cell) []const u8 {
return c.text[0..c.len];
}
+
+ /// Equality of what a shell can actually present. Bytes past `len` are
+ /// scratch left by earlier graphemes and must never manufacture a panel
+ /// diff; an unpainted default cell likewise has no visible style/text.
+ pub fn visuallyEqual(a: *const Cell, b: *const Cell) bool {
+ if (a.default or b.default) return a.default and b.default;
+ return a.len == b.len and
+ std.mem.eql(u8, a.grapheme(), b.grapheme()) and
+ std.meta.eql(a.style, b.style);
+ }
};
+test "cell visual equality ignores dead grapheme tail bytes" {
+ var a: Cell = .{ .default = false };
+ var b = a;
+ a.text[0] = 'x';
+ b.text[0] = 'x';
+ a.text[4] = 'a';
+ b.text[4] = 'b';
+ try std.testing.expect(a.visuallyEqual(&b));
+ b.style.bold = true;
+ try std.testing.expect(!a.visuallyEqual(&b));
+ a.default = true;
+ b.default = true;
+ try std.testing.expect(a.visuallyEqual(&b));
+}
+
/// 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
@@ -3070,26 +1773,8 @@ pub const ImageCacheKey = if (pdf_enabled) struct {
}
};
-/// 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;
-const PdfTintMode = if (pdf_enabled) pdf_impl.TintMode else void;
-
-/// Every resident page records the PDF-only pixel state it was rendered for.
-/// Disabled deliberately ignores colors: changing a theme must not replace an
-/// untouched PDF raster. In tinted modes colors are part of the generation.
-const PdfTintKey = if (pdf_enabled) struct {
- mode: PdfTintMode,
- colors: pdf_impl.TintColors,
-
- fn eql(a: @This(), b: @This()) bool {
- return a.mode == b.mode and
- (a.mode == .disabled or std.meta.eql(a.colors, b.colors));
- }
-} else void;
+const PdfFitMode = pdf_pane.FitMode;
+const PdfTintMode = pdf_pane.TintMode;
/// Dynamic placement exists only for native PDF pages. Static image panes need
/// only their pane identity, so feature-off builds carry a zero-bit payload.
@@ -3153,7 +1838,7 @@ test "pixel attachment cache key follows both pane lifetime and rendered revisio
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));
+ try std.testing.expectEqual(@as(usize, 0), @sizeOf(pdf_pane.PointerDrag));
}
}
@@ -3191,13 +1876,37 @@ pub const Surface = struct {
/// viewport without occasionally dropping an intersecting page.
images: []?ImagePlace = &.{},
nimages: usize = 0,
+ /// Active pane transitions, keyed by the stable pane serial carried in
+ /// each record. GUI shells evaluate these in shaders; the TTY remaps this
+ /// same frame's cells through its grid compositor.
+ panel_tracks: [MAX_PANES * 2]panel_animation.Track = undefined,
+ npanel_tracks: usize = 0,
+ /// Frozen canonical cells from before the current content/lifecycle
+ /// transition, plus its semantic old/new mask. Both are core-owned and
+ /// remain stable until every associated track has finished.
+ previous_cells: []const Cell = &.{},
+ changed_cells: []const u8 = &.{},
+
+ pub fn panelTracks(s: *const Surface) []const panel_animation.Track {
+ return s.panel_tracks[0..s.npanel_tracks];
+ }
+
+ pub fn hasPanelDiff(s: *const Surface) bool {
+ return s.previous_cells.len == s.cells.len and
+ s.changed_cells.len == s.cells.len;
+ }
+
+ pub fn panelCellChanged(s: *const Surface, x: u16, y: u16) bool {
+ if (!s.hasPanelDiff() or x >= s.cols or y >= s.rows) return false;
+ return s.changed_cells[@as(usize, y) * s.cols + x] != 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 {
+ pub fn set(s: *Surface, x: u16, y: u16, text: []const u8, style: CellStyle) void {
const c = s.at(x, y);
std.debug.assert(text.len <= c.text.len);
@memcpy(c.text[0..text.len], text);
@@ -3267,7 +1976,7 @@ pub const Surface = struct {
return col;
}
- fn fill(s: *Surface, x: u16, y: u16, w: u16, h: u16, style: CellStyle) void {
+ pub fn fill(s: *Surface, x: u16, y: u16, w: u16, h: u16, style: CellStyle) void {
var yy = y;
while (yy < y + h) : (yy += 1) {
var xx = x;
@@ -3354,10 +2063,156 @@ test "tabbed file aligns syntax cursor and mouse while preserving virtual column
try std.testing.expectEqual(blank_x, virtual_surface.cursor.?.x);
const local_blank: i32 = @intCast(blank_x - rect.x - config.GUTTER);
- var look_sel = Sel{ .state = .dragging, .c0 = local_blank, .c1 = local_blank, .r0 = BOX_H, .r1 = BOX_H };
- p.expandSel(pane, &look_sel);
- try std.testing.expectEqual(local_blank, look_sel.c0);
- try std.testing.expectEqual(local_blank, look_sel.c1);
+ try std.testing.expect(p.expandedSel(pane, .{ .state = .dragging, .c0 = local_blank, .c1 = local_blank, .r0 = BOX_H, .r1 = BOX_H }) == null);
+
+ // The immediately-adjacent EOL cell and an in-line separator are blank
+ // too. Pointer expansion must not lean left into the preceding word.
+ const adjacent: i32 = @intCast(config.PREFIX_W + file_pane.displayWidth(line));
+ try std.testing.expect(p.expandedSel(pane, .{ .state = .dragging, .c0 = adjacent, .c1 = adjacent, .r0 = BOX_H, .r1 = BOX_H }) == null);
+ const separator: i32 = @intCast(config.PREFIX_W + config.tab_width + "return".len);
+ try std.testing.expect(p.expandedSel(pane, .{ .state = .dragging, .c0 = separator, .c1 = separator, .r0 = BOX_H, .r1 = BOX_H }) == null);
+}
+
+test "Look hover waits without mutating the pane and input cancels it" {
+ const delay = config.look_preview_delay_frames orelse return;
+ try std.testing.expect(delay > 0);
+
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .cols = 60, .rows = 12, .tty_only = true });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = try p.hxOpenFileContent("alpha beta gamma\n");
+ while (p.nextEffect()) |_| {}
+ const other_id = p.freeSlot() orelse return error.NoSparePaneForHoverTest;
+ _ = try p.newShell(other_id, "");
+ try std.testing.expect(p.layoutSplitColumn(0, other_id, false));
+ p.sync();
+ while (p.nextEffect()) |_| {}
+
+ var frame = std.heap.ArenaAllocator.init(gpa);
+ defer frame.deinit();
+ _ = try p.render(frame.allocator());
+ const rect = p.rects[p.active];
+ const word_x = rect.x + config.GUTTER + config.PREFIX_W + 7;
+ const body_y = if (p.settings.tag_bottom) rect.y else rect.y + BOX_H;
+ const motion = Event{ .mouse = .{ .button = .none, .kind = .motion, .col = word_x, .row = body_y } };
+
+ const selections_before = pane.sel;
+ const active_before = p.active;
+ const cursor_before = .{ pane.cur_row, pane.cur_col };
+ const jumps_before = .{ p.njumps, p.jcur, p.n_look_src };
+ const effects_before = p.effects_len;
+
+ p.update(motion);
+ try std.testing.expect(p.look_hover_wait != null);
+ try std.testing.expect(p.look_hover_preview == null);
+ try std.testing.expect(p.animationActive());
+
+ var ticks: u16 = 0;
+ while (ticks + 1 < delay) : (ticks += 1) {
+ p.update(.tick);
+ // Repeated motion reports for one cell are noise, not a fresh delay.
+ if (ticks == delay / 2) p.update(motion);
+ try std.testing.expect(p.look_hover_preview == null);
+ }
+ p.update(.tick);
+ const preview = p.look_hover_preview orelse return error.MissingLookHoverPreview;
+ const preview_sel = preview.sel orelse return error.MissingLookHoverSelection;
+ try std.testing.expectEqual(.done, preview_sel.state);
+ try std.testing.expectEqual(@as(i32, config.PREFIX_W + 6), preview_sel.c0);
+ try std.testing.expectEqual(@as(i32, config.PREFIX_W + 9), preview_sel.c1);
+ try std.testing.expect(!p.animationActive());
+
+ // Resolving and displaying the operand is observational only.
+ try std.testing.expect(std.meta.eql(selections_before, pane.sel));
+ try std.testing.expectEqual(active_before, p.active);
+ try std.testing.expectEqual(cursor_before, .{ pane.cur_row, pane.cur_col });
+ try std.testing.expectEqual(jumps_before, .{ p.njumps, p.jcur, p.n_look_src });
+ try std.testing.expectEqual(effects_before, p.effects_len);
+
+ _ = frame.reset(.retain_capacity);
+ const surface = try p.render(frame.allocator());
+ const expected_bg = mix(p.theme().bg orelse p.theme().tag_bg, mix(p.theme().bg orelse p.theme().tag_bg, p.theme().sel_bg));
+ try std.testing.expectEqual(Color{ .rgb = expected_bg }, surface.at(word_x, body_y).style.bg);
+
+ // Background traffic in another pane does not change the text or geometry
+ // under this pointer and must not strand a stationary hover unarmed.
+ p.update(.{ .output = .{ .pane = @intCast(other_id), .bytes = "busy\r\n" } });
+ try std.testing.expect(p.look_hover_preview != null);
+
+ // A real leave cannot be represented as an out-of-range motion: motion
+ // coordinates are deliberately clamped for drags. It also clears the
+ // ordinary resize-handle hint, not only this Look-specific preview.
+ const seam_x = p.col_x[0] + p.col_w[0] - 1;
+ p.update(.{ .mouse = .{ .button = .none, .kind = .motion, .col = seam_x, .row = body_y } });
+ _ = frame.reset(.retain_capacity);
+ const hovered_seam = try p.render(frame.allocator());
+ try std.testing.expectEqualStrings("╎", hovered_seam.at(seam_x, body_y).grapheme());
+ p.update(.pointer_leave);
+ try std.testing.expect(p.look_hover_wait == null);
+ try std.testing.expect(p.look_hover_preview == null);
+ _ = frame.reset(.retain_capacity);
+ const left_seam = try p.render(frame.allocator());
+ try std.testing.expect(!std.mem.eql(u8, "╎", left_seam.at(seam_x, body_y).grapheme()));
+
+ // Blank space after EOL has a pointer cell but no Look operand. Ageing it
+ // to completion must turn the animation clock back off without drawing a
+ // misleading one-cell preview.
+ const blank_x = rect.x + config.GUTTER + config.PREFIX_W + 30;
+ p.update(.{ .mouse = .{ .button = .none, .kind = .motion, .col = blank_x, .row = body_y } });
+ for (0..delay) |_| p.update(.tick);
+ try std.testing.expect(p.look_hover_wait == null);
+ try std.testing.expect(p.look_hover_preview == null);
+ try std.testing.expect(!p.animationActive());
+
+ // File PREFIX_W belongs only to body line numbers. A preview on the tag's
+ // first character must be paintable all the way at tag column zero.
+ const tag_y = if (p.settings.tag_bottom) rect.y + rect.h - BOX_H else rect.y;
+ p.look_hover_preview = .{
+ .col = rect.x + config.GUTTER,
+ .row = tag_y,
+ .pane = p.active,
+ .serial = pane.serial,
+ .sel = .{ .state = .done, .c0 = 0, .c1 = 0, .r0 = 0, .r1 = 0 },
+ };
+ _ = frame.reset(.retain_capacity);
+ const tag_surface = try p.render(frame.allocator());
+ try std.testing.expectEqual(Color{ .rgb = expected_bg }, tag_surface.at(rect.x + config.GUTTER, tag_y).style.bg);
+
+ p.look_hover_preview = null;
+ p.update(motion);
+ try std.testing.expect(p.look_hover_wait != null);
+ p.update(.{ .key = .{ .cp = Key.escape } });
+ try std.testing.expect(p.look_hover_wait == null);
+ try std.testing.expect(p.look_hover_preview == null);
+ // The following host repaint must not resurrect a hover after keyboard
+ // input cancelled its raw-pointer intent.
+ p.acknowledgePanelPresentation(&.{});
+ try std.testing.expect(p.look_hover_wait == null);
+}
+
+test "cwd changes cancel only the affected pane's Look hover" {
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .shells = 3 });
+ defer p.deinit();
+
+ const first = p.panes[0] orelse return error.MissingFirstPane;
+ const other = p.panes[1] orelse return error.MissingOtherPane;
+ p.setCwd(0, "/work/first");
+ p.setCwd(1, "/work/other");
+ p.look_hover_wait = .{ .col = 1, .row = 1, .pane = 0, .serial = first.serial };
+
+ // Hosts poll cwd every frame. An unchanged answer is observational and a
+ // change reported for another pane cannot invalidate this pane's operand.
+ p.setCwd(0, "/work/first");
+ try std.testing.expect(p.look_hover_wait != null);
+ p.setCwd(1, "/work/elsewhere");
+ try std.testing.expect(p.look_hover_wait != null);
+ try std.testing.expectEqual(other.serial, p.panes[1].?.serial);
+
+ p.setCwd(0, "/work/changed");
+ try std.testing.expect(p.look_hover_wait == null);
+ try std.testing.expect(p.look_hover_preview == null);
}
test "plain left click clears explicit modal selection" {
@@ -3385,6 +2240,23 @@ test "plain left click clears explicit modal selection" {
try std.testing.expectEqual(.none, pane.sel[@intFromEnum(config.select_button)].state);
}
+test "selection drag and queued release do not enter panel pending state" {
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 50, .rows = 10, .tty_only = true });
+ defer p.deinit();
+ const pane = try p.hxOpenFileContent("alpha beta\n");
+ p.acknowledgePanelPresentation(&.{});
+ const rect = p.rects[0];
+ const x = rect.x + config.GUTTER + config.PREFIX_W + 1;
+ const y = rect.y + BOX_H;
+
+ p.update(.{ .mouse = .{ .button = config.select_button, .kind = .press, .col = x, .row = y } });
+ p.update(.{ .mouse = .{ .button = config.select_button, .kind = .drag, .col = x + 2, .row = y } });
+ try std.testing.expect(!p.panel_presentation_pending);
+ p.update(.{ .mouse = .{ .button = config.select_button, .kind = .release, .col = x + 2, .row = y } });
+ try std.testing.expect(p.drag == .none);
+ try std.testing.expectEqual(.done, pane.sel[sel_slot].state);
+}
+
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 };
@@ -3535,10 +2407,11 @@ pub const Event = union(enum) {
/// update only; success is atomic, so a failed/nonzero invocation carries
/// no usable outputs and changes nothing.
pipe_resp: struct { id: u32, success: bool, outputs: []const []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.
+ /// a file the shell was asked to watch changed on disk; `bytes` are the
+ /// exact snapshot the host hashed, borrowed for this call like `output`.
+ /// Text panes adopt a copy; PDF panes reopen the path so MuPDF owns its
+ /// random-access document. 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 },
/// Text from the SYSTEM clipboard, borrowed for this call. Two ways in,
/// one handler (applyPaste): SOLICITED, the answer to a `read_clipboard`
@@ -3559,6 +2432,9 @@ pub const Event = union(enum) {
pdf_scroll: struct { pane: u8, delta_pixels: f32 },
pinch: f32,
touch_scroll: f32,
+ /// The host pointer left the drawable area. Unlike an out-of-range motion,
+ /// this must not clamp onto and preview the final grid cell.
+ pointer_leave,
tick,
};
@@ -3618,7 +2494,8 @@ pub const Effect = union(enum) {
/// 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.
+ /// Real text files and PDFs ask for this; an output buffer has no file
+ /// behind it.
watch: struct { pane: u8, on: bool },
quit,
@@ -3629,7 +2506,7 @@ pub const Effect = union(enum) {
pub fn slice(b: *const @This()) []const u8 {
return b.data[0..b.len];
}
- fn from(text: []const u8) @This() {
+ pub fn from(text: []const u8) @This() {
var b: @This() = .{};
std.debug.assert(text.len <= n);
@memcpy(b.data[0..text.len], text);
@@ -3642,23 +2519,9 @@ pub const Effect = union(enum) {
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 = &.{},
-};
+/// An owned editable buffer and the absolute surface row of its first line.
+/// Files use row zero; terminal overlays may begin anywhere in scrollback.
+pub const EditText = struct { text: []u8, row0: i32 };
/// One mouse selection (block-shaped), per button. c/r are text-area relative;
/// r counts from the tag row (body starts at BOX_H).
@@ -3670,6 +2533,50 @@ pub const Sel = struct {
r1: i32 = 0,
};
+const LookHoverWait = struct {
+ col: u16,
+ row: u16,
+ pane: usize,
+ serial: u32,
+ frames: u16 = 0,
+};
+
+const LookHoverPreview = struct {
+ col: u16,
+ row: u16,
+ pane: usize,
+ serial: u32,
+ /// Null when the operand is the pane's modal selection. The renderer uses
+ /// that marker to paint the live modal range subtly even while tag/search
+ /// editing would normally hide it. A kept mouse selection carries its
+ /// exact screen-space range; an ordinary word carries the click expansion.
+ sel: ?Sel,
+ /// A wrapped file word is one logical source span, not one rectangular
+ /// screen selection. Keeping it in source coordinates lets the renderer
+ /// paint every visible continuation while Look receives the exact same
+ /// bytes, including a path which crosses a soft-wrap boundary.
+ file_word: ?FileWordSpan = null,
+};
+
+const FileWordSpan = struct {
+ row: i32,
+ lo: i32,
+ hi: i32, // exclusive
+};
+
+const PdfWordPreview = if (pdf_enabled) struct {
+ col: u16,
+ row: u16,
+ pane: usize,
+ serial: u32,
+ probe: pdf_pane.WordProbe,
+
+ fn deinit(preview: *@This(), gpa: std.mem.Allocator) void {
+ preview.probe.deinit(gpa);
+ preview.* = undefined;
+ }
+} else void;
+
/// A modal line selection (helix `x`): whole rows [r0, r1], absolute.
pub const LineSel = struct {
active: bool = false,
@@ -3719,419 +2626,10 @@ pub const SelRange = struct {
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,
- /// Monotonic content identity for asynchronous edits. Every content swap
- /// goes through file_pane.setContent, which bumps this; a pipe completion
- /// accepted against another revision would overwrite intervening work.
- revision: u32 = 0,
- /// The `revision` this buffer was last WRITTEN at. Equal means what is on
- /// screen is what is on disk; anything else is unsaved work.
- ///
- /// Bookkeeping only — nothing in the core renders it, and no shell has to
- /// read it. It exists because a windowed host has somewhere to PUT the
- /// answer (macOS puts a dot in the close button, and the proxy icon it sits
- /// beside is the same pane's path), and a shell cannot derive it: revision
- /// counts edits, and only the save knows which edit was the last one
- /// committed. Zero for a fresh buffer, which is why every construction site
- /// gets clean-on-open from the default and none of them mention it.
- ///
- /// Marked at the moment Save is ASKED, not when the write lands: the
- /// save_file effect carries no completion back, so this is as honest as the
- /// rest of that path. A failed write reads as saved, exactly as the tagline
- /// already does.
- saved_revision: u32 = 0,
- /// 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 tree_sitter_gpa-owned syntax.Syn byte per content byte in the highlighted window
- highlights: []u8 = &.{},
- highlight_start: usize = 0,
- syntax_dirty: bool = true,
- undo: [UNDO_MAX]FileSnap = undefined,
- undo_len: usize = 0,
- redo: [UNDO_MAX]FileSnap = undefined,
- redo_len: usize = 0,
-};
-
-/// 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 (image_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;
-
-const PdfRaster = if (pdf_enabled) struct {
- valid: bool = false,
- page: usize = 0,
- rgba: []u8 = &.{},
- /// The FULL page raster's dimensions, whatever part of it `rgba` holds.
- /// Everything that maps between the document and the screen — placement,
- /// the mouse, panning, a search reveal — is arithmetic about the whole
- /// page, and none of it wants to know that a fling only rasterized a
- /// strip.
- iw: usize = 0,
- ih: usize = 0,
- /// ...and the strip that is actually here: rows [band_y, band_y + band_h)
- /// of that full raster, so `rgba.len == iw * band_h * 4`. A page at rest
- /// is the whole page as one band. See `pdfWantedBand`.
- band_y: usize = 0,
- band_h: usize = 0,
- request: pdf_impl.RenderRequest = .{},
- request_valid: bool = false,
- tried: bool = false,
- decorated: bool = false,
- tint_key: ?PdfTintKey = null,
- revision: u32 = 0,
-} else void;
-
-const PdfSectionsOutput = if (pdf_enabled) struct {
- pane: usize,
- serial: u32,
- /// The generated revision. A mismatch means the user edited the output;
- /// reopening must not overwrite it, so a fresh output is opened instead.
- revision: u32,
-} 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,
- page_sizes: []pdf_impl.PageSize,
- /// Pixel-space layout is recomputed only when fit or physical viewport
- /// changes. These tiny arrays replace any all-document raster.
- page_starts: []u64,
- page_heights: []u32,
- document_height: u64 = 0,
- layout_viewport_w: u32 = 0,
- layout_viewport_h: u32 = 0,
- layout_fit: PdfFitMode = .width,
- layout_valid: bool = false,
- /// Top of the viewport in document display pixels. f64 preserves SDL's
- /// fractional wheel distance while page starts/heights remain integral.
- document_scroll_y: f64 = 0,
- scroll_to_page_pending: bool = true,
- /// At most PDF_RASTER_MAX visible pages are retained. If a viewport can
- /// show more pathological tiny pages, later pages are rendered as space
- /// rather than letting cache memory grow with the document.
- rasters: [PDF_RASTER_MAX]PdfRaster = undefined,
- rasters_len: usize = 0,
- /// Retired page buffers, waiting to become the raster of a page arriving at
- /// the same byte length. Every frame of a fling replaces the visible set
- /// wholesale at IDENTICAL page dimensions, so returning megabytes to the
- /// allocator only to ask for the same bytes back costs an unmap, a map, and
- /// a fresh kernel fault on every 4 KiB of every page flown past — for
- /// nothing. The relay spans one reconcile, where the departures happen
- /// before the arrivals; `trimPdfSpares` then drops it back to a single
- /// buffer, so this is a frame-length relay and not a second cache.
- ///
- /// Retired rather than rendered into in place: a raster is still swapped
- /// only after MuPDF and the tint have both succeeded, so a failed rerender
- /// can never blank a page that was already visible.
- spare: [PDF_RASTER_SPARE][]u8 = @splat(&.{}),
- spare_len: usize = 0,
- /// A physical viewport change rebuilds page heights. Preserve the same
- /// page-relative reading position instead of reinterpreting old pixels or
- /// snapping to the active page's top.
- layout_anchor_pending: bool = false,
- layout_anchor_page: usize = 0,
- layout_anchor_fraction: f64 = 0,
- next_raster_revision: u32 = 0,
- /// Display pixels this view has been SCROLLED by since the last frame it
- /// drew — the fling signal, and the reason the core needs no clock: a wheel
- /// batch that moves a screenful or more inside one frame lands on pages
- /// nobody will see again, and only their visible rows are worth
- /// rasterizing (see `pdfWantedBand`). Deliberately counts SCROLLING only:
- /// a page jump, a search reveal or a resize moves the viewport just as far
- /// but leaves the reader looking at one place, where whole pages are what
- /// makes the frames after it free.
- scroll_travel: f64 = 0,
- fit: PdfFitMode = .width,
- tint: PdfTintMode = .filtered,
- /// 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,
- /// Loaded once on first PdfSections. An empty outline is still a loaded
- /// outline; `outline_tried` distinguishes it from the lazy initial state
- /// and makes backend failures repeatably inert instead of retrying every
- /// command invocation.
- outline: ?pdf_impl.Outline = null,
- outline_tried: bool = false,
- sections_output: ?PdfSectionsOutput = null,
- /// Page-space outline coordinates survive a pane split/layout rebuild.
- /// ensurePdfLayout consumes them without invalidating any page pixels.
- outline_reveal_pending: ?pdf_impl.OutlineInternalDestination = null,
-} else void;
-
-/// Pure outline-to-row policy. Keeping destination resolution and title
-/// sanitisation together makes the ordinal written into a row exactly the
-/// ordinal Look later resolves; neither path invents a second flattened tree.
-pub const PdfSectionRows = if (pdf_enabled) struct {
- pub fn usableDestination(destination: pdf_impl.OutlineDestination) ?pdf_impl.OutlineDestination {
- return switch (destination) {
- .internal => destination,
- .external => |uri| if (safeHttpUri(uri)) destination else null,
- .none => null,
- };
- }
-
- fn safeHttpUri(uri: []const u8) bool {
- // Effect.open_link is inline and cannot carry a larger URL. Omitting
- // it here is preferable to rendering a row which can never act.
- if (uri.len > 256) return false;
- var has_scheme = false;
- for (config.url_schemes) |scheme| {
- if (std.mem.startsWith(u8, uri, scheme)) {
- has_scheme = true;
- break;
- }
- }
- if (!has_scheme) return false;
- for (uri) |byte| if (byte <= 0x20 or byte == 0x7f) return false;
- return true;
- }
-
- /// A structural node goes to the first later DFS entry still below it
- /// which carries a usable destination. Nodes with their own unusable URI
- /// are not structural: omit them instead of silently changing their link.
- pub fn resolve(entries: []const pdf_impl.OutlineEntry, ordinal: usize) ?pdf_impl.OutlineDestination {
- if (ordinal >= entries.len) return null;
- const entry = entries[ordinal];
- if (entry.destination != .none) return usableDestination(entry.destination);
- var i = ordinal + 1;
- while (i < entries.len and entries[i].depth > entry.depth) : (i += 1)
- if (usableDestination(entries[i].destination)) |destination| return destination;
- return null;
- }
-
- /// Resolve every rendered row in one DFS pass. `resolve` above stays the
- /// public single-ordinal policy used by Look; bulk materialisation avoids
- /// rescanning the same descendant chain for every structural ancestor.
- /// The one-usize-per-entry table is transient (64 KiB at MuPDF's 8192
- /// outline-item limit) and stores source ordinals, so URI slices continue
- /// to borrow from the document-owned outline instead of being copied.
- fn resolveOrdinals(
- gpa: std.mem.Allocator,
- entries: []const pdf_impl.OutlineEntry,
- ) ![]usize {
- const unresolved = std.math.maxInt(usize);
- const ordinals = try gpa.alloc(usize, entries.len);
- @memset(ordinals, unresolved);
-
- const Pending = struct { depth: u8, ordinal: usize };
- // OutlineEntry.depth is u8. A valid DFS path therefore cannot hold
- // more than 256 simultaneously unresolved ancestors, independent of
- // the tighter limit enforced by the MuPDF bridge.
- var pending: [256]Pending = undefined;
- var pending_len: usize = 0;
- for (entries, 0..) |entry, ordinal| {
- while (pending_len > 0 and pending[pending_len - 1].depth >= entry.depth)
- pending_len -= 1;
-
- if (usableDestination(entry.destination) != null) {
- ordinals[ordinal] = ordinal;
- for (pending[0..pending_len]) |ancestor|
- ordinals[ancestor.ordinal] = ordinal;
- pending_len = 0;
- } else if (entry.destination == .none) {
- pending[pending_len] = .{ .depth = entry.depth, .ordinal = ordinal };
- pending_len += 1;
- }
- }
- return ordinals;
- }
-
- fn cleanTitle(out: ?[]u8, title: ?[]const u8) usize {
- const raw = title orelse {
- if (out) |buf| @memcpy(buf[0..10], "[untitled]");
- return 10;
- };
- var at: usize = 0;
- var i: usize = 0;
- var wrote = false;
- var pending_space = false;
- while (i < raw.len) {
- const n: usize = std.unicode.utf8ByteSequenceLength(raw[i]) catch {
- pending_space = wrote;
- i += 1;
- continue;
- };
- if (i + n > raw.len) {
- pending_space = wrote;
- break;
- }
- const cp = std.unicode.utf8Decode(raw[i .. i + n]) catch {
- pending_space = wrote;
- i += n;
- continue;
- };
- const whitespace_or_control = cp <= 0x20 or cp == 0x7f or
- (cp >= 0x80 and cp <= 0x9f) or cp == 0x2028 or cp == 0x2029;
- if (whitespace_or_control) {
- pending_space = wrote;
- } else {
- if (pending_space) {
- if (out) |buf| buf[at] = ' ';
- at += 1;
- }
- if (out) |buf| @memcpy(buf[at..][0..n], raw[i .. i + n]);
- at += n;
- wrote = true;
- pending_space = false;
- }
- i += n;
- }
- if (!wrote) {
- if (out) |buf| @memcpy(buf[0..13], "[empty title]");
- return 13;
- }
- return at;
- }
-
- pub fn render(gpa: std.mem.Allocator, path: []const u8, entries: []const pdf_impl.OutlineEntry) ![]u8 {
- const target = std.fs.path.basename(path);
- const ordinals = try resolveOrdinals(gpa, entries);
- defer gpa.free(ordinals);
- var total: usize = 0;
- for (entries, 0..) |entry, ordinal| {
- const resolved = ordinals[ordinal];
- if (resolved == std.math.maxInt(usize)) continue;
- const destination = usableDestination(entries[resolved].destination) orelse unreachable;
- total += switch (destination) {
- .internal => |internal| std.fmt.count("{s}:{d}:{d} ", .{ target, internal.page + 1, ordinal + 1 }),
- .external => |uri| std.fmt.count("{s} ", .{uri}),
- .none => unreachable,
- };
- total += @as(usize, entry.depth) * 2 + cleanTitle(null, entry.title) + 1;
- }
- const out = try gpa.alloc(u8, total);
- errdefer gpa.free(out);
- var at: usize = 0;
- for (entries, 0..) |entry, ordinal| {
- const resolved = ordinals[ordinal];
- if (resolved == std.math.maxInt(usize)) continue;
- const destination = usableDestination(entries[resolved].destination) orelse unreachable;
- const prefix = switch (destination) {
- .internal => |internal| try std.fmt.bufPrint(out[at..], "{s}:{d}:{d} ", .{ target, internal.page + 1, ordinal + 1 }),
- .external => |uri| try std.fmt.bufPrint(out[at..], "{s} ", .{uri}),
- .none => unreachable,
- };
- at += prefix.len;
- const indent = @as(usize, entry.depth) * 2;
- @memset(out[at..][0..indent], ' ');
- at += indent;
- at += cleanTitle(out[at..], entry.title);
- out[at] = '\n';
- at += 1;
- }
- return out;
- }
-} else struct {};
-
-const PdfSlot = if (pdf_enabled) ?PdfView else void;
+const PdfSlot = if (pdf_enabled) ?pdf_pane.State else void;
fn hasPdf(pane: *const Pane) bool {
- return pdf_enabled and pane.kind == .pdf;
+ return if (comptime pdf_enabled) pane.pdf != null else false;
}
fn hasPdfSelection(pane: *const Pane) bool {
@@ -4141,42 +2639,21 @@ fn hasPdfSelection(pane: *const Pane) bool {
false;
}
-test "feature-off PDF pane state is zero-sized" {
- if (!pdf_enabled) try std.testing.expectEqual(@as(usize, 0), @sizeOf(PdfSlot));
-}
-pub const PaneKind = enum { terminal, file, image, pdf };
const Prompt = union(enum) {
none,
search: u16,
pipe: u16,
};
-/// File cursor columns may live past EOL. Tabs expand before that boundary;
-/// every virtual column after it is still one screen cell, as it was before
-/// display-cell-aware file coordinates were introduced.
-fn fileRawDisplayCol(line: []const u8, raw_col: usize) usize {
- const bounded = @min(raw_col, line.len);
- return file_pane.displayWidth(line[0..bounded]) +| (raw_col - bounded);
-}
-
-fn fileRawAtDisplay(line: []const u8, display_col: usize) usize {
- const width = file_pane.displayWidth(line);
- if (display_col > width) return line.len +| (display_col - width);
- return file_pane.byteAtDisplay(line, display_col);
-}
-
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
+ /// so the pane methods that need the file's LINE INDEX — scrollBy and
+ /// ensureCursorVisible — can build it. The alternative was
/// threading an allocator through ensureCursorVisible's 33 call sites.
gpa: std.mem.Allocator,
- /// Active payload discriminator. Constructors set this with the matching
- /// file/image/PDF payload; terminal panes carry none of those payloads.
- kind: PaneKind = .terminal,
/// 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
@@ -4187,8 +2664,9 @@ pub const Pane = struct {
cols: u16,
rows: u16,
greet: bool = false,
- file: ?File = null,
- image: ?Image = null,
+ pending_command: term_pane.PendingCommand = .{},
+ file: ?file_pane.State = null,
+ image: ?image_pane.State = null,
pdf: PdfSlot = if (pdf_enabled) null else {},
msel: LineSel = .{},
vsel: CharSel = .{},
@@ -4268,9 +2746,9 @@ pub const Pane = struct {
/// does nothing left of it.
tag_col: u16 = 0,
tag_anchor: u16 = 0,
- ed_undo: [EDIT_UNDO_MAX]EditSnap = undefined,
+ ed_undo: [term_pane.history_max]term_pane.Snapshot = undefined,
ed_undo_len: usize = 0,
- ed_redo: [EDIT_UNDO_MAX]EditSnap = undefined,
+ ed_redo: [term_pane.history_max]term_pane.Snapshot = undefined,
ed_redo_len: usize = 0,
/// live working directory, shell-reported (setCwd); shown in the tag and
/// the base for look-resolution of relative paths.
@@ -4312,7 +2790,7 @@ pub const Pane = struct {
wrap_n: u16 = 0,
sel: [3]Sel = @splat(.{}),
/// terminals only: the typed-text buffer standing in for shell rows
- ovl: ?Ovl = null,
+ ovl: ?term_pane.EditBuffer = null,
/// every raw pty byte, in order — a bounded dump/replay ring. Once full,
/// new output evicts the oldest bytes while the live terminal still sees
/// every byte.
@@ -4378,50 +2856,13 @@ pub const Pane = struct {
p.tag_tail_len -= 1;
}
- fn appendTtyReplay(p: *Pane, bytes: []const u8) void {
- if (bytes.len >= p.tty_stream.len) {
- const kept = bytes[bytes.len - p.tty_stream.len ..];
- @memcpy(p.tty_stream[0..], kept);
- p.tty_stream_head = 0;
- p.tty_stream_len = p.tty_stream.len;
- return;
- }
- const overflow = bytes.len -| (p.tty_stream.len - p.tty_stream_len);
- p.tty_stream_head = (p.tty_stream_head + overflow) % p.tty_stream.len;
- p.tty_stream_len -= overflow;
- const tail = (p.tty_stream_head + p.tty_stream_len) % p.tty_stream.len;
- const first = @min(bytes.len, p.tty_stream.len - tail);
- @memcpy(p.tty_stream[tail..][0..first], bytes[0..first]);
- @memcpy(p.tty_stream[0 .. bytes.len - first], bytes[first..]);
- p.tty_stream_len += bytes.len;
- }
-
- fn ttyReplayBytes(p: *const Pane, allocator: std.mem.Allocator) ![]const u8 {
- if (p.tty_stream_len == 0) return &.{};
- if (p.tty_stream_head + p.tty_stream_len <= p.tty_stream.len)
- return p.tty_stream[p.tty_stream_head..][0..p.tty_stream_len];
- const out = try allocator.alloc(u8, p.tty_stream_len);
- const first = p.tty_stream.len - p.tty_stream_head;
- @memcpy(out[0..first], p.tty_stream[p.tty_stream_head..]);
- @memcpy(out[first..], p.tty_stream[0 .. p.tty_stream_len - first]);
- return out;
- }
-
pub fn cwdSlice(p: *const Pane) []const u8 {
return p.cwd[0..p.cwd_len];
}
- fn payloadValid(p: *const Pane) bool {
- const no_pdf = if (comptime pdf_enabled) p.pdf == null else true;
- return switch (p.kind) {
- .terminal => p.file == null and p.image == null and no_pdf,
- .file => p.file != null and p.image == null and no_pdf,
- .image => p.file == null and p.image != null and no_pdf,
- .pdf => if (comptime pdf_enabled) p.file == null and p.image == null and p.pdf != null else false,
- };
- }
pub fn isTerminal(pane: *const Pane) bool {
- return pane.kind == .terminal;
+ const no_pdf = if (comptime pdf_enabled) pane.pdf == null else true;
+ return pane.file == null and pane.image == null and no_pdf;
}
pub fn pdfPath(pane: *const Pane) ?[]const u8 {
@@ -4582,24 +3023,17 @@ pub const Pane = struct {
const line = modal.lineSlice(f.content, @intCast(@max(0, pane.cur_row)));
const raw_cur: usize = @intCast(@max(0, pane.cur_col));
const raw_scroll: usize = @intCast(@max(0, pane.hscroll));
- const cur = @as(i32, @intCast(fileRawDisplayCol(line, raw_cur)));
- const visual_scroll = @as(i32, @intCast(fileRawDisplayCol(line, raw_scroll)));
+ const cur = @as(i32, @intCast(file_pane.rawDisplayCol(line, raw_cur)));
+ const visual_scroll = @as(i32, @intCast(file_pane.rawDisplayCol(line, raw_scroll)));
var target = visual_scroll;
if (cur < visual_scroll + hmargin)
target = @max(0, cur - hmargin)
else if (cur > visual_scroll + w - 1 - hmargin)
target = cur - (w - 1 - hmargin);
- if (target != visual_scroll) pane.hscroll = @intCast(fileRawAtDisplay(line, @intCast(target)));
+ if (target != visual_scroll) pane.hscroll = @intCast(file_pane.rawAtDisplay(line, @intCast(target)));
}
}
- /// 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)) {
@@ -4614,13 +3048,6 @@ pub const Pane = struct {
}
};
-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` is what makes this a CORNER grab: the press landed on a cell
@@ -4658,8 +3085,20 @@ const Drag = union(enum) {
ctrl: bool = false,
/// This gesture began over a usable native PDF raster. The payload is
/// zero-bit when PDF support is absent.
- pdf: PdfDrag = .{},
+ pdf: pdf_pane.PointerDrag = .{},
},
+
+ /// The physical button whose release balances this gesture. Layout and
+ /// tag drags are all left-button gestures; a text selection remembers its
+ /// own acme button. Deriving this from the gesture keeps multi-button
+ /// chords exact without a parallel held-button mask.
+ fn button(drag: Drag) ?Mouse.Button {
+ return switch (drag) {
+ .none => null,
+ .border_v, .border_h, .move, .tag => config.select_button,
+ .select => |selection| selection.button,
+ };
+ }
};
// The two border clamps, pulled out as plain arithmetic on plain numbers for
@@ -4749,7 +3188,7 @@ test "a corner drag's two axes clamp independently" {
/// lives; the h hit test, the corner search and the hover hint all read it here.
fn seamRowOf(p: *const Pardes, c: usize, k: usize) u16 {
const r = p.rects[p.col_terms[c][k]];
- return if (p.tag_bottom) r.y +| r.h else r.y + r.h -| 1;
+ return if (p.settings.tag_bottom) r.y +| r.h else r.y + r.h -| 1;
}
/// The corner fixture: the classic two-column boot with a SECOND pane added to
@@ -4880,12 +3319,736 @@ test "a RIGHT-column corner's two axes clamp independently" {
try std.testing.expectEqual(handle, p.drag.border_v.cur_x);
const a = p.rects[p.col_terms[1][0]];
const b = p.rects[p.col_terms[1][1]];
- try std.testing.expectEqual(clampBorderRow(a.y, a.h, b.h, 999, p.tag_bottom), p.drag.border_v.cur_y);
+ try std.testing.expectEqual(clampBorderRow(a.y, a.h, b.h, 999, p.settings.tag_bottom), p.drag.border_v.cur_y);
p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = handle, .row = 999 } });
}
+test "a new column takes width only from the column that created it" {
+ if (platform == .web) return;
+ // 124 deliberately makes the thirty-one-cell source split into unequal
+ // rounded halves. Independent width rounding moved the right column by a
+ // cell here; cumulative boundaries keep it pinned.
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 124, .rows = 24 });
+ defer p.deinit();
+
+ _ = try p.newShell(1, "");
+ try std.testing.expect(p.layoutSplitColumn(0, 1, false));
+ _ = try p.newShell(2, "");
+ try std.testing.expect(p.layoutSplitColumn(0, 2, true));
+ p.sync();
+
+ // ids 2, 0, 1 now own 1/4, 1/4 and 1/2 of the screen. Splitting the
+ // middle column must consume its own thirty cells in place: the columns
+ // on both sides retain their exact rectangles, not merely their weights.
+ const left_before = p.rects[2];
+ const right_before = p.rects[1];
+ _ = try p.newShell(3, "");
+ try std.testing.expect(p.layoutSplitColumn(0, 3, false));
+ p.sync();
+
+ try std.testing.expectEqual(left_before.x, p.rects[2].x);
+ try std.testing.expectEqual(left_before.w, p.rects[2].w);
+ try std.testing.expectEqual(right_before.x, p.rects[1].x);
+ try std.testing.expectEqual(right_before.w, p.rects[1].w);
+ try std.testing.expectEqual(@as(u16, 16), p.rects[0].w);
+ try std.testing.expectEqual(@as(u16, 15), p.rects[3].w);
+
+ const narrow = try Pardes.init(std.testing.allocator, .{ .cols = config.MINW * 2 - 1, .rows = 10 });
+ defer narrow.deinit();
+ try std.testing.expect(!narrow.layoutSplitColumn(0, 1, false));
+ try std.testing.expectEqual(@as(usize, 1), narrow.ncol);
+
+ // Public callers may chain surgery before sync. The first split leaves
+ // two minimum-width columns, so the second must read the freshly-derived
+ // source width and refuse rather than consulting the old full-width rect.
+ const sequential = try Pardes.init(std.testing.allocator, .{ .cols = config.MINW * 2, .rows = 10 });
+ defer sequential.deinit();
+ _ = try sequential.newShell(1, "");
+ try std.testing.expect(sequential.layoutSplitColumn(0, 1, false));
+ _ = try sequential.newShell(2, "");
+ try std.testing.expect(!sequential.layoutSplitColumn(0, 2, false));
+ try std.testing.expectEqual(@as(usize, 2), sequential.ncol);
+
+ // Even a deliberately coarse restored proportion divides into two usable
+ // columns. Rebase preserves every ratio while giving an odd numerator an
+ // exact half instead of rendering weight 1:2 from the value 3.
+ const coarse = try Pardes.init(std.testing.allocator, .{ .cols = 22, .rows = 10 });
+ defer coarse.deinit();
+ coarse.col_weight[0] = 3;
+ _ = try coarse.newShell(1, "");
+ try std.testing.expect(coarse.layoutSplitColumn(0, 1, false));
+ coarse.sync();
+ try std.testing.expectEqual(@as(u16, 11), coarse.col_w[0]);
+ try std.testing.expectEqual(@as(u16, 11), coarse.col_w[1]);
+
+ // A failed rebase is transactional even when the source pane is being
+ // MOVED out of a stack. Previously absorb/remove ran before overflow was
+ // discovered, so false meant the pane had silently vanished.
+ const extreme = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 20 });
+ defer extreme.deinit();
+ _ = try extreme.newShell(1, "");
+ try std.testing.expect(extreme.layoutSplitColumn(0, 1, false));
+ _ = try extreme.newShell(2, "");
+ extreme.layoutInsert(0, 1, 2);
+ extreme.col_weight[0] = std.math.maxInt(u64) / 2 + 2; // odd and cannot double
+ extreme.col_weight[1] = 1;
+ extreme.computeGeom();
+ const terms_before = extreme.col_terms;
+ const counts_before = extreme.col_n;
+ const weights_before = extreme.col_weight;
+ const source_vweight = extreme.panes[0].?.vweight;
+ const sibling_vweight = extreme.panes[2].?.vweight;
+ try std.testing.expect(!extreme.layoutSplitColumn(0, 0, false));
+ try std.testing.expectEqual(terms_before, extreme.col_terms);
+ try std.testing.expectEqual(counts_before, extreme.col_n);
+ try std.testing.expectEqual(weights_before, extreme.col_weight);
+ try std.testing.expectEqual(source_vweight, extreme.panes[0].?.vweight);
+ try std.testing.expectEqual(sibling_vweight, extreme.panes[2].?.vweight);
+}
+
+test "Newcol refuses an unsplittable restored weight before spawning" {
+ if (platform == .web) return;
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 200, .rows = 20 });
+ defer p.deinit();
+
+ // Restore accepts each individual weight up to maxInt/6. Deleting three
+ // one-pane columns can legitimately coalesce four such values into an odd
+ // survivor above maxInt/2, which cannot be globally doubled for a split.
+ for (1..4) |id| {
+ _ = try p.newShell(id, "");
+ try std.testing.expect(p.layoutSplitColumn(id - 1, id, false));
+ }
+ const restored_cap = std.math.maxInt(u64) / 6;
+ for (0..4) |column| p.col_weight[column] = restored_cap;
+ p.col_weight[3] -= 1;
+ for ([_]usize{ 3, 2, 1 }) |id| {
+ const pane = p.panes[id].?;
+ p.layoutRemove(id);
+ p.deinitPane(pane);
+ p.panes[id] = null;
+ }
+ while (p.nextEffect()) |_| {}
+
+ try std.testing.expect(p.col_weight[0] > std.math.maxInt(u64) / 2);
+ try std.testing.expect(p.col_weight[0] % 2 == 1);
+ try std.testing.expect(!p.layoutCanSplitColumn(0));
+ const panes_before = p.panes;
+ const serial_before = p.next_serial;
+ try std.testing.expect(p.executeBuiltinLine(0, "Newcol"));
+ try std.testing.expectEqual(panes_before, p.panes);
+ try std.testing.expectEqual(serial_before, p.next_serial);
+ try std.testing.expect(p.nextEffect() == null);
+}
+
+test "layout commits publish finite tracks only for changed panes" {
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 120, .rows = 20 });
+ defer p.deinit();
+
+ _ = try p.newShell(1, "");
+ try std.testing.expect(p.layoutSplitColumn(0, 1, false));
+ p.sync();
+ const untouched = p.rects[1];
+
+ p.settings.panel_transition = .slide;
+ _ = try p.newShell(2, "");
+ try std.testing.expect(p.layoutSplitColumn(0, 2, true));
+ p.sync();
+
+ // Pane 1 belongs to the unrelated right column. Its exact layout stayed
+ // fixed, so it does not receive a presentation record either.
+ try std.testing.expect(p.panel_tracks[1] == null);
+ try std.testing.expect(p.panel_tracks[0] != null);
+ try std.testing.expect(p.panel_tracks[2] != null);
+ try std.testing.expectEqual(panel_animation.Phase.moving, p.panel_tracks[0].?.phase);
+ try std.testing.expectEqual(panel_animation.Phase.opening, p.panel_tracks[2].?.phase);
+ try std.testing.expectEqual(untouched, p.rects[1]);
+ try std.testing.expect(p.animationActive());
+
+ var frame = std.heap.ArenaAllocator.init(std.testing.allocator);
+ defer frame.deinit();
+ const surface = try p.render(frame.allocator());
+ try std.testing.expectEqual(@as(usize, 2), surface.panelTracks().len);
+
+ for (0..panel_animation.Transition.slide.frames()) |_| p.update(.tick);
+ try std.testing.expect(!p.animationActive());
+ _ = frame.reset(.retain_capacity);
+ try std.testing.expectEqual(@as(usize, 0), (try p.render(frame.allocator())).panelTracks().len);
+
+ p.settings.panel_transition = .zoom;
+ p.update(.{ .resize = .{ .cols = 101, .rows = 17 } });
+ try std.testing.expect(!p.animationActive());
+ for (p.panel_tracks) |track| try std.testing.expect(track == null);
+}
+
+test "vertical close samples only a canonical baseline containing that pane" {
+ // Deleting an opener before its first canonical frame must not animate
+ // unrelated cells from the older global baseline as though they belonged
+ // to the short-lived pane.
+ {
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 16, .tty_only = true });
+ defer p.deinit();
+ var frame = std.heap.ArenaAllocator.init(std.testing.allocator);
+ defer frame.deinit();
+ const boot = try p.render(frame.allocator());
+ p.acknowledgePanelPresentation(boot.panelTracks());
+ p.settings.panel_transition = .vertical;
+
+ _ = try p.newShell(1, "");
+ try std.testing.expect(p.layoutSplitColumn(0, 1, false));
+ p.sync();
+ _ = frame.reset(.retain_capacity);
+ const opening = try p.render(frame.allocator());
+ p.acknowledgePanelPresentation(opening.panelTracks());
+ p.removePane(1);
+ p.sync();
+
+ try std.testing.expectEqual(@as(usize, 0), p.nclosing_panel_tracks);
+ }
+
+ // Once the pane itself has reached a canonical acknowledged frame, that
+ // exact box is safe frozen source data for its ordinary closing slide.
+ {
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 16, .tty_only = true });
+ defer p.deinit();
+ var frame = std.heap.ArenaAllocator.init(std.testing.allocator);
+ defer frame.deinit();
+ const boot = try p.render(frame.allocator());
+ p.acknowledgePanelPresentation(boot.panelTracks());
+ p.settings.panel_transition = .vertical;
+
+ _ = try p.newShell(1, "");
+ try std.testing.expect(p.layoutSplitColumn(0, 1, false));
+ p.sync();
+ for (0..panel_animation.Transition.vertical.frames()) |_| p.update(.tick);
+ _ = frame.reset(.retain_capacity);
+ const canonical = try p.render(frame.allocator());
+ try std.testing.expectEqual(@as(usize, 0), canonical.panelTracks().len);
+ p.acknowledgePanelPresentation(canonical.panelTracks());
+ const serial = p.panes[1].?.serial;
+ p.removePane(1);
+ p.sync();
+
+ try std.testing.expectEqual(@as(usize, 1), p.nclosing_panel_tracks);
+ try std.testing.expectEqual(serial, p.closing_panel_tracks[0].serial);
+ }
+}
+
+test "previous-grid animation refreshes its mask and snaps overlapping layout churn" {
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 120, .rows = 20, .tty_only = true });
+ defer p.deinit();
+ var frame = std.heap.ArenaAllocator.init(std.testing.allocator);
+ defer frame.deinit();
+ const boot = try p.render(frame.allocator());
+ p.acknowledgePanelPresentation(boot.panelTracks());
+ p.settings.panel_transition = .vertical;
+
+ _ = try p.newShell(1, "");
+ try std.testing.expect(p.layoutSplitColumn(0, 1, false));
+ p.sync();
+ _ = frame.reset(.retain_capacity);
+ const opening = try p.render(frame.allocator());
+ const changed_index = std.mem.indexOfScalar(u8, opening.changed_cells, 1) orelse
+ return error.MissingPanelDiff;
+ p.acknowledgePanelPresentation(opening.panelTracks());
+
+ // Corrupt one cached bit to prove a later live frame derives it again
+ // from the frozen old cells and freshly rendered new cells.
+ p.panel_changed_cells[changed_index] = 0;
+ _ = frame.reset(.retain_capacity);
+ const refreshed = try p.render(frame.allocator());
+ try std.testing.expectEqual(@as(u8, 1), refreshed.changed_cells[changed_index]);
+ p.acknowledgePanelPresentation(refreshed.panelTracks());
+
+ // A second opener before the first canonical endpoint has no truthful
+ // single old grid. Submit this layout canonically instead of rewinding to
+ // the boot baseline or manufacturing a stale closing pane.
+ _ = try p.newShell(2, "");
+ try std.testing.expect(p.layoutSplitColumn(1, 2, false));
+ p.sync();
+ try std.testing.expect(!p.panel_diff_pending and !p.panel_diff_ready);
+ try std.testing.expectEqual(@as(usize, 0), p.nclosing_panel_tracks);
+ for (p.panel_tracks) |track| try std.testing.expect(track == null);
+ try std.testing.expect(p.panel_presentation_pending);
+
+ _ = frame.reset(.retain_capacity);
+ const snapped = try p.render(frame.allocator());
+ try std.testing.expectEqual(@as(usize, 0), snapped.panelTracks().len);
+}
+
+test "canonical fallback and transition toggle retire unpresented tracks" {
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 16, .tty_only = true });
+ defer p.deinit();
+ var frame = std.heap.ArenaAllocator.init(std.testing.allocator);
+ defer frame.deinit();
+ const boot = try p.render(frame.allocator());
+ p.acknowledgePanelPresentation(boot.panelTracks());
+ p.settings.panel_transition = .slide;
+
+ _ = try p.newShell(1, "");
+ try std.testing.expect(p.layoutSplitColumn(0, 1, false));
+ p.sync();
+ _ = frame.reset(.retain_capacity);
+ const animated = try p.render(frame.allocator());
+ try std.testing.expect(animated.panelTracks().len > 0);
+
+ // A host which rendered the canonical fallback acknowledges exactly that
+ // empty record set; producers cannot resume on its next frame.
+ p.acknowledgePanelPresentation(&.{});
+ try std.testing.expect(!p.animationActive());
+ try std.testing.expect(!p.panel_diff_pending and !p.panel_diff_ready);
+ _ = frame.reset(.retain_capacity);
+ try std.testing.expectEqual(@as(usize, 0), (try p.render(frame.allocator())).panelTracks().len);
+
+ // Recreate a visible sample, then change the selected effect. Actual old
+ // pixels remain on screen until the next submit, so pointer input waits.
+ p.settings.panel_transition = .slide;
+ p.col_weight[0] = column_weight_unit * 2;
+ p.col_weight[1] = column_weight_unit;
+ p.sync();
+ _ = frame.reset(.retain_capacity);
+ const moving = try p.render(frame.allocator());
+ p.acknowledgePanelPresentation(moving.panelTracks());
+ try std.testing.expect(moving.panelTracks().len > 0);
+ p.applySettingBuiltin(runtime_cfg.find("PanelZoom").?, null);
+ try std.testing.expectEqual(panel_animation.Transition.zoom, p.settings.panel_transition);
+ try std.testing.expect(p.panel_presentation_pending);
+ try std.testing.expect(p.presentedPointer(10, 4) == null);
+ for (p.panel_tracks) |track| try std.testing.expect(track == null);
+
+ // Once canonical has replaced that sample, make another real transition.
+ // Re-applying the effective tagline percentage is inert, but changing it
+ // invalidates the frozen raster's metrics and therefore snaps the tracks.
+ p.acknowledgePanelPresentation(&.{});
+ p.col_weight[0] = column_weight_unit;
+ p.col_weight[1] = column_weight_unit * 2;
+ p.sync();
+ _ = frame.reset(.retain_capacity);
+ const before_resize = try p.render(frame.allocator());
+ p.acknowledgePanelPresentation(before_resize.panelTracks());
+ try std.testing.expect(before_resize.panelTracks().len > 0);
+ const old_tagline_percent = p.settings.font.tagline_percent;
+ var percent_buf: [3]u8 = undefined;
+ const same_percent = try std.fmt.bufPrint(&percent_buf, "{d}", .{old_tagline_percent});
+ p.applySettingBuiltin(runtime_cfg.find("TaglineSize").?, same_percent);
+ try std.testing.expect(p.animationActive());
+ const changed_percent: u8 = if (old_tagline_percent == 73) 74 else 73;
+ const changed_text = try std.fmt.bufPrint(&percent_buf, "{d}", .{changed_percent});
+ p.applySettingBuiltin(runtime_cfg.find("TaglineSize").?, changed_text);
+ try std.testing.expectEqual(changed_percent, p.settings.font.tagline_percent);
+ try std.testing.expect(p.panel_presentation_pending);
+ for (p.panel_tracks) |track| try std.testing.expect(track == null);
+}
+
+test "pointer coordinates follow presented panel geometry" {
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 120, .rows = 20, .tty_only = true });
+ defer p.deinit();
+ const pane = p.panes[0].?;
+
+ const moving: panel_animation.Track = .{
+ .serial = pane.serial,
+ .pane = 0,
+ .phase = .moving,
+ .effect = .slide,
+ .from = .{ .x = 20, .y = 5, .w = 40, .h = 10 },
+ .to = .{ .x = 0, .y = 1, .w = 80, .h = 18 },
+ };
+ p.acknowledgePanelPresentation(&.{moving});
+ // One shared physical-to-logical map feeds all pointer gestures. The
+ // selected sample is 26.25% across and 35% down the presented rectangle.
+ try std.testing.expectEqual(Pardes.PointerCell{ .col = 21, .row = 7 }, p.presentedPointer(30, 8).?);
+ // Canonical cells covered only by the not-yet-arrived target are inert.
+ try std.testing.expect(p.presentedPointer(2, 2) == null);
+ // Unrelated screen space remains in the ordinary grid coordinate system.
+ try std.testing.expectEqual(Pardes.PointerCell{ .col = 100, .row = 10 }, p.presentedPointer(100, 10).?);
+
+ p.acknowledgePanelPresentation(&.{panel_animation.Track{
+ .serial = pane.serial,
+ .pane = 0,
+ .phase = .opening,
+ .effect = .zoom,
+ .from = .{ .x = 40, .y = 10, .w = 0, .h = 0 },
+ .to = .{ .x = 0, .y = 1, .w = 80, .h = 18 },
+ }});
+ try std.testing.expect(p.presentedPointer(40, 10) == null);
+
+ // Tracks paint in pane-slot order inside a phase. The later slot is the
+ // top quad and therefore owns an overlap, even though both are moving.
+ const overlap = [_]panel_animation.Track{ .{
+ .serial = pane.serial,
+ .pane = 0,
+ .phase = .moving,
+ .effect = .slide,
+ .from = .{ .x = 20, .y = 2, .w = 20, .h = 4 },
+ .to = .{ .x = 0, .y = 2, .w = 20, .h = 4 },
+ }, .{
+ .serial = pane.serial + 1,
+ .pane = 1,
+ .phase = .moving,
+ .effect = .slide,
+ .from = .{ .x = 20, .y = 2, .w = 20, .h = 4 },
+ .to = .{ .x = 60, .y = 2, .w = 20, .h = 4 },
+ } };
+ // Slot one is deliberately absent from the fixture, so install this
+ // synthetic overlap directly; the production acknowledgement rejects
+ // dead pane lifetimes before they can participate in input.
+ p.presented_panel_tracks = @splat(null);
+ p.presented_panel_tracks[0] = overlap[0];
+ p.presented_panel_tracks[1] = overlap[1];
+ try std.testing.expectEqual(Pardes.PointerCell{ .col = 65, .row = 3 }, p.presentedPointer(25, 3).?);
+}
+
+test "queued pointer input is inert until a changed layout is presented" {
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 16, .tty_only = true });
+ defer p.deinit();
+ // Establish that a real host has presented the boot layout.
+ p.acknowledgePanelPresentation(&.{});
+ p.settings.panel_transition = .slide;
+
+ _ = try p.newShell(1, "");
+ try std.testing.expect(p.layoutSplitColumn(0, 1, false));
+ p.sync();
+ try std.testing.expect(p.panel_presentation_pending);
+ try std.testing.expect(p.presentedPointer(10, 4) == null);
+
+ var tracks: [MAX_PANES]panel_animation.Track = undefined;
+ var len: usize = 0;
+ for (p.panel_tracks) |maybe| if (maybe) |track| {
+ tracks[len] = track;
+ len += 1;
+ };
+ p.acknowledgePanelPresentation(tracks[0..len]);
+ try std.testing.expect(!p.panel_presentation_pending);
+ // Whether this particular opening sample exposes the chosen cell is the
+ // transition's concern; it is no longer rejected merely as speculative.
+ try std.testing.expect(p.presentedPointer(99, 4) != null);
+}
+
+test "back-to-back layout commits retarget from the last presented boxes" {
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 120, .rows = 20, .tty_only = true });
+ defer p.deinit();
+ _ = try p.newShell(1, "");
+ try std.testing.expect(p.layoutSplitColumn(0, 1, false));
+ p.sync();
+
+ var frame = std.heap.ArenaAllocator.init(std.testing.allocator);
+ defer frame.deinit();
+ const initial = try p.render(frame.allocator());
+ p.acknowledgePanelPresentation(initial.panelTracks());
+ const shown_a = Pardes.panelBox(p.rects[0]);
+ p.settings.panel_transition = .slide;
+
+ p.col_weight[0] = column_weight_unit;
+ p.col_weight[1] = column_weight_unit * 2;
+ p.sync();
+ const target_b = p.panel_tracks[0] orelse return error.MissingFirstRetarget;
+ try std.testing.expect(target_b.from.eql(shown_a));
+
+ // No render or acknowledgement of B: the pixels are still A. C must not
+ // jump to the logical-but-unseen B before beginning its own transition.
+ p.col_weight[0] = column_weight_unit * 2;
+ p.col_weight[1] = column_weight_unit;
+ p.sync();
+ const target_c = p.panel_tracks[0] orelse return error.MissingSecondRetarget;
+ try std.testing.expect(target_c.from.eql(shown_a));
+ try std.testing.expect(target_c.to.eql(Pardes.panelBox(p.rects[0])));
+ try std.testing.expectEqual(panel_animation.Phase.moving, target_c.phase);
+
+ _ = frame.reset(.retain_capacity);
+ const latest = try p.render(frame.allocator());
+ var submitted: ?panel_animation.Track = null;
+ for (latest.panelTracks()) |track| if (track.pane == 0) {
+ submitted = track;
+ break;
+ };
+ const track = submitted orelse return error.MissingSubmittedRetarget;
+ p.acknowledgePanelPresentation(latest.panelTracks());
+ try std.testing.expect(p.presented_panel_layout[0].?.box.eql(track.visualBox()));
+}
+
+test "an unpresented opening pane remains in the opening paint phase" {
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 120, .rows = 20, .tty_only = true });
+ defer p.deinit();
+ var frame = std.heap.ArenaAllocator.init(std.testing.allocator);
+ defer frame.deinit();
+ const initial = try p.render(frame.allocator());
+ p.acknowledgePanelPresentation(initial.panelTracks());
+ p.settings.panel_transition = .slide;
+
+ _ = try p.newShell(1, "");
+ try std.testing.expect(p.layoutSplitColumn(0, 1, false));
+ p.sync();
+ const first = p.panel_tracks[1] orelse return error.MissingOpeningTrack;
+ try std.testing.expectEqual(panel_animation.Phase.opening, first.phase);
+
+ _ = try p.newShell(2, "");
+ try std.testing.expect(p.layoutSplitColumn(1, 2, true));
+ p.sync();
+ const retargeted = p.panel_tracks[1] orelse return error.MissingOpeningRetarget;
+ try std.testing.expectEqual(panel_animation.Phase.opening, retargeted.phase);
+ try std.testing.expect(retargeted.from.eql(first.from));
+}
+
+test "pointer rejects panel content cells which have not materialized" {
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 12, .tty_only = true });
+ defer p.deinit();
+ const pane = p.panes[0].?;
+ const target: panel_animation.Box = .{ .x = 4, .y = 2, .w = 32, .h = 4 };
+ p.panel_changed_cells = try p.gpa.alloc(u8, @as(usize, p.screen_w) * p.screen_h);
+ @memset(p.panel_changed_cells, 0);
+ for (2..6) |row| {
+ for (4..36) |col| {
+ p.panel_changed_cells[row * p.screen_w + col] = 1;
+ }
+ }
+ p.panel_diff_ready = true;
+
+ for ([_]panel_animation.Transition{ .dissolve, .ascii }) |effect| {
+ const frame: u16 = if (effect == .dissolve) 3 else 5;
+ const track: panel_animation.Track = .{
+ .serial = pane.serial,
+ .pane = 0,
+ .phase = .opening,
+ .effect = effect,
+ .frame = frame,
+ .from = target,
+ .to = target,
+ };
+ p.acknowledgePanelPresentation(&.{track});
+
+ var saw_visible = false;
+ var saw_hidden = false;
+ for (0..4) |relative_row| for (0..32) |relative_col| {
+ const rcol: u16 = @intCast(relative_col);
+ const rrow: u16 = @intCast(relative_row);
+ const visible = switch (effect) {
+ .dissolve => panel_animation.dissolveRevealed(
+ pane.serial,
+ rcol,
+ rrow,
+ track.amount(),
+ ),
+ .ascii => panel_animation.asciiCell(
+ pane.serial,
+ rcol,
+ rrow,
+ track.amount(),
+ ).reveal,
+ else => unreachable,
+ };
+ const col: u16 = @intCast(4 + relative_col);
+ const row: u16 = @intCast(2 + relative_row);
+ const mapped = p.presentedPointer(col, row);
+ try std.testing.expectEqual(visible, mapped != null);
+ if (mapped) |point|
+ try std.testing.expectEqual(Pardes.PointerCell{ .col = col, .row = row }, point);
+ saw_visible = saw_visible or visible;
+ saw_hidden = saw_hidden or !visible;
+ };
+ try std.testing.expect(saw_visible);
+ try std.testing.expect(saw_hidden);
+ }
+}
+
+test "unchanged content cells remain clickable through data effects" {
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 40, .rows = 10, .tty_only = true });
+ defer p.deinit();
+ const pane = p.panes[0].?;
+ p.panel_changed_cells = try p.gpa.alloc(u8, @as(usize, p.screen_w) * p.screen_h);
+ @memset(p.panel_changed_cells, 0);
+ p.panel_diff_ready = true;
+ const box: panel_animation.Box = .{ .x = 2, .y = 2, .w = 20, .h = 4 };
+
+ for ([_]panel_animation.Transition{ .dissolve, .ascii }) |effect| {
+ p.acknowledgePanelPresentation(&.{.{
+ .serial = pane.serial,
+ .pane = 0,
+ .phase = .opening,
+ .effect = effect,
+ .from = box,
+ .to = box,
+ }});
+ try std.testing.expectEqual(Pardes.PointerCell{ .col = 8, .row = 3 }, p.presentedPointer(8, 3).?);
+ }
+}
+
+test "stationary Look hover follows only acknowledged panel samples" {
+ _ = config.look_preview_delay_frames orelse return;
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 120, .rows = 20, .tty_only = true });
+ defer p.deinit();
+ const pane = p.panes[0].?;
+ const rect = p.rects[0];
+ const target: panel_animation.Box = .{
+ .x = @floatFromInt(rect.x),
+ .y = @floatFromInt(rect.y),
+ .w = @floatFromInt(rect.w),
+ .h = @floatFromInt(rect.h),
+ };
+ var track: panel_animation.Track = .{
+ .serial = pane.serial,
+ .pane = 0,
+ .phase = .moving,
+ .effect = .slide,
+ .from = .{ .x = 20, .y = 5, .w = 40, .h = 10 },
+ .to = target,
+ };
+ p.acknowledgePanelPresentation(&.{track});
+ p.update(.{ .mouse = .{ .button = .none, .kind = .motion, .col = 30, .row = 8 } });
+ const first = p.look_hover_wait orelse return error.MissingInitialHoverWait;
+
+ // Advancing the producer-side track alone cannot move the semantic cell
+ // under a stationary pointer.
+ track.frame = track.effect.frames() - 1;
+ p.panel_tracks[0] = track;
+ p.refreshLookHoverFromRaw();
+ try std.testing.expectEqual(first, p.look_hover_wait.?);
+
+ // Once that exact sample is acknowledged, the same raw cell maps through
+ // the new panel rectangle and the debounce starts over for the new target.
+ p.acknowledgePanelPresentation(&.{track});
+ const moved = p.look_hover_wait orelse return error.MissingRemappedHoverWait;
+ try std.testing.expect(first.col != moved.col or first.row != moved.row);
+ try std.testing.expectEqual(@as(u16, 0), moved.frames);
+
+ // Re-acknowledging the same semantic target is host repaint noise, not a
+ // new hover, so it preserves the accumulated delay.
+ p.look_hover_wait.?.frames = 3;
+ p.acknowledgePanelPresentation(&.{track});
+ try std.testing.expectEqual(@as(u16, 3), p.look_hover_wait.?.frames);
+
+ // A zero-sized opening sample covers its future target but exposes no
+ // clickable content. Successful presentation therefore cancels the hover.
+ track.phase = .opening;
+ track.effect = .zoom;
+ track.frame = 0;
+ track.from = .{
+ .x = target.x + target.w * 0.5,
+ .y = target.y + target.h * 0.5,
+ };
+ p.acknowledgePanelPresentation(&.{track});
+ try std.testing.expect(p.look_hover_wait == null);
+ try std.testing.expect(p.look_hover_preview == null);
+
+ // The motion intent itself survives temporary invisibility. When a later
+ // successfully presented sample materializes under the stationary raw
+ // pointer, it can begin a fresh delay without synthetic mouse motion.
+ track.frame = track.effect.frames() - 1;
+ p.acknowledgePanelPresentation(&.{track});
+ try std.testing.expect(p.look_hover_wait != null);
+}
+
+test "held drag follows acknowledged panels and balances an invisible release" {
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 120, .rows = 20, .tty_only = true });
+ defer p.deinit();
+ const pane = p.panes[0].?;
+ const target = Pardes.panelBox(p.rects[0]);
+ var track: panel_animation.Track = .{
+ .serial = pane.serial,
+ .pane = 0,
+ .phase = .moving,
+ .effect = .slide,
+ .from = .{ .x = 50, .y = 5, .w = 20, .h = 8 },
+ .to = target,
+ };
+ const raw_col: u16 = 55;
+ const raw_row: u16 = 7;
+ p.acknowledgePanelPresentation(&.{track});
+ p.update(.{ .mouse = .{
+ .button = config.select_button,
+ .kind = .press,
+ .col = raw_col,
+ .row = raw_row,
+ } });
+ try std.testing.expect(p.drag == .select);
+ const first_col = pane.sel[sel_slot].c1;
+ const first_row = pane.sel[sel_slot].r1;
+
+ // Advancing producer state alone cannot move input. Only the sample the
+ // host says it actually drew may remap the stationary held endpoint.
+ track.frame = track.effect.frames() - 1;
+ p.panel_tracks[0] = track;
+ try std.testing.expectEqual(first_col, pane.sel[sel_slot].c1);
+ try std.testing.expectEqual(first_row, pane.sel[sel_slot].r1);
+ p.acknowledgePanelPresentation(&.{track});
+ try std.testing.expect(first_col != pane.sel[sel_slot].c1 or
+ first_row != pane.sel[sel_slot].r1);
+
+ const last_col = pane.sel[sel_slot].c1;
+ const last_row = pane.sel[sel_slot].r1;
+ track.phase = .opening;
+ track.effect = .zoom;
+ track.frame = 0;
+ track.from = .{
+ .x = target.x + target.w * 0.5,
+ .y = target.y + target.h * 0.5,
+ };
+ p.acknowledgePanelPresentation(&.{track});
+ try std.testing.expect(p.presentedPointer(raw_col, raw_row) == null);
+ try std.testing.expectEqual(last_col, pane.sel[sel_slot].c1);
+ try std.testing.expectEqual(last_row, pane.sel[sel_slot].r1);
+
+ // An unrelated release cannot consume the left drag. Its own release is
+ // nevertheless balanced at the retained endpoint while the cell is dark.
+ p.update(.{ .mouse = .{
+ .button = config.exec_button,
+ .kind = .release,
+ .col = raw_col,
+ .row = raw_row,
+ } });
+ try std.testing.expect(p.drag == .select);
+ p.update(.{ .mouse = .{
+ .button = config.select_button,
+ .kind = .release,
+ .col = raw_col,
+ .row = raw_row,
+ } });
+ try std.testing.expect(p.drag == .none);
+ try std.testing.expectEqual(.done, pane.sel[sel_slot].state);
+}
+
+test "repeated non-dyadic column splits preserve every unrelated boundary" {
+ if (platform == .web) return;
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 979, .rows = 20 });
+ defer p.deinit();
+
+ _ = try p.newShell(1, "");
+ try std.testing.expect(p.layoutSplitColumn(0, 1, false));
+ p.sync();
+
+ // Make the pair 418/561 through the real divider path. This ratio was a
+ // counterexample for f32 weights: splitting the right side twice changed
+ // the already-created middle boundary by one cell.
+ const handle = p.col_x[0] + p.col_w[0] - 1;
+ const untouched = p.col_weight;
+ p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = handle, .row = TOPBAR_H + 1 } });
+ p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = handle, .row = TOPBAR_H + 1 } });
+ try std.testing.expectEqual(untouched, p.col_weight);
+
+ p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = handle, .row = TOPBAR_H + 1 } });
+ p.update(.{ .mouse = .{ .button = .left, .kind = .drag, .col = 417, .row = TOPBAR_H + 1 } });
+ p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = 417, .row = TOPBAR_H + 1 } });
+ p.sync();
+ try std.testing.expectEqual(@as(u16, 418), p.rects[0].w);
+
+ _ = try p.newShell(2, "");
+ try std.testing.expect(p.layoutSplitColumn(1, 2, false));
+ p.sync();
+ const left_before = p.rects[0];
+ const middle_before = p.rects[1];
+
+ _ = try p.newShell(3, "");
+ try std.testing.expect(p.layoutSplitColumn(2, 3, false));
+ p.sync();
+ try std.testing.expectEqual(left_before, p.rects[0]);
+ try std.testing.expectEqual(middle_before, p.rects[1]);
+}
+
pub const Rect = struct { x: u16, y: u16, w: u16, h: u16 };
+const LayoutSnapshot = struct {
+ serial: u32,
+ box: panel_animation.Box,
+};
+
pub const Options = struct {
tty_only: bool = false,
/// initial shell panes: 1 (default) or 3 for the classic two-column boot.
@@ -4919,10 +4082,11 @@ pub const Options = struct {
tree_sitter_allocator: ?std.mem.Allocator = 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).
+ /// defers an integrated shell's greeting until after a resize AND its OSC
+ /// 133 B input mark (so `ls` cannot race startup and 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,
};
@@ -4983,13 +4147,67 @@ pub const Pardes = struct {
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),
+ /// Fixed-point horizontal proportions. Integer sums make splitting W
+ /// into A+B exactly associative, so an unrelated boundary cannot move
+ /// through floating-point regrouping after repeated source-local splits.
+ col_weight: [MAX_COLS]u64 = @splat(column_weight_unit),
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,
+ /// Last committed layout and the finite visual tracks derived from it.
+ /// Both are indexed by pane slot; serial rejects slot reuse.
+ layout_snapshot: [MAX_PANES]?LayoutSnapshot = @splat(null),
+ layout_snapshot_ready: bool = false,
+ /// Boot/config replay is not a visible layout event, and a host resize is
+ /// already continuous physical motion. Those paths snap this presentation
+ /// cache once instead of manufacturing panel transitions.
+ panel_animation_enabled: bool = false,
+ snap_panel_layout_once: bool = false,
+ /// Tracks the core is preparing for the next frame, and the independent
+ /// tracks a backend has actually put on screen. Input must follow the
+ /// latter: a failed GPU submit or a delayed AppKit draw cannot make an
+ /// unpresented animation tick clickable.
+ panel_tracks: [MAX_PANES]?panel_animation.Track = @splat(null),
+ presented_panel_tracks: [MAX_PANES]?panel_animation.Track = @splat(null),
+ /// Deleted pane lifetimes cannot stay in the slot-indexed live table: a
+ /// slot may be reused while its old pixels are still leaving. Tombstones
+ /// are dense plain records and never retain a functional Pane.
+ closing_panel_tracks: [MAX_PANES]panel_animation.Track = undefined,
+ nclosing_panel_tracks: usize = 0,
+ presented_closing_panel_tracks: [MAX_PANES]panel_animation.Track = undefined,
+ npresented_closing_panel_tracks: usize = 0,
+ /// Last canonical grid a backend explicitly acknowledged, and the frozen
+ /// semantic diff from it to the first canonical frame of this transition.
+ /// These buffers are capacity-reused and own no pane resources.
+ presented_cells: []Cell = &.{},
+ presented_cells_cols: u16 = 0,
+ presented_cells_rows: u16 = 0,
+ presented_cells_valid: bool = false,
+ /// Pane lifetimes and boxes represented by `presented_cells`. This is
+ /// deliberately separate from `presented_panel_layout`, which follows
+ /// partially animated pixels. A closing tombstone may sample the frozen
+ /// grid only when that grid actually contains the exact pane box.
+ presented_cells_layout: [MAX_PANES]?LayoutSnapshot = @splat(null),
+ panel_changed_cells: []u8 = &.{},
+ panel_diff_pending: bool = false,
+ panel_diff_ready: bool = false,
+ /// Canonical pane lifetimes/boxes captured by render, and the visual boxes
+ /// from the last successful acknowledgement. Retargeting reads the latter,
+ /// never a logical layout which may not have reached the screen yet.
+ submitted_panel_layout: [MAX_PANES]?LayoutSnapshot = @splat(null),
+ submitted_panel_layout_ready: bool = false,
+ presented_panel_layout: [MAX_PANES]?LayoutSnapshot = @splat(null),
+ /// Becomes true on the first host acknowledgement. Sans-host unit callers
+ /// retain direct canonical pointer semantics; a real frontend thereafter
+ /// gets the strict unpresented interval below.
+ panel_presentation_ready: bool = false,
+ /// A layout mutation has not reached a backend yet. Pointer gestures are
+ /// inert during this normally sub-frame interval; guessing frame-zero
+ /// geometry would make queued input address pixels nobody has presented.
+ panel_presentation_pending: bool = false,
active: usize = 0,
/// THE focus history: where the keyboard has BEEN, oldest first, and `jcur`
@@ -5018,39 +4236,24 @@ pub const Pardes = struct {
/// coincide by accident.
look_src: [MAX_PANES]u32 = undefined,
n_look_src: usize = 0,
+ /// Serial of the pane whose stream n/N is actively walking. Focus may
+ /// leave it when Enter/Look opens a row; provenance does not. Null/stale
+ /// falls back to the ordinary history order.
+ look_walk_owner: ?u32 = null,
/// hands out Pane.serial; monotonic, never reused
next_serial: u32 = 0,
- theme_idx: usize = 0,
+ /// Every user-settable, queryable display/runtime choice in one plain
+ /// authoritative record. Generated setting builtins mutate it directly;
+ /// rendering and the Config report read those same fields.
+ settings: runtime_cfg.State = .{ .font = .{ .tagline_percent = config.gui_tagline_font_percent } },
+ /// Delivery bookkeeping, not configuration: prevents a synchronous host
+ /// pump from taking one still-pending font request more than once before it
+ /// acknowledges success or rejection.
+ font_request_taken: bool = false,
chrome_animation: ChromeAnimation = ChromeAnimation.init(initial_chrome),
/// False only while startup configuration or a dump restore is selecting
/// its first theme. No frame is rendered in that interval.
animate_theme_changes: bool = false,
- colors_on: bool = true,
- /// The binary a NEW terminal execs (the Shell builtin). Inline and fixed
- /// like every other short string the core keeps, and `0` means "the
- /// default" rather than a copy of it — so there is no constructor to
- /// remember and config.default_shell stays the single place the default is
- /// written. Existing panes are not touched: a shell you are already in is
- /// a process, not a setting.
- shell_buf: [255]u8 = undefined,
- shell_len: u8 = 0,
- /// soft line breaks in file panes (the Wrap builtin). ON by default: a line
- /// running off the right edge is text you cannot read without scrolling
- /// sideways for it, and the reason to reach for the toggle is the rare
- /// file — a table, a fixture, a minified blob — whose columns mean
- /// something. Turned off, every row math in here is the one-row-per-line
- /// arithmetic it always was and hscroll comes back.
- wrap_on: bool = true,
- /// every pane's tagline on its LAST row instead of its first (the
- /// Tagbottom builtin). Render and hit-testing derive two locals from it —
- /// the tag row and the first body row — and nothing else in here knows;
- /// the Sel coordinate space is untouched, so row 0 is still the tag and
- /// the body still starts at BOX_H whichever end they are drawn at.
- tag_bottom: bool = false,
- 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.
@@ -5068,6 +4271,19 @@ pub const Pardes = struct {
drag: Drag = .none,
hover_col: u16 = 0,
hover_row: u16 = 0,
+ pointer_raw_col: u16 = 0,
+ pointer_raw_row: u16 = 0,
+ /// True only after buttonless motion. Presentation acknowledgements may
+ /// remap that stationary hover; keyboard/button/wheel input clears the
+ /// intent so an ordinary repaint cannot silently re-arm a cancelled hint.
+ raw_hover_intent: bool = false,
+ /// Hosts explicitly tell us when the pointer leaves. Keeping this bit
+ /// separate from the last coordinates lets a drag retain its endpoint
+ /// while idle resize-handle hints disappear immediately outside a window.
+ pointer_inside: bool = false,
+ look_hover_wait: ?LookHoverWait = null,
+ look_hover_preview: ?LookHoverPreview = null,
+ pdf_hover_preview: if (pdf_enabled) ?PdfWordPreview else void = if (pdf_enabled) null else {},
/// 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
@@ -5091,7 +4307,7 @@ pub const Pardes = struct {
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.
+ /// the active pane, whose transient message row shows the pending path.
leader_on: bool = false,
leader_keys: [4]u8 = undefined,
leader_n: u8 = 0,
@@ -5159,7 +4375,7 @@ pub const Pardes = struct {
/// write_dump); shown in the topbar as `Restore <path>`
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)
+ /// shell consumes it via takeRestore each frame (restore contents stay host-fed)
restore_req: ?[]const u8 = null,
restore_buf: [1024]u8 = undefined,
@@ -5193,25 +4409,25 @@ pub const Pardes = struct {
// away when it is wanted. Doc, PDF and image all boot the same way.
_ = initial_doc: {
if (comptime pdf_enabled) if (look.isPdfPath(path))
- break :initial_doc try p.openPdfView(0, path, opts.file_line);
+ break :initial_doc try pdf_pane.openPane(p, 0, path, opts.file_line);
if (look.isImagePath(path))
- break :initial_doc try p.openImageView(0, path);
+ break :initial_doc try image_pane.create(p, 0, path, &.{});
break :initial_doc try file_pane.open(p, 0, path, opts.file_line);
};
p.ncol = 1;
p.col_n[0] = 1;
p.col_terms[0][0] = 0;
} else if (opts.tty_only) {
- _ = try p.newPane(0);
+ _ = try p.newShell(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);
+ _ = try p.newShell(0, "");
+ _ = try p.newShell(1, "");
+ _ = try p.newShell(2, "");
for (p.panes[0..3]) |slot| slot.?.greet = true;
p.ncol = 2;
p.col_n[0] = 2;
@@ -5221,7 +4437,7 @@ pub const Pardes = struct {
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);
+ _ = try p.newShell(0, "");
p.panes[0].?.greet = true;
p.ncol = 1;
p.col_n[0] = 1;
@@ -5231,6 +4447,7 @@ pub const Pardes = struct {
p.applyStartupConfig();
p.finishThemeInitialization();
p.sync();
+ p.panel_animation_enabled = true;
// A config file that opens a file with `Look …` armed the pulse before
// anyone touched anything. Nobody asked for that, so boot is silent.
_ = p.takeHaptic();
@@ -5238,6 +4455,7 @@ pub const Pardes = struct {
}
pub fn deinit(p: *Pardes) void {
+ p.cancelLookHover();
for (&p.panes) |*slot| if (slot.*) |pane| {
p.deinitPane(pane);
slot.* = null;
@@ -5251,6 +4469,8 @@ pub const Pardes = struct {
p.shell_rows.reset(gpa);
p.scratch.deinit();
gpa.free(p.surface.cells);
+ if (p.presented_cells.len > 0) gpa.free(p.presented_cells);
+ if (p.panel_changed_cells.len > 0) gpa.free(p.panel_changed_cells);
if (p.surface.images.len > 0) gpa.free(p.surface.images);
gpa.destroy(p);
}
@@ -5279,50 +4499,17 @@ pub const Pardes = struct {
}
pub fn deinitPane(p: *Pardes, pane: *Pane) void {
- std.debug.assert(pane.payloadValid());
+ if (p.lookHoverPane()) |hovered| {
+ if (hovered < p.panes.len and p.panes[hovered] == pane)
+ p.cancelLookHover();
+ }
p.shell_rows.dropPane(pane);
+ term_pane.deinitPendingCommand(pane);
if (pane.image) |*iv| {
- p.image_gpa.free(iv.path);
- if (iv.rgba.len > 0) p.image_gpa.free(iv.rgba);
- if (iv.raw.len > 0) p.image_gpa.free(iv.raw);
- if (iv.grid.len > 0) p.image_gpa.free(iv.grid);
- }
- if (comptime pdf_enabled) {
- if (pane.pdf) |*pv| {
- p.pdf_gpa.free(pv.path);
- for (pv.rasters[0..pv.rasters_len]) |raster| if (raster.rgba.len > 0)
- p.pdf_gpa.free(raster.rgba);
- for (pv.spare[0..pv.spare_len]) |retired| p.pdf_gpa.free(retired);
- p.pdf_gpa.free(pv.page_sizes);
- p.pdf_gpa.free(pv.page_starts);
- p.pdf_gpa.free(pv.page_heights);
- // `rgba` aliases the active raster for legacy geometry state;
- // its owner was released above.
- if (pv.text.len > 0) p.pdf_gpa.free(pv.text);
- if (pv.search_query.len > 0) p.pdf_gpa.free(pv.search_query);
- if (pv.search_results) |*results| results.deinit(p.pdf_gpa);
- if (pv.selection) |*selection| selection.deinit(p.pdf_gpa);
- if (pv.selection_text.len > 0) p.pdf_gpa.free(pv.selection_text);
- if (pv.outline) |*outline| outline.deinit(p.pdf_gpa);
- 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.tree_sitter_gpa.free(f.highlights);
- for (f.undo[0..f.undo_len]) |s| p.gpa.free(s.content);
- for (f.redo[0..f.redo_len]) |s| p.gpa.free(s.content);
+ iv.deinit(p.image_gpa);
}
+ if (comptime pdf_enabled) if (pane.pdf) |*pv| pdf_pane.deinitPane(p, pane, pv);
+ if (pane.file) |*file| file_pane.deinit(p, pane, file);
if (pane.ovl) |o| p.gpa.free(o.text);
for (pane.ed_undo[0..pane.ed_undo_len]) |sn| if (sn.ovl) |o| p.gpa.free(o.text);
for (pane.ed_redo[0..pane.ed_redo_len]) |sn| if (sn.ovl) |o| p.gpa.free(o.text);
@@ -5331,37 +4518,25 @@ pub const Pardes = struct {
p.gpa.destroy(pane);
}
+ /// Put a fully constructed pane in a free slot and give it the monotonic
+ /// identity every slot-reuse guard relies on. Pane kinds construct their
+ /// own payloads; this registration rule remains a core invariant.
+ fn installPane(p: *Pardes, id: usize, pane: *Pane) void {
+ std.debug.assert(p.panes[id] == null);
+ p.next_serial += 1;
+ pane.serial = p.next_serial;
+ p.panes[id] = 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);
- errdefer p.gpa.destroy(pane);
- pane.* = .{
- .vt = try ghostty_vt.Terminal.init(std.Io.Threaded.global_single_threaded.io(), 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;
+ const pane = try term_pane.create(p.gpa, p.screen_w, p.screen_h);
+ term_pane.armShellSpawn(pane);
+ p.installPane(id, pane);
p.emit(.{ .spawn = .{ .pane = @intCast(id), .cwd = .from(cwd) } });
return pane;
}
@@ -5373,16 +4548,14 @@ pub const Pardes = struct {
const pane = try p.gpa.create(Pane);
errdefer p.gpa.destroy(pane);
pane.* = .{
- .vt = try ghostty_vt.Terminal.init(std.Io.Threaded.global_single_threaded.io(), p.gpa, .{ .cols = 1, .rows = 1 }),
+ .vt = try ghostty_vt.Terminal.init(term_pane.terminalIo(), 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;
+ p.installPane(id, pane);
return pane;
}
@@ -5421,6 +4594,18 @@ pub const Pardes = struct {
}
p.look_src[w] = pane.serial;
p.n_look_src = w + 1;
+ p.look_walk_owner = pane.serial;
+ }
+
+ /// Arm n/N on a concrete live pane without pretending that pane has
+ /// already performed a Look. Result producers use this when a fresh or
+ /// refilled list supersedes older walk history. An empty answer cannot
+ /// supersede anything: it has no position to resume, so preserve the pane
+ /// whose prior Look established the walk instead of stealing provenance.
+ pub fn armLookWalk(p: *Pardes, id: usize) void {
+ const pane = p.panes[id] orelse return;
+ if (pane.file) |file| if (file.content.len == 0) return;
+ p.look_walk_owner = pane.serial;
}
/// chunk arbitrary-length bytes into fixed-size write effects (order kept)
@@ -5438,6 +4623,8 @@ pub const Pardes = struct {
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);
+ if (pane.cwd_len == n and std.mem.eql(u8, pane.cwd[0..n], cwd[0..n])) return;
+ if (p.lookHoverPane() == id) p.cancelLookHover();
@memcpy(pane.cwd[0..n], cwd[0..n]);
pane.cwd_len = @intCast(n);
}
@@ -5492,7 +4679,6 @@ pub const Pardes = struct {
return false;
};
pane.file = .{ .path = path_copy, .content = content };
- pane.kind = .file;
pane.cur_pinned = true;
// sf was captured from SOURCE, not active. Nothing above this point
@@ -5509,7 +4695,80 @@ pub const Pardes = struct {
/// looked up, or an absolute path to be used as it stands. The resolving
/// is the shells' half: the core has no filesystem to ask.
pub fn shellBin(p: *const Pardes) []const u8 {
- return if (p.shell_len == 0) config.default_shell else p.shell_buf[0..p.shell_len];
+ const selected = p.settings.shell.requested.get();
+ return if (selected.len == 0) config.default_shell else selected;
+ }
+
+ /// The native host resolved the request (or chose a fallback) for a real
+ /// spawn. Record the executable that actually ran; web never calls this
+ /// because it has no process backend.
+ pub fn acknowledgeShell(p: *Pardes, id: usize, executable: []const u8, prompt_marks: bool) void {
+ if (id < MAX_PANES) term_pane.shellSpawned(p, id, prompt_marks);
+ if (p.settings.shell.effective.set(executable))
+ p.settings.shell.pending = false;
+ }
+
+ /// Small backend reads over the same plain state the builtins mutate.
+ /// Take the newest unresolved Font request once. `pending` remains true
+ /// until the host explicitly acknowledges success or rejection; requested
+ /// name/path remain queryable either way.
+ pub fn takeFontRequest(p: *Pardes) ?[]const u8 {
+ if (comptime !font_picker) return null;
+ if (!p.settings.font.pending or p.font_request_taken) return null;
+ const path = p.settings.font.requested_path.get();
+ if (path.len == 0) return null;
+ p.font_request_taken = true;
+ return path;
+ }
+
+ /// Record the face a GUI host is currently wearing. Boot, zoom and backing
+ /// scale changes are observations, not answers to a pending Font request:
+ /// only acknowledgeFont may resolve one after takeFontRequest handed it to
+ /// the host.
+ pub fn observeFont(
+ p: *Pardes,
+ effective_name: []const u8,
+ effective_size_hundredths: u16,
+ unit: FontSizeUnit,
+ ) bool {
+ if (comptime !font_picker) return false;
+ if (!p.settings.font.effective_name.set(effective_name)) return false;
+ p.settings.font.effective_size_hundredths = effective_size_hundredths;
+ p.settings.font.effective_size_unit = unit;
+ return true;
+ }
+
+ /// Resolve the request already handed to the GUI host. Requested and
+ /// effective tuples deliberately remain separate so Config can show both.
+ pub fn acknowledgeFont(
+ p: *Pardes,
+ effective_name: []const u8,
+ effective_size_hundredths: u16,
+ unit: FontSizeUnit,
+ ) bool {
+ if (comptime !font_picker) return false;
+ if (!p.settings.font.pending or !p.font_request_taken) return false;
+ const changed = !std.mem.eql(
+ u8,
+ p.settings.font.effective_name.get(),
+ effective_name,
+ ) or p.settings.font.effective_size_hundredths != effective_size_hundredths or
+ p.settings.font.effective_size_unit != unit;
+ if (!p.observeFont(effective_name, effective_size_hundredths, unit)) return false;
+ p.settings.font.pending = false;
+ p.font_request_taken = false;
+ // The host has already replaced its face/atlas at this boundary. Even
+ // when the new face measures to the same grid, a frozen old panel
+ // layer cannot truthfully be rasterized through those new glyphs.
+ if (changed) p.abandonPanelAnimations();
+ return true;
+ }
+
+ pub fn rejectFont(p: *Pardes) void {
+ if (comptime !font_picker) return;
+ if (!p.settings.font.pending or !p.font_request_taken) return;
+ p.settings.font.pending = false;
+ p.font_request_taken = false;
}
/// Post the transient message on `id`'s last row. Called by a SHELL once
@@ -5534,6 +4793,48 @@ pub const Pardes = struct {
p.setMessage(id, text);
}
+ /// Apply one watched-path snapshot to the payload which owns that path.
+ /// True means the pane now represents this successful host transaction;
+ /// native watchers commit their generation only then, so a transient PDF
+ /// reopen/allocation failure remains retryable.
+ fn applyWatchedFileChanged(p: *Pardes, id: u8, bytes: []const u8) bool {
+ if (id >= MAX_PANES) return false;
+ const pane = p.panes[id] orelse return false;
+ if (comptime pdf_enabled) if (pane.pdf != null) {
+ pdf_pane.reloadWatched(p, pane) catch |err| {
+ p.reportError(id, "PDF reload", err);
+ return false;
+ };
+ return true;
+ };
+ if (pane.file == null) return false;
+ file_pane.changed(p, id, bytes);
+ return if (p.panes[id]) |current|
+ if (current.file) |file| std.mem.eql(u8, file.content, bytes) else false
+ else
+ false;
+ }
+
+ /// Synchronous host seam for a watched file. Event.update routes through
+ /// the same payload operation, while native watchers use this spelling to
+ /// learn whether they may commit the observed disk generation.
+ pub fn reloadWatchedFile(p: *Pardes, id: u8, bytes: []const u8) bool {
+ p.invalidateLookHover(id);
+ const applied = p.applyWatchedFileChanged(id, bytes);
+ _ = p.scratch.reset(.retain_capacity);
+ p.sync();
+ return applied;
+ }
+
+ /// Content replacement invalidates a preview whose operand was expanded
+ /// from that pane. Payload modules call this for derived buffers they
+ /// refresh as part of the same transaction.
+ pub fn invalidateLookHover(p: *Pardes, id: usize) void {
+ if (p.lookHoverPane() != id) return;
+ p.raw_hover_intent = false;
+ p.cancelLookHover();
+ }
+
pub fn emit(p: *Pardes, e: Effect) void {
if (p.effects_len == p.effects.len) return;
const tail = (p.effects_head + p.effects_len) % p.effects.len;
@@ -5557,6 +4858,33 @@ pub const Pardes = struct {
// anything in this one can ask for it. Nothing frees it mid-update:
// handlers hand `rows` around for the length of a single update.
p.shell_rows.sweep(p.gpa);
+ // A preview describes the frame under an idle pointer. Any state
+ // change can replace that text or geometry, so it cancels; buttonless
+ // motion is the one event that debounces/re-arms it, and ticks only
+ // age the current candidate.
+ switch (ev) {
+ .tick => {},
+ .mouse => |m| if (!(m.button == .none and m.kind == .motion)) {
+ p.raw_hover_intent = false;
+ p.cancelLookHover();
+ },
+ .output => |o| if (p.lookHoverPane() == o.pane) {
+ p.raw_hover_intent = false;
+ p.cancelLookHover();
+ },
+ .file_changed => |changed| if (p.lookHoverPane() == changed.pane) {
+ p.raw_hover_intent = false;
+ p.cancelLookHover();
+ },
+ .pdf_scroll => |scroll| if (p.lookHoverPane() == scroll.pane) {
+ p.raw_hover_intent = false;
+ p.cancelLookHover();
+ },
+ else => {
+ p.raw_hover_intent = false;
+ p.cancelLookHover();
+ },
+ }
// A transient message is exactly as old as your last input: touch the
// keyboard or the mouse and it is gone, on every pane, because a
// message is a report and you have just proved you are back. Only
@@ -5587,11 +4915,12 @@ pub const Pardes = struct {
}
switch (ev) {
.resize => |sz| {
+ p.snap_panel_layout_once = true;
if (comptime pdf_enabled) {
- var before: [MAX_PANES]?PdfViewport = @splat(null);
+ var before: [MAX_PANES]?pdf_pane.Viewport = @splat(null);
for (p.panes, 0..) |slot, id| {
const pane = slot orelse continue;
- if (pane.pdf != null) before[id] = p.pdfViewport(pane);
+ if (pane.pdf != null) before[id] = pdf_pane.paneViewport(p, pane);
}
p.screen_w = sz.cols;
p.screen_h = sz.rows;
@@ -5604,8 +4933,8 @@ pub const Pardes = struct {
for (p.panes, 0..) |slot, id| {
const pane = slot orelse continue;
if (pane.pdf) |*pv| {
- if (!std.meta.eql(before[id], p.pdfViewport(pane))) {
- capturePdfLayoutAnchor(pv);
+ if (!std.meta.eql(before[id], pdf_pane.paneViewport(p, pane))) {
+ pdf_pane.captureLayoutAnchor(pv);
pv.layout_valid = false;
pv.search_reveal_pending = pv.search_query.len > 0;
}
@@ -5619,29 +4948,29 @@ pub const Pardes = struct {
},
.output => |o| {
const pane = p.panes[o.pane] orelse return;
- pane.appendTtyReplay(o.bytes);
- pane.stream.nextSlice(o.bytes);
- p.shell_rows.markStale(pane);
- // 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);
+ term_pane.feedOutput(p, pane, o.bytes);
},
.eof => |e| p.removePane(e.pane),
.lsp_resp => |r| p.lspResponse(r.id, r.rows),
.pipe_resp => |r| p.pipeResponse(r.id, r.success, r.outputs),
- .file_changed => |fc| p.fileChanged(fc.pane, fc.bytes),
+ .file_changed => |fc| _ = p.applyWatchedFileChanged(fc.pane, fc.bytes),
.key => |key| p.handleKey(key),
- .mouse => |m| p.handleMouse(m),
+ .mouse => |m| {
+ // Layout is only committed on RELEASE. Mark that one frame as
+ // a direct-manipulation snap before dragRelease mutates the
+ // weights; text/tag/PDF drags never touch panel presentation.
+ if (m.kind == .release) switch (p.drag) {
+ .border_v, .border_h, .move => p.snap_panel_layout_once = true,
+ else => {},
+ };
+ p.handleMouse(m);
+ },
.pdf_scroll => |scroll| {
if (comptime pdf_enabled) {
if (scroll.pane < MAX_PANES) {
if (p.panes[scroll.pane]) |pane| {
if (hasPdf(pane) and p.native_images)
- _ = p.scrollPdfDocument(pane, @floatCast(scroll.delta_pixels));
+ _ = pdf_pane.scrollPane(p, pane, @floatCast(scroll.delta_pixels));
}
}
}
@@ -5650,7 +4979,12 @@ pub const Pardes = struct {
.command => |line| _ = p.executeBuiltinLine(p.active, line),
.pinch => |scale| p.ov_pinch_scale = scale,
.touch_scroll => |delta| p.ov_touch_scroll_delta = delta,
- .tick => p.chrome_animation.advance(),
+ .pointer_leave => p.pointer_inside = false,
+ .tick => {
+ p.chrome_animation.advance();
+ p.advancePanelAnimations();
+ p.advanceLookHover();
+ },
}
_ = p.scratch.reset(.retain_capacity);
p.sync();
@@ -5774,9 +5108,9 @@ pub const Pardes = struct {
// ---- 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 live tag prefix: the pane's cwd/path, plus pane-local state whose
+ /// owner reports it (PDF view and image renderer choices). Those choices
+ /// mutate only through builtins 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.
///
@@ -5791,7 +5125,7 @@ pub const Pardes = struct {
"pdf {d}/{d} {s} PdfFit {s} PdfTint PdfSections {s}",
.{ pv.page + 1, pv.page_count, @tagName(pv.fit), @tagName(pv.tint), pv.path },
);
- if (pane.image) |iv| return std.fmt.allocPrint(arena, config.tag_image ++ " {s}", .{iv.path});
+ if (pane.image) |*state| return image_pane.tagPrefix(arena, state);
if (pane.file) |f| return arena.dupe(u8, f.path);
return arena.dupe(u8, pane.cwdSlice());
}
@@ -6234,52 +5568,6 @@ pub const Pardes = struct {
if (col) |c| p.topbar_col = @intCast(@min(c, modal.prevGrapheme(bar, bar.len)));
}
- fn fileLine(pane: *const Pane, row: i32) []const u8 {
- const f = pane.file orelse return "";
- if (row < 0) return "";
- return modal.lineSlice(f.content, @intCast(row));
- }
-
- fn fileDisplayOffset(pane: *const Pane, row: i32, from_raw: i32, to_raw: i32) i32 {
- const line = fileLine(pane, row);
- const from: usize = @intCast(@max(0, from_raw));
- const to: usize = @intCast(@max(0, to_raw));
- const from_display = fileRawDisplayCol(line, from);
- const to_display = fileRawDisplayCol(line, to);
- if (to_display >= from_display) return @intCast(to_display - from_display);
- return -@as(i32, @intCast(from_display - to_display));
- }
-
- fn fileDisplayEndOffset(pane: *const Pane, row: i32, from_raw: i32, at_raw: i32) i32 {
- const start = fileDisplayOffset(pane, row, from_raw, at_raw);
- const line = fileLine(pane, row);
- const at: usize = @intCast(@max(0, at_raw));
- if (at >= line.len) return start;
- return start + @as(i32, @intCast(file_pane.byteDisplayWidth(line[at]))) - 1;
- }
-
- fn fileByteAtDisplay(pane: *const Pane, row: i32, from_raw: i32, display_col: i32) i32 {
- const line = fileLine(pane, row);
- const from: usize = @intCast(@max(0, from_raw));
- const display: usize = @intCast(@max(0, display_col));
- if (from >= line.len) return @intCast(from +| display);
- return @intCast(from +| fileRawAtDisplay(line[from..], display));
- }
-
- fn renderedLineByteCol(pane: *const Pane, row: i32, line: []const u8, display_col: usize) usize {
- if (pane.file == null or row < BOX_H) return display_col;
- const prefix = @min(@as(usize, config.PREFIX_W), line.len);
- if (display_col <= prefix) return display_col;
- return prefix +| fileRawAtDisplay(line[prefix..], display_col - prefix);
- }
-
- fn renderedLineDisplayCol(pane: *const Pane, row: i32, line: []const u8, byte_col: usize) usize {
- if (pane.file == null or row < BOX_H) return byte_col;
- const prefix = @min(@as(usize, config.PREFIX_W), line.len);
- if (byte_col <= prefix) return byte_col;
- return prefix +| fileRawDisplayCol(line[prefix..], byte_col - prefix);
- }
-
/// tag + '\n' + body: the full selectable pane text (row 0 = the tag)
fn paneText(p: *Pardes, pane: *Pane) ![]u8 {
const arena = p.scratch.allocator();
@@ -6301,8 +5589,8 @@ pub const Pardes = struct {
var count_row: i32 = 0;
while (count_it.next()) |line| : (count_row += 1) {
if (count_row < r0 or count_row > r1) continue;
- const b0 = @min(renderedLineByteCol(pane, count_row, line, c0), line.len);
- const b1 = @min(renderedLineByteCol(pane, count_row, line, c1) + 1, line.len);
+ const b0 = @min(file_pane.renderedLineByteCol(pane, count_row, line, c0), line.len);
+ const b1 = @min(file_pane.renderedLineByteCol(pane, count_row, line, c1) + 1, line.len);
total += b1 - b0 + @intFromBool(selected > 0);
selected += 1;
}
@@ -6318,8 +5606,8 @@ pub const Pardes = struct {
at += 1;
}
first = false;
- const b0 = @min(renderedLineByteCol(pane, v, line, c0), line.len);
- const b1 = @min(renderedLineByteCol(pane, v, line, c1) + 1, line.len);
+ const b0 = @min(file_pane.renderedLineByteCol(pane, v, line, c0), line.len);
+ const b1 = @min(file_pane.renderedLineByteCol(pane, v, line, c1) + 1, line.len);
@memcpy(out[at..][0 .. b1 - b0], line[b0..b1]);
at += b1 - b0;
}
@@ -6337,25 +5625,39 @@ pub const Pardes = struct {
return (r > sr or (r == sr and c >= sc)) and (r < er or (r == er and c <= ec));
}
+ const ExpandedWord = struct { lo: usize, hi: usize };
+
+ /// The single spelling rule used by pointer and keyboard expansion.
+ /// Spaces inside an explicit @`command run` belong to that run; every
+ /// other separator is no operand. In particular, do not let wordBounds'
+ /// left scan make a blank cell borrow its neighbour.
+ fn expandedWord(line: []const u8, col: usize) ?ExpandedWord {
+ if (col >= line.len) return null;
+ const b = config.wordBounds(line, col);
+ const command_run = b.hi > b.lo and config.commandWord(line[b.lo..b.hi]) != null;
+ if (!config.isFileChar(line[col]) and !command_run) return null;
+ if (b.hi <= b.lo) return null;
+ return .{ .lo = b.lo, .hi = b.hi };
+ }
+
/// acme: a no-drag middle/right click expands to the word under it —
/// file-ish, or a whole `` @`...` `` run (config.wordBounds is the spelling)
- fn 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;
+ fn expandedSel(p: *Pardes, pane: *Pane, at: Sel) ?Sel {
+ var sl = at;
+ if (sl.c0 != sl.c1 or sl.r0 != sl.r1) return sl;
+ const text = p.paneText(pane) catch return null;
var it = std.mem.splitAny(u8, text, "\n");
var v: i32 = 0;
while (it.next()) |line| : (v += 1) {
if (v != sl.r0) continue;
const display_col: usize = @intCast(@max(0, sl.c0));
- const col = renderedLineByteCol(pane, v, line, display_col);
- if (col > line.len) return;
- const b = config.wordBounds(line, col);
- if (b.hi > b.lo) {
- sl.c0 = @intCast(renderedLineDisplayCol(pane, v, line, b.lo));
- sl.c1 = @intCast(renderedLineDisplayCol(pane, v, line, b.hi) - 1);
- }
- return;
+ const col = file_pane.renderedLineByteCol(pane, v, line, display_col);
+ const b = expandedWord(line, col) orelse return null;
+ sl.c0 = @intCast(file_pane.renderedLineDisplayCol(pane, v, line, b.lo));
+ sl.c1 = @intCast(file_pane.renderedLineDisplayCol(pane, v, line, b.hi) - 1);
+ return sl;
}
+ return null;
}
/// the word under the modal cursor as a pane-local selection (paneText
@@ -6363,7 +5665,7 @@ pub const Pardes = struct {
fn cursorWordSel(pane: *Pane) Sel {
const w = pane.wrapRow(pane.cur_row, pane.cur_col);
const vrow = w.row + @as(i32, BOX_H);
- const vcol = if (pane.file != null) fileDisplayOffset(pane, pane.cur_row, w.at, pane.cur_col) + @as(i32, config.PREFIX_W) else pane.cur_col;
+ const vcol = if (pane.file != null) file_pane.displayOffset(pane, pane.cur_row, w.at, pane.cur_col) + @as(i32, config.PREFIX_W) else pane.cur_col;
return .{ .state = .done, .c0 = vcol, .c1 = vcol, .r0 = vrow, .r1 = vrow };
}
@@ -6376,6 +5678,90 @@ pub const Pardes = struct {
return p.yankRows(pane, @min(pane.msel.r0, pane.msel.r1), @max(pane.msel.r0, pane.msel.r1));
}
+ const PointerOperand = struct {
+ /// Absolute body position corresponding to the pointed screen cell.
+ row: i32,
+ col: i32,
+ /// Exactly what a no-drag middle/right click will dispatch.
+ text: ?[]const u8 = null,
+ /// What hover paints. Null names the pane's live modal selection.
+ preview: ?Sel = null,
+ /// File words stay logical so a soft-wrapped operand is not cut into
+ /// unrelated rendered fragments. Null for tags and non-file panes.
+ file_word: ?FileWordSpan = null,
+ /// A newly expanded word is installed in the gesture's button slot;
+ /// an existing selection is only borrowed and must not replace it.
+ expanded: ?Sel = null,
+ };
+
+ /// Resolve a no-drag pointer gesture without mutating Pane. Click release
+ /// and delayed Look hover call this same production primitive, so existing
+ /// selection precedence and word expansion cannot drift into two policies.
+ fn pointerOperand(p: *Pardes, pane: *Pane, clicked: Sel) PointerOperand {
+ const visible = clicked.r0 - @as(i32, BOX_H);
+ const wrapped = pane.wrapAt(visible);
+ const row = wrapped.line;
+ const col = if (pane.file != null)
+ file_pane.byteAtRowDisplay(pane, wrapped.line, wrapped.at, clicked.c0 - @as(i32, config.PREFIX_W))
+ else
+ clicked.c0;
+ var result: PointerOperand = .{ .row = row, .col = col };
+
+ const kept = pane.sel[sel_slot];
+ if (kept.state == .done and
+ spanHas(clicked.r0, clicked.c0, kept.r0, kept.c0, kept.r1, kept.c1))
+ {
+ result.text = p.selectionText(pane, kept) catch null;
+ result.preview = kept;
+ return result;
+ }
+ if (pane.vsel.active and pane.vsel.explicit and
+ spanHas(row, col, pane.vsel.row, pane.vsel.col, pane.cur_row, pane.cur_col))
+ {
+ result.text = p.currentSelText(pane);
+ return result;
+ }
+ if (pane.msel.active and row >= @min(pane.msel.r0, pane.msel.r1) and
+ row <= @max(pane.msel.r0, pane.msel.r1))
+ {
+ result.text = p.currentSelText(pane);
+ return result;
+ }
+ if (pane.file != null and clicked.r0 >= BOX_H and clicked.r0 == clicked.r1 and clicked.c0 == clicked.c1) {
+ const line = file_pane.sourceLine(pane, row);
+ const source_col: usize = @intCast(@max(0, col));
+ const b = expandedWord(line, source_col) orelse return result;
+ const lo = std.math.cast(i32, b.lo) orelse return result;
+ const hi = std.math.cast(i32, b.hi) orelse return result;
+ result.text = line[b.lo..b.hi];
+ result.file_word = .{ .row = row, .lo = lo, .hi = hi };
+
+ // The transient middle/right slot still carries screen endpoints,
+ // but no text is reconstructed from this rectangular legacy
+ // shape. The logical span above is authoritative for dispatch and
+ // hover painting.
+ var expanded = clicked;
+ const first = pane.wrapRow(row, lo);
+ const last = pane.wrapRow(row, hi - 1);
+ if (first.row >= 0 and last.row >= 0) {
+ expanded.r0 = first.row + @as(i32, BOX_H);
+ expanded.c0 = @as(i32, config.PREFIX_W) + file_pane.displayOffset(pane, row, first.at, lo);
+ expanded.r1 = last.row + @as(i32, BOX_H);
+ expanded.c1 = @as(i32, config.PREFIX_W) + file_pane.displayEndOffset(pane, row, last.at, hi - 1);
+ }
+ result.preview = expanded;
+ result.expanded = expanded;
+ return result;
+ }
+
+ const expanded = p.expandedSel(pane, clicked) orelse return result;
+ result.text = p.selectionText(pane, expanded) catch null;
+ if (result.text == null or result.text.?.len == 0) return result;
+ result.preview = expanded;
+ result.expanded = expanded;
+ return result;
+ }
+
/// 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
@@ -6474,9 +5860,7 @@ pub const Pardes = struct {
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);
+ _ = p.layoutSplitColumn(p.active, p.active, false);
}
return;
}
@@ -6497,7 +5881,7 @@ pub const Pardes = struct {
if (tty_alt) return p.runBuiltin(.Last, p.active, "", null);
pane.mode = .normal;
pane.pending = 0;
- } else p.enterTty(p.active);
+ } else term_pane.enterTty(p, p.active);
return;
}
// `|` owns the same visible one-line tag input as search, but Enter
@@ -6556,8 +5940,7 @@ pub const Pardes = struct {
return;
}
}
- var sel = cursorWordSel(pane);
- p.expandSel(pane, &sel);
+ const sel = p.expandedSel(pane, cursorWordSel(pane)) orelse return;
const word = p.selectionText(pane, sel) catch return;
p.runBuiltin(cmd, p.active, "", word);
return;
@@ -6618,7 +6001,7 @@ pub const Pardes = struct {
}
p.handleInsert(pane, key);
},
- .tty => p.forwardKey(p.active, key),
+ .tty => term_pane.forwardKey(p, p.active, key),
}
}
@@ -6655,65 +6038,6 @@ pub const Pardes = struct {
}
}
- /// 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.cur_pinned and 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) {
- // ...and its COLUMN back through the hidden prompt. Outside
- // tty the prompt row shows the typed command left-hugged at
- // column 0 (term_pane.promptRow), so a cursor three cells into
- // what you can see is three cells past the prompt's END on the
- // real grid — and it is the real grid this pin addresses.
- var grid_col: i32 = @max(0, pane.cur_col);
- if (screen.pages.pin(.{ .viewport = .{ .x = 0, .y = @intCast(vp_row) } })) |row_pin| {
- if (row_pin.rowAndCell().row.semantic_prompt != .none) switch (term_pane.promptCut(row_pin)) {
- .cut => |cols| grid_col += @intCast(cols),
- .keep, .blank => {},
- };
- }
- if (screen.pages.pin(.{ .viewport = .{ .x = @intCast(grid_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
- // ...so hand the CURSOR over too. A pin is a row of the surface, and a
- // shell's rows scroll out from under one: pinned at the prompt, fifty
- // lines of output later the pin names scrollback nobody is looking at.
- // Unpinned it tracks the shell again, which is the only thing that
- // stays right — and it is what trackJump already calls "a place, not a
- // spot", so Last stops dragging the view back to that stale row on the
- // way in (it sat there until the next keystroke's echo scrolled it
- // down). Leaving tty re-pins wherever the shell left the cursor.
- pane.cur_pinned = false;
- 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.
@@ -6867,16 +6191,6 @@ pub const Pardes = struct {
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.pdf_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
@@ -6885,60 +6199,15 @@ pub const Pardes = struct {
/// 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 repeated growth or
- // second scan for newlines the index already found. 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 (pane.file) |*f| return .{ .lines = try file_pane.cursorLines(arena, pane, f), .row0 = 0 };
if (hasPdf(pane)) {
- const text = try p.ensurePdfText(pane);
- const count = std.mem.count(u8, text, "\n") + 1;
- const ls = try arena.alloc([]const u8, count);
- var it = std.mem.splitScalar(u8, text, '\n');
- var n: usize = 0;
- while (it.next()) |line| : (n += 1) ls[n] = line;
- return .{ .lines = ls, .row0 = 0 };
- }
- const rows = try term_pane.shellRows(p, pane);
- // No edit buffer: the loop below would copy `rows` into a second array
- // one slice at a time and return exactly what it was given. On a
- // scrollback of any size that copy is the keystroke.
- if (pane.ovl == null) return .{ .lines = rows, .row0 = 0 };
- // 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 count = last;
- if (pane.ovl) |o| {
- // The split below writes ONE line for an empty buffer, but
- // modal.lineCount("") is 0 — the same floor `n - 1` needs at the
- // paste site. Without it, killing a whole line (`A<C-u>`, `d%`)
- // sizes `ls` one short of what the loop writes, and on a buffer
- // covering the last row that is a length of zero.
- if (o.row >= 0 and @as(usize, @intCast(o.row)) < last)
- count = count - @min(@as(usize, @intCast(o.rows)), last - @as(usize, @intCast(o.row))) + @max(1, modal.lineCount(o.text));
- }
- const ls = try arena.alloc([]const u8, count);
- var n: usize = 0;
- 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| : (n += 1) ls[n] = ln;
- g += @intCast(o.rows - 1);
- continue;
+ if (comptime pdf_enabled) return .{
+ .lines = try pdf_pane.textLines(&pane.pdf.?, p.pdf_gpa, arena),
+ .row0 = 0,
};
- ls[n] = if (g < rows.len) rows[g] else "";
- n += 1;
+ unreachable;
}
- return .{ .lines = ls[0..n], .row0 = 0 };
+ return .{ .lines = try term_pane.cursorLines(p, pane), .row0 = 0 };
}
fn toModalCursor(pane: *Pane, pl: PaneLines) modal.Cursor {
@@ -6964,84 +6233,31 @@ pub const Pardes = struct {
/// 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);
- // The memo already holds these rows joined by '\n' — that is what its
- // `text` IS. The identity check is the same argument hxOff makes below:
- // `lines` came out of the cache untouched (no edit buffer stood in for
- // any row), so the join cannot have drifted from it.
- const c = &p.shell_rows;
- if (!c.stale and c.pane == pane and
- pl.lines.ptr == c.rows.ptr and pl.lines.len == c.rows.len) return c.text;
- const arena = p.scratch.allocator();
- var total: usize = if (pl.lines.len > 0) pl.lines.len - 1 else 0;
- for (pl.lines) |ln| total += ln.len;
- const buf = try arena.alloc(u8, total);
- var at: usize = 0;
- for (pl.lines, 0..) |ln, i| {
- if (i > 0) {
- buf[at] = '\n';
- at += 1;
- }
- @memcpy(buf[at..][0..ln.len], ln);
- at += ln.len;
+ if (hasPdf(pane)) {
+ if (comptime pdf_enabled) return pane.pdf.?.ensureText(p.pdf_gpa);
+ unreachable;
}
- return buf;
- }
-
- /// 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.
- ///
- /// ponytail: a terminal's motion surface is NOT one of the things with no
- /// index any more — shell_rows.rows are slices into shell_rows.text, so
- /// their offsets ARE the line starts, for free. Claiming them here needs
- /// `p`, which means threading it through paneOff/panePos/paneLineStart and
- /// their callers (paneRange, setPaneRange, the three motion helpers). Left
- /// undone deliberately: measured, that is a terminal key-down of 615us
- /// against 140us, and 0.6ms is not a keystroke anybody feels. Do it the
- /// day one of those scans shows up next to something that matters.
- 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;
+ return term_pane.flatSurface(p, pane, pl.lines);
}
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);
+ if (pane.file != null) return file_pane.textOffset(pane, text, c);
+ return modal.hxOff(text, c);
}
- /// 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];
+ if (pane.file != null) return file_pane.textLineStart(pane, text, row);
+ return modal.lineStartOffset(text, row);
+ }
+
+ fn paneLineCount(pane: *Pane, text: []const u8) usize {
+ if (pane.file != null) return file_pane.textLineCount(pane, text);
+ return modal.hxLineCount(text);
}
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] };
+ if (pane.file != null) return file_pane.textPosition(pane, text, off);
+ return modal.hxPos(text, off);
}
/// the current selection as a helix gap range over `text`, whose first
@@ -7795,10 +7011,9 @@ pub const Pardes = struct {
// 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);
+ const last_row = paneLineCount(pane, text) - 1;
+ const nline = if (down) @min(pos.row + @max(1, cnt), last_row) else pos.row -| @max(1, cnt);
+ const t = paneOff(pane, text, .{ .row = nline, .col = 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);
@@ -7818,7 +7033,7 @@ pub const Pardes = struct {
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 last_row: i32 = @intCast(paneLineCount(pane, text) - 1);
const cur = modal.hxCursor(text, range);
if (delta > 0) {
const snap: i32 = @max(0, @min(top + margin, last_row));
@@ -7839,7 +7054,7 @@ pub const Pardes = struct {
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_row: i32 = @intCast(paneLineCount(pane, 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) {
@@ -8358,70 +7573,6 @@ pub const Pardes = struct {
return pane.cwdSlice();
}
- const PdfSearchResult = struct { bytes: usize = 0, 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: []u8,
- ) !PdfSearchResult {
- if (comptime !pdf_enabled) return .{};
- const pv = &(pane.pdf orelse return .{});
- try 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 = try pv.document.search(p.pdf_gpa, page, pat);
- defer found.deinit(p.pdf_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;
- const row = try std.fmt.allocPrint(arena, "{s}:{d}:{d} {s}\n", .{
- shown, page + 1, hit_index + 1, snippet,
- });
- if (row.len > out.len - result.bytes) return result;
- if (start == .cursor and
- (page < pv.page or (page == pv.page and cursor_hit != null and hit_index <= cursor_hit.?)))
- result.anchor = result.rows;
- @memcpy(out[result.bytes..][0..row.len], row);
- result.bytes += row.len;
- 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.
@@ -8571,10 +7722,19 @@ pub const Pardes = struct {
} else if (kind == .find) {
out_len = try look.find(arena, dir, pat, out);
} else if (hasPdf(pane)) {
- const found = try p.searchPdf(pane, pat, start, out);
- out_len = found.bytes;
- nrows = found.rows;
- anchor = found.anchor;
+ if (comptime pdf_enabled) {
+ const found = try pdf_pane.searchRows(
+ &pane.pdf.?,
+ p.pdf_gpa,
+ arena,
+ pat,
+ start == .cursor,
+ out,
+ );
+ out_len = found.bytes;
+ nrows = found.rows;
+ anchor = found.anchor;
+ }
} else {
const pl = try p.paneCursorLines(pane);
// a real file names itself; a terminal or an output buffer has no path
@@ -8651,7 +7811,6 @@ pub const Pardes = struct {
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
@@ -8660,6 +7819,22 @@ pub const Pardes = struct {
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));
+ var realbuf: [4096]u8 = undefined;
+ const span = if (tr.commands)
+ wholeRowSpan(ln)
+ else
+ look.lookableLineSpan(ln, paneDir(rp), &realbuf);
+ if (span) |selected| {
+ rp.cur_col = @intCast(selected.start);
+ rp.look_at = .{
+ .row = r,
+ .col0 = @intCast(selected.start),
+ .col1 = @intCast(selected.end - 1),
+ };
+ } else {
+ rp.cur_col = 0;
+ rp.look_at = null;
+ }
// 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
@@ -9072,9 +8247,10 @@ pub const Pardes = struct {
/// never stood on, the first step ACQUIRES a position rather than moving
/// one — see lookStand. Every press after that is exact.)
///
- /// No walk state outside the panes themselves, which is what makes this
- /// robust where the old stepper was not: nothing to go stale when a search
- /// refills, a pane closes, or you move the cursor and step on from there.
+ /// Position stays in each pane (`look_at`); the one global serial records
+ /// only WHICH stream owns the next step after a Look moves focus away.
+ /// Serials make deletion/reuse stale safely, and refilling a list simply
+ /// re-arms that list without manufacturing a second cursor.
///
/// ONE MOTION, EVERYWHERE. Not a pane kind, not a buffer kind, not a mode:
/// n/N are this walk in all of them, which is the other half of making
@@ -9094,12 +8270,15 @@ pub const Pardes = struct {
var buf: [MAX_PANES]usize = undefined;
const order = p.lookWalkPanes(&buf);
if (order.len == 0) return;
- // Start where you ARE when that is somewhere the walk goes; otherwise
- // at its head, which after a look is the pane you looked FROM — the
- // look focused what it OPENED, and that is not a walk pane.
- const at: usize = for (order, 0..) |id, i| {
- if (id == p.active) break i;
- } else 0;
+ // A Look may move focus away from the list it came from. Continue the
+ // explicitly armed origin first; only a stale/missing owner falls back
+ // to the pane under focus and then the history head.
+ const owner = if (p.look_walk_owner) |serial| p.paneBySerial(serial) else null;
+ const active_at = std.mem.indexOfScalar(usize, order, p.active) orelse 0;
+ const at = if (owner) |wanted|
+ std.mem.indexOfScalar(usize, order, wanted) orelse active_at
+ else
+ active_at;
var from = lookStand(p.panes[order[at]] orelse return);
// ...then every OTHER pane once, in the direction of travel, entered
// at its edge — and `k == order.len` brings the starting pane round a
@@ -9131,79 +8310,16 @@ pub const Pardes = struct {
pane.cur_col = spot.col0;
pane.cur_pinned = true;
pane.look_at = spot;
+ p.look_walk_owner = pane.serial;
p.active = id;
pane.ensureCursorVisible();
}
- const EditText = struct { text: []u8, row0: i32 };
-
- /// Materialize or extend a terminal edit buffer with one exact allocation.
+ /// Route shared edit operations to a file's content or a terminal overlay.
fn editText(p: *Pardes, pane: *Pane, lo: i32, hi: i32, col: i32) ?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;
- const down = want_hi - (old.row + lines - 1);
- if (fresh or up > 0 or down > 0) {
- const rows = term_pane.shellRows(p, pane) catch return null;
- const row0 = old.row - @max(0, up);
- var total: usize = 0;
- var g: i32 = row0;
- while (g < old.row) : (g += 1)
- total += (if (@as(usize, @intCast(g)) < rows.len) rows[@intCast(g)].len else 0) + 1;
- total += if (fresh)
- (if (@as(usize, @intCast(old.row)) < rows.len) rows[@intCast(old.row)].len else 0)
- else
- old.text.len;
- var k: i32 = 0;
- while (k < down) : (k += 1) {
- const src = old.row + old.rows + k;
- total += 1 + if (@as(usize, @intCast(src)) < rows.len) rows[@intCast(src)].len else 0;
- }
- const text = p.gpa.alloc(u8, total) catch return null;
- var at: usize = 0;
- g = row0;
- while (g < old.row) : (g += 1) {
- const src = if (@as(usize, @intCast(g)) < rows.len) rows[@intCast(g)] else "";
- @memcpy(text[at..][0..src.len], src);
- at += src.len;
- text[at] = '\n';
- at += 1;
- }
- const middle = if (fresh)
- (if (@as(usize, @intCast(old.row)) < rows.len) rows[@intCast(old.row)] else "")
- else
- old.text;
- @memcpy(text[at..][0..middle.len], middle);
- at += middle.len;
- k = 0;
- while (k < down) : (k += 1) {
- const src_row = old.row + old.rows + k;
- const src = if (@as(usize, @intCast(src_row)) < rows.len) rows[@intCast(src_row)] else "";
- text[at] = '\n';
- at += 1;
- @memcpy(text[at..][0..src.len], src);
- at += src.len;
- }
- 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 };
+ return term_pane.editText(p, pane, lo, hi, col);
}
/// editText for an op whose selection can END on a line's newline cell:
@@ -9220,10 +8336,7 @@ pub const Pardes = struct {
/// 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);
+ term_pane.setEditText(p, pane, new);
}
const InsertAt = enum { at, append, line_start, line_end, open_below, open_above };
@@ -9597,7 +8710,10 @@ pub const Pardes = struct {
fn vselText(p: *Pardes, pane: *Pane) []const u8 {
const arena = p.scratch.allocator();
const b = vselBounds(pane);
- const text = p.surfaceText(pane) orelse return "";
+ const text = if (pane.file) |f| f.content else surface: {
+ const pl = p.paneCursorLines(pane) catch return "";
+ break :surface p.flatSurface(pane, pl) catch return "";
+ };
return modal.rangeText(arena, text, .{ .row = @intCast(@max(0, b.lo_row)), .col = @intCast(@max(0, b.lo_col)) }, .{ .row = @intCast(@max(0, b.hi_row)), .col = @intCast(@max(0, b.hi_col)) }) catch "";
}
@@ -9629,14 +8745,6 @@ pub const Pardes = struct {
return out;
}
- /// 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 {
@@ -10479,131 +9587,21 @@ pub const Pardes = struct {
pane.ensureCursorVisible();
}
- fn pushFileHistory(p: *Pardes, slots: *[UNDO_MAX]FileSnap, len: *usize, snap: FileSnap) void {
- if (len.* == slots.len) {
- p.gpa.free(slots[0].content);
- std.mem.copyForwards(FileSnap, slots[0 .. slots.len - 1], slots[1..]);
- len.* -= 1;
- }
- slots[len.*] = snap;
- len.* += 1;
- }
-
- fn pushEditHistory(p: *Pardes, slots: *[EDIT_UNDO_MAX]EditSnap, len: *usize, snap: EditSnap) void {
- if (len.* == slots.len) {
- if (slots[0].ovl) |o| p.gpa.free(o.text);
- std.mem.copyForwards(EditSnap, slots[0 .. slots.len - 1], slots[1..]);
- len.* -= 1;
- }
- slots[len.*] = snap;
- len.* += 1;
- }
-
fn pushUndo(p: *Pardes, pane: *Pane) void {
// one keystroke, one undo step — even when it edited at ten cursors.
if (p.multi_on and !p.multi_first) return;
- if (pane.file) |*f| {
- if (f.undo_len > 0 and std.mem.eql(u8, f.undo[f.undo_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,
- };
- p.pushFileHistory(&f.undo, &f.undo_len, snap);
- for (f.redo[0..f.redo_len]) |s| p.gpa.free(s.content);
- f.redo_len = 0;
- } else {
- const cur = pane.ovl orelse Ovl{ .rows = 0 };
- if (pane.ed_undo_len > 0) {
- const top = pane.ed_undo[pane.ed_undo_len - 1];
- 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;
- p.pushEditHistory(&pane.ed_undo, &pane.ed_undo_len, snap);
- for (pane.ed_redo[0..pane.ed_redo_len]) |sn| if (sn.ovl) |o| p.gpa.free(o.text);
- pane.ed_redo_len = 0;
- }
+ if (pane.file != null) return file_pane.pushUndo(p, pane);
+ term_pane.pushUndo(p, pane);
}
fn doUndo(p: *Pardes, pane: *Pane) void {
- if (pane.file) |*f| {
- if (f.undo_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,
- };
- p.pushFileHistory(&f.redo, &f.redo_len, cur);
- f.undo_len -= 1;
- const prev = f.undo[f.undo_len];
- file_pane.setContent(p, f, prev.content);
- file_pane.restoreSnap(pane, f, prev);
- return;
- }
- if (pane.ed_undo_len == 0) return;
- const cur = term_pane.snap(p, pane) orelse return;
- p.pushEditHistory(&pane.ed_redo, &pane.ed_redo_len, cur);
- pane.ed_undo_len -= 1;
- term_pane.restoreSnap(p, pane, pane.ed_undo[pane.ed_undo_len]);
+ if (pane.file != null) return file_pane.undo(p, pane);
+ term_pane.undo(p, pane);
}
fn doRedo(p: *Pardes, pane: *Pane) void {
- if (pane.file) |*f| {
- if (f.redo_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,
- };
- p.pushFileHistory(&f.undo, &f.undo_len, cur);
- f.redo_len -= 1;
- const next = f.redo[f.redo_len];
- file_pane.setContent(p, f, next.content);
- file_pane.restoreSnap(pane, f, next);
- return;
- }
- if (pane.ed_redo_len == 0) return;
- const cur = term_pane.snap(p, pane) orelse return;
- p.pushEditHistory(&pane.ed_undo, &pane.ed_undo_len, cur);
- pane.ed_redo_len -= 1;
- term_pane.restoreSnap(p, pane, pane.ed_redo[pane.ed_redo_len]);
- }
-
- /// 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,
- });
+ if (pane.file != null) return file_pane.redo(p, pane);
+ term_pane.redo(p, pane);
}
pub const ChromeTarget = struct { col: u16, row: u16 };
@@ -10631,7 +9629,7 @@ pub const Pardes = struct {
for (p.panes, 0..) |slot, id| {
if (slot == null) continue;
const rect = p.rects[id];
- const tag = if (p.tag_bottom) rect.y + rect.h -| BOX_H else rect.y;
+ const tag = if (p.settings.tag_bottom) rect.y + rect.h -| BOX_H else rect.y;
if (row == tag and col >= rect.x and col < rect.x + rect.w and col < rect.x + config.GUTTER)
return .{ .col = col, .row = tag };
}
@@ -10643,14 +9641,14 @@ pub const Pardes = struct {
if (col < p.col_x[column] or col >= p.col_x[column] + p.col_w[column]) continue;
for (0..p.col_n[column] -| 1) |index| {
const rect = p.rects[p.col_terms[column][index]];
- const handle = if (p.tag_bottom) rect.y +| rect.h else rect.y + rect.h -| 1;
+ const handle = if (p.settings.tag_bottom) rect.y +| rect.h else rect.y + rect.h -| 1;
if (row == handle) return .{ .col = col, .row = handle };
}
}
for (p.panes, 0..) |slot, id| {
if (slot == null) continue;
const rect = p.rects[id];
- const tag = if (p.tag_bottom) rect.y + rect.h -| BOX_H else rect.y;
+ const tag = if (p.settings.tag_bottom) rect.y + rect.h -| BOX_H else rect.y;
if (row == tag and col >= rect.x and col < rect.x + rect.w)
return .{ .col = col, .row = tag };
}
@@ -10662,24 +9660,397 @@ pub const Pardes = struct {
if (col < p.col_x[column] or col >= p.col_x[column] + p.col_w[column]) continue;
for (0..p.col_n[column] -| 1) |index| {
const rect = p.rects[p.col_terms[column][index]];
- const handle = if (p.tag_bottom) rect.y +| rect.h else rect.y + rect.h -| 1;
+ const handle = if (p.settings.tag_bottom) rect.y +| rect.h else rect.y + rect.h -| 1;
if (@max(row, handle) - @min(row, handle) == 1) return .{ .col = col, .row = handle };
}
}
return null;
}
+ fn paneAt(p: *const Pardes, col: u16, row: u16) ?usize {
+ for (p.panes, 0..) |slot, id| {
+ if (slot == null) continue;
+ const r = p.rects[id];
+ if (col >= r.x and col < r.x + r.w and row >= r.y and row < r.y + r.h) return id;
+ }
+ return null;
+ }
+
+ const PointerTextSelection = struct {
+ sel: Sel,
+ on_tag: bool,
+ };
+
+ /// Map one physical grid cell into the selection coordinate space shared
+ /// by Look, Exec and the hover preview. Geometry lives here once: the tag
+ /// is always selection row zero and body rows start at BOX_H, even when
+ /// Tagbottom swaps their physical positions.
+ fn pointerTextSelection(p: *const Pardes, id: usize, col: u16, row: u16) ?PointerTextSelection {
+ if (p.panes[id] == null) return null;
+ const r = p.rects[id];
+ if (col < r.x + config.GUTTER or col >= r.x + r.w or row < r.y or row >= r.y + r.h) return null;
+ const tag_y = if (p.settings.tag_bottom) r.y + r.h -| BOX_H else r.y;
+ const body_y = if (p.settings.tag_bottom) r.y else r.y + BOX_H;
+ const on_tag = row >= tag_y and row < tag_y + BOX_H;
+ const c: i32 = @as(i32, col) - @as(i32, r.x + config.GUTTER);
+ const v: i32 = if (on_tag) 0 else @as(i32, row) - @as(i32, body_y) + @as(i32, BOX_H);
+ return .{
+ .sel = .{ .state = .dragging, .c0 = c, .c1 = c, .r0 = v, .r1 = v },
+ .on_tag = on_tag,
+ };
+ }
+
+ fn cancelLookHover(p: *Pardes) void {
+ if (comptime pdf_enabled) if (p.pdf_hover_preview) |*preview| {
+ if (preview.pane < p.panes.len) if (p.panes[preview.pane]) |pane| {
+ if (pane.serial == preview.serial) if (pane.pdf) |*pv|
+ pv.invalidateRaster(preview.probe.page);
+ };
+ preview.deinit(p.pdf_gpa);
+ p.pdf_hover_preview = null;
+ };
+ p.look_hover_wait = null;
+ p.look_hover_preview = null;
+ }
+
+ fn lookHoverPane(p: *const Pardes) ?usize {
+ if (comptime pdf_enabled) if (p.pdf_hover_preview) |preview| return preview.pane;
+ if (p.look_hover_preview) |preview| return preview.pane;
+ if (p.look_hover_wait) |waiting| return waiting.pane;
+ return null;
+ }
+
+ /// Debounce by semantic grid cell rather than raw motion events. A host
+ /// may report the same pixel position every frame; those reports must not
+ /// postpone the preview forever.
+ fn noteLookHover(p: *Pardes, col: u16, row: u16) void {
+ const delay = config.look_preview_delay_frames orelse return;
+ _ = delay;
+ const id = p.paneAt(col, row) orelse return p.cancelLookHover();
+ const pane = p.panes[id] orelse return p.cancelLookHover();
+ _ = p.pointerTextSelection(id, col, row) orelse return p.cancelLookHover();
+
+ if (comptime pdf_enabled) if (p.pdf_hover_preview) |shown|
+ if (shown.col == col and shown.row == row and shown.pane == id and shown.serial == pane.serial) return;
+ if (p.look_hover_preview) |shown|
+ if (shown.col == col and shown.row == row and shown.pane == id and shown.serial == pane.serial) return;
+ if (p.look_hover_wait) |waiting|
+ if (waiting.col == col and waiting.row == row and waiting.pane == id and waiting.serial == pane.serial) return;
+
+ p.cancelLookHover();
+ p.look_hover_wait = .{ .col = col, .row = row, .pane = id, .serial = pane.serial };
+ }
+
+ /// Re-evaluate a stationary pointer against the frame the host just
+ /// presented. `noteLookHover` preserves an existing delay or preview only
+ /// when this still resolves to the same canonical cell and pane lifetime;
+ /// moving to another semantic target re-arms it, and an invisible panel
+ /// cell cancels it.
+ fn refreshLookHoverFromRaw(p: *Pardes) void {
+ if (!p.pointer_inside or !p.raw_hover_intent) return;
+ const mapped = p.presentedPointer(p.pointer_raw_col, p.pointer_raw_row) orelse
+ return p.cancelLookHover();
+ p.hover_col = mapped.col;
+ p.hover_row = mapped.row;
+ p.noteLookHover(mapped.col, mapped.row);
+ }
+
+ fn advanceLookHover(p: *Pardes) void {
+ const delay = config.look_preview_delay_frames orelse return;
+ const waiting = if (p.look_hover_wait) |*w| w else return;
+ waiting.frames +|= 1;
+ if (waiting.frames < delay) return;
+ const pane = p.panes[waiting.pane] orelse return p.cancelLookHover();
+ if (pane.serial != waiting.serial) return p.cancelLookHover();
+ const pointed = p.pointerTextSelection(waiting.pane, waiting.col, waiting.row) orelse
+ return p.cancelLookHover();
+ if (comptime pdf_enabled) if (!pointed.on_tag and pdf_pane.paneNativeReady(p, pane)) {
+ const probe = pdf_pane.probeAt(p, pane, waiting.col, waiting.row) orelse
+ return p.cancelLookHover();
+ const ready = waiting.*;
+ p.look_hover_wait = null;
+ p.pdf_hover_preview = .{
+ .col = ready.col,
+ .row = ready.row,
+ .pane = ready.pane,
+ .serial = ready.serial,
+ .probe = probe,
+ };
+ pane.pdf.?.invalidateRaster(probe.page);
+ return;
+ };
+ const operand = p.pointerOperand(pane, pointed.sel);
+ if (operand.text == null or operand.text.?.len == 0) return p.cancelLookHover();
+ var preview = operand.preview;
+ if (preview) |*sel| sel.state = .done;
+ p.look_hover_preview = .{
+ .col = waiting.col,
+ .row = waiting.row,
+ .pane = waiting.pane,
+ .serial = waiting.serial,
+ .sel = preview,
+ .file_word = operand.file_word,
+ };
+ p.look_hover_wait = null;
+ }
+
+ const PointerCell = struct { col: u16, row: u16 };
+
+ fn boxContainsCell(box: panel_animation.Box, col: u16, row: u16) bool {
+ const x = @as(f32, @floatFromInt(col)) + 0.5;
+ const y = @as(f32, @floatFromInt(row)) + 0.5;
+ return x >= box.x and x < box.x + box.w and y >= box.y and y < box.y + box.h;
+ }
+
+ /// Input follows the panel pixels the shaders/TTY compositor present.
+ /// Opening panels are drawn last, so they are probed first here too. A
+ /// cell in final geometry which has not appeared yet is deliberately not
+ /// clickable; otherwise a user could act on invisible content.
+ fn presentedPointer(p: *const Pardes, col: u16, row: u16) ?PointerCell {
+ if (p.panel_presentation_pending) return null;
+ // Closing pixels belong to a dead pane lifetime. They occlude the
+ // canonical survivor underneath while visible, but can never dispatch
+ // into either that survivor or a slot which reused the old pane id.
+ var closing = p.npresented_closing_panel_tracks;
+ while (closing > 0) {
+ closing -= 1;
+ const track = p.presented_closing_panel_tracks[closing];
+ if (!track.active()) continue;
+ if (boxContainsCell(track.contentBox(), col, row) and
+ boxContainsCell(track.presented(), col, row)) return null;
+ }
+ const phases = [_]panel_animation.Phase{ .opening, .moving };
+ for (phases) |phase| {
+ // Backends paint pane slots forward within a phase. Probe them in
+ // reverse so overlapping transition quads address the top pixel.
+ var index = p.presented_panel_tracks.len;
+ while (index > 0) {
+ index -= 1;
+ const track = p.presented_panel_tracks[index] orelse continue;
+ if (!track.active() or track.phase != phase) continue;
+ switch (track.effect) {
+ .slide, .zoom => {
+ const shown = track.presented();
+ if (shown.w <= 0 or shown.h <= 0 or !boxContainsCell(shown, col, row)) continue;
+ const x = @as(f32, @floatFromInt(col)) + 0.5;
+ const y = @as(f32, @floatFromInt(row)) + 0.5;
+ const u = std.math.clamp((x - shown.x) / shown.w, 0, 0.999_999);
+ const v = std.math.clamp((y - shown.y) / shown.h, 0, 0.999_999);
+ const logical_x: i32 = @intFromFloat(@floor(track.to.x + u * track.to.w));
+ const logical_y: i32 = @intFromFloat(@floor(track.to.y + v * track.to.h));
+ return .{
+ .col = @intCast(std.math.clamp(logical_x, 0, @as(i32, p.screen_w -| 1))),
+ .row = @intCast(std.math.clamp(logical_y, 0, @as(i32, p.screen_h -| 1))),
+ };
+ },
+ .vertical => {
+ const clip = track.contentBox();
+ if (!boxContainsCell(clip, col, row)) continue;
+ const shown = track.presented();
+ if (shown.w <= 0 or shown.h <= 0 or !boxContainsCell(shown, col, row))
+ return null;
+ const x = @as(f32, @floatFromInt(col)) + 0.5;
+ const y = @as(f32, @floatFromInt(row)) + 0.5;
+ const u = std.math.clamp((x - shown.x) / shown.w, 0, 0.999_999);
+ const v = std.math.clamp((y - shown.y) / shown.h, 0, 0.999_999);
+ return .{
+ .col = @intFromFloat(@floor(clip.x + u * clip.w)),
+ .row = @intFromFloat(@floor(clip.y + v * clip.h)),
+ };
+ },
+ .dissolve, .ascii => {
+ if (!boxContainsCell(track.to, col, row)) continue;
+ if (!p.panelCellChanged(col, row))
+ return .{ .col = col, .row = row };
+ const relative_col: u16 = @intFromFloat(@floor(
+ @as(f32, @floatFromInt(col)) + 0.5 - track.to.x,
+ ));
+ const relative_row: u16 = @intFromFloat(@floor(
+ @as(f32, @floatFromInt(row)) + 0.5 - track.to.y,
+ ));
+ const visible = switch (track.effect) {
+ .dissolve => panel_animation.dissolveRevealed(
+ track.serial,
+ relative_col,
+ relative_row,
+ track.amount(),
+ ),
+ .ascii => panel_animation.asciiCell(
+ track.serial,
+ relative_col,
+ relative_row,
+ track.amount(),
+ ).reveal,
+ else => unreachable,
+ };
+ return if (visible) .{ .col = col, .row = row } else null;
+ },
+ .off => {},
+ }
+ }
+ }
+ for (p.presented_panel_tracks) |maybe| {
+ const track = maybe orelse continue;
+ if (!track.active() or (track.effect != .slide and track.effect != .zoom and
+ track.effect != .vertical)) continue;
+ if (boxContainsCell(track.to, col, row)) return null;
+ }
+ return .{ .col = col, .row = row };
+ }
+
+ /// Commit the exact panel samples a backend successfully presented. An
+ /// empty slice means that backend drew the canonical grid directly.
+ /// Records are keyed by pane slot so hit-test order stays identical to the
+ /// renderer even when a native ABI publishes them in paint order.
+ pub fn acknowledgePanelPresentation(p: *Pardes, tracks: []const panel_animation.Track) void {
+ var presented: [MAX_PANES]?panel_animation.Track = @splat(null);
+ var presented_closing: [MAX_PANES]panel_animation.Track = undefined;
+ var nclosing: usize = 0;
+ var layout: [MAX_PANES]?LayoutSnapshot = if (p.submitted_panel_layout_ready)
+ p.submitted_panel_layout
+ else
+ @splat(null);
+ if (!p.submitted_panel_layout_ready) for (p.panes, 0..) |slot, id| {
+ const pane = slot orelse continue;
+ layout[id] = .{ .serial = pane.serial, .box = panelBox(p.rects[id]) };
+ };
+ for (&layout, 0..) |*snapshot, id| if (snapshot.*) |saved| {
+ const pane = p.panes[id] orelse {
+ snapshot.* = null;
+ continue;
+ };
+ if (pane.serial != saved.serial) snapshot.* = null;
+ };
+ for (tracks) |track| {
+ if (track.phase == .closing) {
+ const current = for (p.closing_panel_tracks[0..p.nclosing_panel_tracks]) |candidate| {
+ if (candidate.serial == track.serial and candidate.pane == track.pane and
+ candidate.effect == track.effect) break true;
+ } else false;
+ if (!current or !track.active() or nclosing == presented_closing.len) continue;
+ presented_closing[nclosing] = track;
+ nclosing += 1;
+ continue;
+ }
+ const id: usize = track.pane;
+ if (id >= p.panes.len or !track.active()) continue;
+ const pane = p.panes[id] orelse continue;
+ if (pane.serial != track.serial) continue;
+ presented[id] = track;
+ layout[id] = .{ .serial = track.serial, .box = track.visualBox() };
+ }
+ p.presented_panel_tracks = presented;
+ p.presented_closing_panel_tracks = presented_closing;
+ p.npresented_closing_panel_tracks = nclosing;
+ p.presented_panel_layout = layout;
+ p.panel_presentation_ready = true;
+ p.panel_presentation_pending = false;
+ // An empty acknowledgement means canonical cells, not an invisible
+ // transition. Retire any producer records a direct/headless fallback
+ // deliberately did not present, then make this exact frame the future
+ // old-grid baseline. Animated acknowledgements keep their baseline
+ // frozen until the canonical endpoint is actually presented.
+ if (tracks.len == 0) {
+ p.panel_tracks = @splat(null);
+ p.nclosing_panel_tracks = 0;
+ p.panel_diff_pending = false;
+ p.panel_diff_ready = false;
+ p.rememberPresentedCells();
+ }
+ // A held gesture follows the pixels just acknowledged even when the
+ // physical pointer stayed still. Its payload already is the last
+ // successfully mapped endpoint, so an invisible sample simply leaves
+ // that endpoint intact for a later frame or balanced release.
+ if (p.pointer_inside and p.drag != .none) {
+ if (p.presentedPointer(p.pointer_raw_col, p.pointer_raw_row)) |mapped| {
+ p.hover_col = mapped.col;
+ p.hover_row = mapped.row;
+ p.dragUpdate(mapped.col, mapped.row);
+ }
+ }
+ p.refreshLookHoverFromRaw();
+ }
+
+ fn rememberPresentedCells(p: *Pardes) void {
+ p.panel_diff_pending = false;
+ p.panel_diff_ready = false;
+ const cells = p.surface.cells;
+ if (cells.len == 0) {
+ p.presented_cells_valid = false;
+ p.presented_cells_layout = @splat(null);
+ return;
+ }
+ if (p.presented_cells.len != cells.len) {
+ const next = p.gpa.alloc(Cell, cells.len) catch {
+ p.presented_cells_valid = false;
+ p.presented_cells_layout = @splat(null);
+ return;
+ };
+ if (p.presented_cells.len > 0) p.gpa.free(p.presented_cells);
+ p.presented_cells = next;
+ }
+ @memcpy(p.presented_cells, cells);
+ p.presented_cells_cols = p.surface.cols;
+ p.presented_cells_rows = p.surface.rows;
+ p.presented_cells_valid = true;
+ p.presented_cells_layout = if (p.submitted_panel_layout_ready)
+ p.submitted_panel_layout
+ else
+ @splat(null);
+ }
+
+ fn panelCellChanged(p: *const Pardes, col: u16, row: u16) bool {
+ if (!p.panel_diff_ready or col >= p.screen_w or row >= p.screen_h or
+ p.panel_changed_cells.len != @as(usize, p.screen_w) * p.screen_h) return false;
+ return p.panel_changed_cells[@as(usize, row) * p.screen_w + col] != 0;
+ }
+
+ /// A backend is about to replace an unpresentable animated frame with the
+ /// canonical grid. Stop producer tracks too, so a later successful retry
+ /// cannot resume halfway through an animation after canonical was shown.
+ pub fn abandonPanelAnimations(p: *Pardes) void {
+ p.panel_tracks = @splat(null);
+ p.nclosing_panel_tracks = 0;
+ p.presented_panel_tracks = @splat(null);
+ p.npresented_closing_panel_tracks = 0;
+ p.panel_diff_pending = false;
+ p.panel_diff_ready = false;
+ p.panel_presentation_pending = p.panel_presentation_ready;
+ p.cancelLookHover();
+ }
+
+ /// A native scene pass proved unavailable. Config reads these exact bits,
+ /// and the idle-clock predicate stops immediately once they are clear.
+ pub fn disableSceneEffects(p: *Pardes) void {
+ p.settings.scene_effects = .{};
+ }
+
fn handleMouse(p: *Pardes, m: Mouse) void {
- const mcol = @min(m.col, p.screen_w -| 1);
- const mrow = @min(m.row, p.screen_h -| 1);
+ const raw_col = @min(m.col, p.screen_w -| 1);
+ const raw_row = @min(m.row, p.screen_h -| 1);
+ p.pointer_raw_col = raw_col;
+ p.pointer_raw_row = raw_row;
+ p.pointer_inside = true;
+ if (m.button == .none and m.kind == .motion) {
+ p.raw_hover_intent = true;
+ p.refreshLookHoverFromRaw();
+ return;
+ }
+ p.raw_hover_intent = false;
+ const mapped = p.presentedPointer(raw_col, raw_row) orelse {
+ p.cancelLookHover();
+ // A disappearing transition cell may hide the release, but the
+ // press it balances already has a last valid endpoint in Drag.
+ // End only the gesture owned by this exact physical button.
+ if (m.kind == .release) p.dragRelease(m.button);
+ return;
+ };
+ const mcol = mapped.col;
+ const mrow = mapped.row;
p.hover_col = mcol;
p.hover_row = mrow;
- const hovered: ?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;
+ const hovered = p.paneAt(mcol, mrow);
// any press that is NOT the execute+select chord abandons a pending
// chord argument
@@ -10706,7 +10077,7 @@ pub const Pardes = struct {
const id = hovered orelse return;
const pane = p.panes[id].?;
if (hasPdf(pane))
- p.pdfVerticalWheel(pane, if (m.button == .wheel_up) -1 else 1)
+ pdf_pane.verticalWheel(p, pane, if (m.button == .wheel_up) -1 else 1)
else
pane.scrollBy(if (m.button == .wheel_up) -config.wheel_rows else config.wheel_rows);
},
@@ -10720,10 +10091,10 @@ pub const Pardes = struct {
const id = hovered orelse return;
const pane = p.panes[id].?;
if (hasPdf(pane)) {
- p.pdfHorizontalWheel(pane, if (m.button == .wheel_right) 1 else -1);
+ pdf_pane.horizontalWheel(p, pane, if (m.button == .wheel_right) 1 else -1);
// ponytail: no right clamp — overscroll shows blank and the
// next cursor move or left wheel pulls it back
- } else if (pane.file != null and !p.wrap_on) {
+ } else if (pane.file != null and !p.settings.wrap) {
// wrapped there is nothing off to the right to reach
pane.hscroll = @max(0, pane.hscroll + (if (m.button == .wheel_right) config.wheel_cols else -config.wheel_cols));
}
@@ -10744,7 +10115,11 @@ pub const Pardes = struct {
// acme 3-1: a select tap during a LOOK drag cancels the
// look; the rest of the gesture is inert
if (p.drag == .select and p.drag.select.button == config.look_button) {
- if (p.panes[p.drag.select.id]) |t| t.sel[@intFromEnum(config.look_button)].state = .none;
+ if (p.panes[p.drag.select.id]) |t| {
+ t.sel[@intFromEnum(config.look_button)].state = .none;
+ if (comptime pdf_enabled)
+ pdf_pane.pointerCancel(t, p.drag.select.pdf);
+ }
p.drag = .none;
return;
}
@@ -10754,7 +10129,7 @@ pub const Pardes = struct {
// pair's h-handle, and the box must stay grabbable to move
if (hovered) |mid| {
const mr = p.rects[mid];
- const mtag = if (p.tag_bottom) mr.y + mr.h -| BOX_H else mr.y;
+ const mtag = if (p.settings.tag_bottom) mr.y + mr.h -| BOX_H else mr.y;
if (mcol < mr.x + config.GUTTER and mrow >= mtag and mrow < mtag + BOX_H) {
p.drag = .{ .move = .{ .id = mid, .cur_x = mcol, .cur_y = mrow } };
return;
@@ -10798,8 +10173,8 @@ pub const Pardes = struct {
// the tag and BOX_H upward for the body whichever end they
// are at, so the mapping happens HERE and everything
// downstream of a Sel is untouched.
- const tag_y = if (p.tag_bottom) r.y + r.h -| BOX_H else r.y;
- const body_y = if (p.tag_bottom) r.y else r.y + BOX_H;
+ const tag_y = if (p.settings.tag_bottom) r.y + r.h -| BOX_H else r.y;
+ const body_y = if (p.settings.tag_bottom) r.y else r.y + BOX_H;
if (mcol < r.x + config.GUTTER) {
// gutter scrollbar: a left click scrolls UP to that row
p.active = id;
@@ -10819,8 +10194,6 @@ pub const Pardes = struct {
const sc: i32 = @as(i32, mcol) - @as(i32, r.x + config.GUTTER);
const v: i32 = @as(i32, mrow) - @as(i32, body_y) + @as(i32, BOX_H);
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
@@ -10829,7 +10202,14 @@ pub const Pardes = struct {
.id = id,
.button = config.select_button,
.ctrl = m.ctrl,
- .pdf = if (comptime pdf_enabled) .{ .native = pdf_native } else .{},
+ .pdf = pdf_pane.pointerStart(
+ p,
+ pane,
+ config.select_button,
+ mcol,
+ mrow,
+ false,
+ ),
} };
}
},
@@ -10848,7 +10228,11 @@ pub const Pardes = struct {
// acme 2-3 / 3-2: tapping the OTHER of execute/look during
// an execute or look drag cancels it (textselect23's mask)
if (p.drag == .select) {
- if (p.panes[p.drag.select.id]) |t| t.sel[@intFromEnum(p.drag.select.button)].state = .none;
+ if (p.panes[p.drag.select.id]) |t| {
+ t.sel[@intFromEnum(p.drag.select.button)].state = .none;
+ if (comptime pdf_enabled)
+ pdf_pane.pointerCancel(t, p.drag.select.pdf);
+ }
p.drag = .none;
return;
}
@@ -10868,7 +10252,7 @@ pub const Pardes = struct {
// no builtin and therefore went to a shell.
const named = std.meta.stringToEnum(Builtin, word);
const held = if (named) |b|
- (if (takesArg(b)) p.heldSelection(p.active) else null)
+ (if (builtins.registry.takesArg(b)) p.heldSelection(p.active) else null)
else
p.heldSelection(p.active); // not a builtin: an ordinary acme 2-1
if (word.len > 0) _ = p.execute(p.active, p.withArg(word, held));
@@ -10880,8 +10264,8 @@ pub const Pardes = struct {
const pane = p.panes[id] orelse return;
// the tag row and the body's first, exactly as the select
// arm above derives them
- const tag_y = if (p.tag_bottom) r.y + r.h -| BOX_H else r.y;
- const body_y = if (p.tag_bottom) r.y else r.y + BOX_H;
+ const tag_y = if (p.settings.tag_bottom) r.y + r.h -| BOX_H else r.y;
+ const body_y = if (p.settings.tag_bottom) r.y else r.y + BOX_H;
const on_tag = mrow >= tag_y and mrow < tag_y + BOX_H;
if (mcol < r.x + config.GUTTER and !on_tag) {
// gutter: right scrolls DOWN to here, mirroring left's up;
@@ -10890,31 +10274,28 @@ pub const Pardes = struct {
const local = @as(i32, mrow) - @as(i32, body_y);
if (m.button == config.look_button) p.active = id;
pane.scrollBy(if (m.button == config.look_button) local else -local);
- } else if (mcol >= r.x + config.GUTTER) {
+ } else if (p.pointerTextSelection(id, mcol, mrow)) |pointed| {
if (m.button == config.look_button) p.active = id;
- const c: i32 = @as(i32, mcol) - @as(i32, r.x + config.GUTTER);
- // unlike the select button, execute and look sweep the
- // TAG through this same Sel — that is how middle-clicking
- // Del on a tagline runs it — so the tag row is row 0 and
- // everything else counts from BOX_H, wherever the tag is
- // physically drawn.
- const v: i32 = if (on_tag) 0 else @as(i32, mrow) - @as(i32, body_y) + @as(i32, BOX_H);
- 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)
- !on_tag 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 .{},
- } };
+ // Click and delayed hover enter through the same physical
+ // cell mapper. From here on both also share expandedSel;
+ // hover merely stores the result outside Pane.sel.
+ pane.sel[@intFromEnum(m.button)] = pointed.sel;
+ p.drag = .{
+ .select = .{
+ .id = id,
+ .button = m.button,
+ // A native body gesture stays native even when its
+ // point later misses a letterbox or selection.
+ .pdf = pdf_pane.pointerStart(
+ p,
+ pane,
+ m.button,
+ mcol,
+ mrow,
+ pointed.on_tag,
+ ),
+ },
+ };
}
},
.drag => p.dragUpdate(mcol, mrow),
@@ -10948,7 +10329,7 @@ pub const Pardes = struct {
if (d.corner) |k| if (k.idx + 1 < p.col_n[k.col]) {
const a = p.rects[p.col_terms[k.col][k.idx]];
const b = p.rects[p.col_terms[k.col][k.idx + 1]];
- d.cur_y = clampBorderRow(a.y, a.h, b.h, mrow, p.tag_bottom);
+ d.cur_y = clampBorderRow(a.y, a.h, b.h, mrow, p.settings.tag_bottom);
} else {
d.cur_y = mrow;
};
@@ -10959,7 +10340,7 @@ pub const Pardes = struct {
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, p.tag_bottom);
+ d.cur_y = clampBorderRow(a.y, a.h, b.h, mrow, p.settings.tag_bottom);
} else d.cur_y = mrow;
},
.move => |*d| {
@@ -10985,19 +10366,15 @@ pub const Pardes = struct {
// .tag drag) stays on the tag, which is one line anyway.
// The cost is that a body sweep can no longer be extended
// onto the bottom tagline to pick up the tag text.
- if (p.tag_bottom) {
+ if (p.settings.tag_bottom) {
const body_h = r.h -| BOX_H;
pane.sel[b].r1 = if (pane.sel[b].r0 < BOX_H or body_h == 0)
0
else
@as(i32, @min(@max(mrow, r.y), r.y + body_h - 1)) - @as(i32, r.y) + @as(i32, BOX_H);
} else pane.sel[b].r1 = @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;
- }
- }
+ if (comptime pdf_enabled)
+ pdf_pane.pointerUpdate(&s.pdf, p, pane, mcol, mrow);
}
},
.tag => |d| {
@@ -11014,75 +10391,53 @@ pub const Pardes = struct {
}
}
- 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);
+ fn dispatchPointerBuiltin(
+ p: *Pardes,
+ id: usize,
+ cmd: Builtin,
+ text: ?[]const u8,
+ ) void {
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));
+ const operand = text orelse return;
+ p.runBuiltin(cmd, id, "", p.withArg(operand, 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;
+ // A different button's release belongs to a chord or another host
+ // stream; only the button which began this gesture may end it.
+ if (p.drag.button() != button) return;
defer p.drag = .none;
switch (p.drag) {
.border_v => |d| {
const c = d.left_col;
- if (c + 1 < p.ncol) {
- const combined: f32 = @floatFromInt(p.col_w[c] + p.col_w[c + 1]);
+ const old_seam = p.col_x[c] + p.col_w[c] -| 1;
+ if (c + 1 < p.ncol and d.cur_x != old_seam) {
+ const combined: u64 = p.col_w[c] + p.col_w[c + 1];
// the handle is the left pane's LAST column (inclusive), so
// the new width includes it — a no-drag click changes nothing
- var nl: 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));
+ var nl: u64 = (d.cur_x + 1) -| p.col_x[c];
+ nl = std.math.clamp(nl, config.MINW, @max(@as(u64, config.MINW), combined -| config.MINW));
const pair = p.col_weight[c] + p.col_weight[c + 1];
- p.col_weight[c] = pair * (nl / combined);
- p.col_weight[c + 1] = pair - p.col_weight[c];
+ if (pair > 1 and combined > 0) {
+ const numerator = @as(u128, pair) * nl + combined / 2;
+ const left = std.math.clamp(@as(u64, @intCast(numerator / combined)), 1, pair - 1);
+ p.col_weight[c] = left;
+ p.col_weight[c + 1] = pair - left;
+ }
}
// 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) |k| p.applyRowSplit(k.col, k.idx, d.cur_y);
+ if (d.corner) |k| {
+ if (d.cur_y != seamRowOf(p, k.col, k.idx))
+ p.applyRowSplit(k.col, k.idx, d.cur_y);
+ }
},
- .border_h => |d| p.applyRowSplit(d.col, d.top_idx, d.cur_y),
+ .border_h => |d| if (d.cur_y != seamRowOf(p, d.col, d.top_idx))
+ 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
@@ -11094,7 +10449,22 @@ pub const Pardes = struct {
.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.pdf.native) {
+ var release = pdf_pane.pointerRelease(
+ p,
+ pane,
+ s.pdf,
+ s.button,
+ s.chorded,
+ );
+ defer release.deinit(p.pdf_gpa);
+ if (release.action) |action| p.dispatchPointerBuiltin(
+ s.id,
+ if (action == .look) config.look_cmd else config.exec_cmd,
+ release.text,
+ );
+ return;
+ }
}
if (s.chorded) {
// a cut/paste chord consumed this drag; the release is inert
@@ -11120,7 +10490,7 @@ pub const Pardes = struct {
const w = pane.wrapAt(body_vis);
pane.cur_row = w.line;
pane.cur_col = if (pane.file != null)
- fileByteAtDisplay(pane, w.line, w.at, sl.c1 - @as(i32, config.PREFIX_W))
+ file_pane.byteAtRowDisplay(pane, w.line, w.at, sl.c1 - @as(i32, config.PREFIX_W))
else
sl.c1;
pane.cur_pinned = true;
@@ -11146,55 +10516,23 @@ pub const Pardes = struct {
// 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 cw = pane.wrapAt(cvis);
- const crow = cw.line;
- const ccol = if (pane.file != null)
- fileByteAtDisplay(pane, cw.line, cw.at, clk.c0 - @as(i32, config.PREFIX_W))
- 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]);
+ const operand = p.pointerOperand(pane, clk);
+ if (operand.expanded) |expanded|
+ pane.sel[b] = expanded
+ else if (operand.text == null)
+ pane.sel[b].state = .none;
// 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;
+ if (s.button == config.look_button and clk.r0 >= BOX_H and pane.mode != .tty) {
+ pane.cur_row = operand.row;
+ pane.cur_col = operand.col;
}
- 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 txt = operand.text;
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));
+ p.dispatchPointerBuiltin(s.id, cmd, txt);
}
},
.tag => {}, // dragUpdate already left the tag cursor + selection set
@@ -11218,7 +10556,7 @@ pub const Pardes = struct {
// with Tagbottom the lower pane's FIRST — which is that row's height
// already, with nothing to add. Either way a no-drag click hands back
// exactly ra.h and nothing moves.
- var nt: f32 = @floatFromInt(if (p.tag_bottom) cur_y -| ra.y else (cur_y + 1) -| ra.y);
+ var nt: f32 = @floatFromInt(if (p.settings.tag_bottom) cur_y -| ra.y else (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);
@@ -11343,45 +10681,6 @@ pub const Pardes = struct {
} 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 } {
@@ -11409,7 +10708,7 @@ pub const Pardes = struct {
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];
+ 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];
@@ -11420,25 +10719,68 @@ pub const Pardes = struct {
}
}
- pub fn layoutAppendColumn(p: *Pardes, id: usize) void {
- p.layoutInsertColumn(p.ncol, id);
+ /// Whether `source_id` can donate half its width to a new column. This is
+ /// public so callers that must create a pane first can reject before that
+ /// creation emits any native-side work.
+ pub fn layoutCanSplitColumn(p: *Pardes, source_id: usize) bool {
+ if (p.ncol >= MAX_COLS or source_id >= MAX_PANES or p.panes[source_id] == null) return false;
+ const source = p.layoutFindTerm(source_id) orelse return false;
+ // Refresh derived widths: public layout surgery may be chained between
+ // syncs, and a cached width must never admit a now-too-narrow split.
+ p.computeGeom();
+ if (p.col_w[source.col] < config.MINW * 2) return false;
+
+ const weight = p.col_weight[source.col];
+ if (weight >= 2 and weight % 2 == 0) return true;
+ for (0..p.ncol) |column| if (p.col_weight[column] > std.math.maxInt(u64) / 2)
+ return false;
+ return weight > 0;
}
- /// 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);
+ /// Put `id` in a fresh column immediately beside `source_id`, taking the
+ /// new column's width only from the source column. `before` is used by a
+ /// first document, which belongs to the left of the shell that opened it;
+ /// Newcol and Alt-c put the new column on the right.
+ ///
+ /// `id == source_id` moves one pane out of a stack. Its vertical weight is
+ /// absorbed before removal, while the column's horizontal weight remains
+ /// in place to be split. In either form the total column weight is exactly
+ /// unchanged, so every unrelated column keeps both its width and its x.
+ pub fn layoutSplitColumn(p: *Pardes, source_id: usize, id: usize, before: bool) bool {
+ if (id >= MAX_PANES or p.panes[id] == null) return false;
+ if (!p.layoutCanSplitColumn(source_id)) return false;
+ const source = p.layoutFindTerm(source_id) orelse return false;
+ const source_col = source.col;
+ var old_weight = p.col_weight[source_col];
+ const needs_rebase = old_weight < 2 or old_weight % 2 != 0;
+ if (needs_rebase) old_weight *= 2;
+ if (id == source_id) {
+ if (p.col_n[source_col] <= 1) return false;
+ p.absorbVWeight(id);
+ p.layoutRemove(id);
+ } else if (p.layoutFindTerm(id) != null) return false;
+
+ // A pathological hand-written dump can restore a positive proportion
+ // below one fixed-point quantum. Rebase every column together before
+ // splitting; ratios and therefore geometry remain identical.
+ if (needs_rebase) {
+ for (0..p.ncol) |column| p.col_weight[column] *= 2;
+ }
+ const source_weight = old_weight / 2;
+ const new_weight = old_weight - source_weight;
+ p.col_weight[source_col] = source_weight;
+ const c = source_col + @intFromBool(!before);
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_weight[c] = new_weight;
p.col_terms[c][0] = id;
p.col_n[c] = 1;
p.ncol += 1;
+ return true;
}
/// Snap every pane in column `c` to its on-screen row count so later
@@ -11526,15 +10868,6 @@ pub const Pardes = struct {
};
}
- /// 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 {
@@ -11549,7 +10882,10 @@ pub const Pardes = struct {
}
if (n_tty != 1) return;
const tt = p.panes[tty_id] orelse return;
- if (!isPristineTty(tt)) return;
+ // No typing, cursor on the first prompt line, no scrollback: this is
+ // the throwaway boot placeholder a document may replace.
+ if (tt.ovl != null or tt.vt.screens.active.cursor.y != 0 or
+ tt.vt.screens.active.pages.scrollbar().total > tt.rows) return;
p.computeGeom(); // a just-stacked doc has no rect yet; absorb snaps to rows
p.absorbVWeight(tty_id);
p.layoutRemove(tty_id);
@@ -11596,7 +10932,6 @@ pub const Pardes = struct {
errdefer p.gpa.free(path);
const pane = try p.newDocPane(id);
pane.file = .{ .path = path, .content = content };
- pane.kind = .file;
pane.cur_pinned = true;
return pane;
}
@@ -11614,1091 +10949,12 @@ pub const Pardes = struct {
p.panes[0] = null;
const pane = try p.newDocPane(0);
pane.file = .{ .path = path, .content = copy };
- pane.kind = .file;
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.image_gpa.dupe(u8, path);
- errdefer p.image_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 };
- pane.kind = .image;
- 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 page_sizes = try p.pdf_gpa.alloc(pdf_impl.PageSize, document.pages);
- errdefer p.pdf_gpa.free(page_sizes);
- for (page_sizes, 0..) |*size, page_number|
- size.* = try document.pageSize(page_number);
- const page_starts = try p.pdf_gpa.alloc(u64, document.pages);
- errdefer p.pdf_gpa.free(page_starts);
- const page_heights = try p.pdf_gpa.alloc(u32, document.pages);
- errdefer p.pdf_gpa.free(page_heights);
- const path_copy = try p.pdf_gpa.dupe(u8, path);
- errdefer p.pdf_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,
- .page_sizes = page_sizes,
- .page_starts = page_starts,
- .page_heights = page_heights,
- };
- pane.kind = .pdf;
- 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;
- for (pv.rasters[0..pv.rasters_len]) |*raster| if (raster.valid and raster.page == pv.page) {
- raster.tried = false;
- break;
- };
- }
-
- /// Tint and theme changes affect every page currently carried to a native
- /// backend, including both sides of a continuous-strip page boundary.
- /// Retain their old buffers until each replacement succeeds.
- fn invalidateAllPdfRasters(p: *Pardes, pv: *PdfView) void {
- if (comptime !pdf_enabled) return;
- _ = p;
- pv.render_tried = false;
- for (pv.rasters[0..pv.rasters_len]) |*raster| {
- if (raster.valid) raster.tried = false;
- }
- }
-
- fn releasePdfRaster(p: *Pardes, raster: *PdfRaster) void {
- if (comptime !pdf_enabled) return;
- if (raster.rgba.len > 0) p.pdf_gpa.free(raster.rgba);
- raster.* = .{};
- }
-
- /// Park one page buffer in the relay instead of handing it to the
- /// allocator. A full relay means this frame retired more pages than any
- /// arrival can want, so those bytes do go back.
- fn retirePdfRgba(p: *Pardes, pv: *PdfView, rgba: []u8) void {
- if (comptime !pdf_enabled) return;
- if (rgba.len == 0) return;
- if (pv.spare_len == pv.spare.len) {
- p.pdf_gpa.free(rgba);
- return;
- }
- pv.spare[pv.spare_len] = rgba;
- pv.spare_len += 1;
- }
-
- /// Eviction path: the slot's pixels are no longer reachable, but its bytes
- /// are exactly what the page replacing it needs.
- fn retirePdfRaster(p: *Pardes, pv: *PdfView, raster: *PdfRaster) void {
- if (comptime !pdf_enabled) return;
- p.retirePdfRgba(pv, raster.rgba);
- raster.* = .{};
- }
-
- /// A buffer of exactly `bytes`, out of the relay when one fits, so a steady
- /// fling stops touching the allocator at all. Uniform page sizes are the
- /// overwhelming case, and an inexact match is worth nothing: the render
- /// needs this length precisely.
- fn claimPdfRgba(p: *Pardes, pv: *PdfView, bytes: usize) ?[]u8 {
- if (comptime !pdf_enabled) return null;
- for (pv.spare[0..pv.spare_len], 0..) |candidate, index| {
- if (candidate.len != bytes) continue;
- pv.spare_len -= 1;
- pv.spare[index] = pv.spare[pv.spare_len];
- return candidate;
- }
- return p.pdf_gpa.alloc(u8, bytes) catch null;
- }
-
- /// Close the relay at the end of a reconcile. One buffer survives, because
- /// the next frame of a fling opens by retiring one and asking for one; more
- /// than that would be idle megabytes pretending to be a cache.
- fn trimPdfSpares(p: *Pardes, pv: *PdfView) void {
- if (comptime !pdf_enabled) return;
- while (pv.spare_len > 1) {
- pv.spare_len -= 1;
- p.pdf_gpa.free(pv.spare[pv.spare_len]);
- }
- }
-
- fn dropPdfRaster(p: *Pardes, pv: *PdfView) void {
- if (comptime !pdf_enabled) return;
- for (pv.rasters[0..pv.rasters_len]) |*raster| p.releasePdfRaster(raster);
- pv.rasters_len = 0;
- pv.rgba = &.{};
- pv.iw = 0;
- pv.ih = 0;
- 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.pdf_gpa);
- pv.search_results = null;
- }
-
- fn dropPdfSelection(p: *Pardes, pv: *PdfView) void {
- if (comptime !pdf_enabled) return;
- if (pv.selection) |*selection| selection.deinit(p.pdf_gpa);
- pv.selection = null;
- if (pv.selection_text.len > 0) p.pdf_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 semantic state tied to the active page. Plain cached pixels remain
- /// reusable if that page is still adjacent in the continuous viewport.
- fn invalidatePdfPage(p: *Pardes, pv: *PdfView) void {
- if (comptime !pdf_enabled) return;
- p.dropPdfSearchResults(pv);
- p.dropPdfSelection(pv);
- clearPdfDrag(pv);
- if (pv.text.len > 0) p.pdf_gpa.free(pv.text);
- pv.text = &.{};
- pv.text_tried = false;
- pv.text_scroll = 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 = try p.pdf_gpa.dupe(u8, query);
- if (pv.search_query.len > 0) p.pdf_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.pdf_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;
- };
- 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;
- if (pv.fit == .width) {
- const laid_out = p.ensurePdfLayout(pane, pv) orelse return;
- const page_y = @as(f64, @floatFromInt(pv.page_starts[pv.page])) +
- @as(f64, center_y) * @as(f64, @floatFromInt(pv.page_heights[pv.page]));
- const wanted = page_y - @as(f64, @floatFromInt(laid_out.pixel_h)) / 2;
- const max_scroll = @as(f64, @floatFromInt(pv.document_height -| laid_out.pixel_h));
- pv.document_scroll_y = std.math.clamp(wanted, 0, max_scroll);
- return;
- }
- const geometry = p.pdfGeometry(pane) orelse return;
- const full: u32 = @intCast(pv.iw);
- const at = normalizedPdfPixel(center_x, pv.iw);
- if (at >= geometry.src.x and at < geometry.src.x + geometry.src.w) return;
- const overflow = full -| geometry.src.w;
- if (overflow == 0) return;
- const wanted = @min(overflow, at -| geometry.src.w / 2);
- pv.pan_x = @intCast(
- (@as(u64, wanted) * std.math.maxInt(u16) + overflow / 2) / overflow,
- );
- }
-
- fn activatePdfPage(p: *Pardes, pane: *Pane, page: usize, reveal: bool) void {
- if (comptime !pdf_enabled) return;
- const pv = &(pane.pdf orelse return);
- // Every ordinary page/navigation action supersedes an outline reveal.
- // The outline path calls this first and installs its destination after.
- pv.outline_reveal_pending = null;
- const next = @min(page, pv.page_count -| 1);
- if (next != pv.page) {
- 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;
- }
- // Input events can be queued behind a scroll in the same shell batch.
- // If the newly active adjacent page is already resident, publish its
- // dimensions immediately so the following click/pan cannot combine
- // its geometry with the prior page's aliases.
- if (pdfRasterForPage(pv, next)) |raster| syncPdfRasterAliases(pv, raster);
- if (reveal) {
- pv.scroll_to_page_pending = true;
- if (pv.layout_valid) {
- pv.document_scroll_y = @floatFromInt(pv.page_starts[next]);
- pv.scroll_to_page_pending = false;
- }
- } else {
- // Scrolling into another page must not turn a pending search into
- // a snap back to its center; manual document motion stays manual.
- pv.search_reveal_pending = false;
- }
- }
-
- /// Reveal one page in the continuous strip without reparsing the document.
- fn setPdfPage(p: *Pardes, pane: *Pane, page: usize) void {
- p.activatePdfPage(pane, page, true);
- }
-
- /// 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.layout_valid = false;
- pv.scroll_to_page_pending = true;
- pv.search_reveal_pending = pv.search_query.len > 0;
- }
-
- /// Builtin entry point for the exact disabled -> filtered -> full ring.
- /// State belongs to one PDF pane; other document and terminal panes are
- /// deliberately inert.
- pub fn togglePdfTint(p: *Pardes, pane: *Pane) void {
- if (comptime !pdf_enabled) return;
- const pv = &(pane.pdf orelse return);
- pv.tint = pv.tint.next();
- p.invalidateAllPdfRasters(pv);
- }
-
- fn ensurePdfOutline(p: *Pardes, pv: *PdfView) ?*const pdf_impl.Outline {
- if (comptime !pdf_enabled) return null;
- if (!pv.outline_tried) {
- pv.outline_tried = true;
- pv.outline = pv.document.outline(p.pdf_gpa) catch null;
- }
- return if (pv.outline) |*outline| outline else null;
- }
-
- fn isPdfSectionsOutput(pane: *const Pane) bool {
- if (comptime !pdf_enabled) return false;
- const file = pane.file orelse return false;
- const output = file.output orelse return false;
- return switch (output.from) {
- .cmd => |builtin| builtin == .PdfSections,
- else => false,
- };
- }
-
- /// Re-arm a remembered, unedited +PdfSections pane without consulting
- /// MuPDF or rebuilding bytes which cannot have changed while its owning
- /// document remains open. Serial + generated revision are the two cache
- /// guards: a closed/reused slot or user edit falls through to a fresh
- /// materialisation below.
- fn rearmCleanPdfSections(p: *Pardes, id: usize, pane: *Pane, pv: *PdfView) bool {
- const remembered = pv.sections_output orelse return false;
- if (remembered.pane >= p.panes.len) return false;
- const result = p.panes[remembered.pane] orelse return false;
- if (result.serial != remembered.serial or !isPdfSectionsOutput(result)) return false;
- const file = &result.file.?;
- if (file.revision != remembered.revision) return false;
-
- file.scroll = 0;
- result.cur_row = 0;
- result.cur_col = 0;
- result.msel.active = false;
- result.vsel.active = false;
- result.nsel = 0;
- pane.search_pane = remembered.pane;
- pane.search_row = null;
- p.active = id;
- return true;
- }
-
- /// Lazily materialise this pane's cached MuPDF outline as a location list.
- /// A clean live output is refreshed in place; an edited, closed, or reused
- /// slot is never overwritten. Focus and n/N ownership stay on the PDF.
- pub fn openPdfSections(p: *Pardes, id: usize) void {
- if (comptime pdf_enabled) {
- const pane = p.panes[id] orelse return;
- const pv = &(pane.pdf orelse return);
- if (p.rearmCleanPdfSections(id, pane, pv)) return;
- const entries: []const pdf_impl.OutlineEntry = if (p.ensurePdfOutline(pv)) |outline|
- outline.entries
- else
- &.{};
- const content = PdfSectionRows.render(p.gpa, pv.path, entries) catch return;
-
- const free = p.freeSlot() orelse {
- p.gpa.free(content);
- return;
- };
- const dir = std.fs.path.dirname(pv.path) orelse "/";
- const result = output_pane.open(
- p,
- free,
- dir,
- .{ .cmd = .PdfSections },
- "",
- content,
- ) catch {
- p.gpa.free(content);
- return;
- };
- pv.sections_output = .{
- .pane = free,
- .serial = result.serial,
- .revision = result.file.?.revision,
- };
- p.placeDoc(id, free, result);
- p.computeGeom();
- p.active = id;
- pane.search_pane = free;
- pane.search_row = null;
- }
- }
-
- 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 PdfVisiblePages = struct { first: usize = 0, len: usize = 0 };
-
- 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 pdfPageHeight(size: pdf_impl.PageSize, viewport: PdfViewport, fit: PdfFitMode) u32 {
- if (comptime !pdf_enabled) return 0;
- if (fit == .height) return viewport.pixel_h;
- const scaled = @as(f64, @floatFromInt(viewport.pixel_w)) *
- @as(f64, size.height) / @as(f64, size.width);
- return @max(1, @as(u32, @intFromFloat(@min(
- @as(f64, @floatFromInt(std.math.maxInt(u32))),
- @round(scaled),
- ))));
- }
-
- fn consumePdfOutlineReveal(pv: *PdfView, viewport: PdfViewport) void {
- if (comptime !pdf_enabled) return;
- const destination = pv.outline_reveal_pending orelse return;
- pv.outline_reveal_pending = null;
- const page = @min(destination.page, pv.page_count -| 1);
- const size = pv.page_sizes[page];
- const raw_y = destination.y orelse 0;
- const y = if (std.math.isFinite(raw_y))
- std.math.clamp(raw_y, 0, size.height)
- else
- 0;
- const y_fraction = @as(f64, y) / @as(f64, size.height);
- pv.document_scroll_y = @as(f64, @floatFromInt(pv.page_starts[page])) +
- y_fraction * @as(f64, @floatFromInt(pv.page_heights[page]));
-
- // Fit-width has no horizontal overflow. In fit-height, derive the
- // display-space crop from page geometry so x works before this page
- // has ever been rasterised and does not force a render merely to pan.
- if (destination.x) |raw_x| if (pv.fit == .height and std.math.isFinite(raw_x)) {
- const display_width = @as(f64, @floatFromInt(viewport.pixel_h)) *
- @as(f64, size.width) / @as(f64, size.height);
- const viewport_width: f64 = @floatFromInt(viewport.pixel_w);
- if (display_width > viewport_width) {
- const x = std.math.clamp(raw_x, 0, size.width);
- const target = @as(f64, x) / @as(f64, size.width) * display_width;
- const overflow = display_width - viewport_width;
- const wanted = std.math.clamp(target - viewport_width / 2, 0, overflow);
- pv.pan_x = @intFromFloat(@round(
- wanted / overflow * @as(f64, std.math.maxInt(u16)),
- ));
- }
- };
-
- // The outline, not an older search hit, owns this explicit reveal.
- // Record the current viewport so drawPdf's resize rearm agrees.
- pv.search_reveal_pending = false;
- pv.reveal_viewport_w = viewport.pixel_w;
- pv.reveal_viewport_h = viewport.pixel_h;
- pv.reveal_fit = pv.fit;
- pv.reveal_viewport_valid = true;
- }
-
- /// Rebuild only O(page-count) point-size layout metadata. No page pixels
- /// are touched, and ordinary scrolling never enters this path.
- fn ensurePdfLayout(p: *Pardes, pane: *Pane, pv: *PdfView) ?PdfViewport {
- if (comptime !pdf_enabled) return null;
- const tz = tracy.zone(@src(), "pdf.ensure_layout");
- defer tz.end();
- const viewport = p.pdfViewport(pane) orelse return null;
- // Pane-local geometry changes (divider drags, splits, closes) do not
- // pass through Event.resize. Capture against the still-valid OLD
- // layout here, immediately before replacing its page heights. A shell
- // resize has already invalidated the layout after capturing, while an
- // explicit page reveal owns scroll_to_page_pending; neither may be
- // captured a second time or overridden by this implicit anchor.
- if (pv.layout_valid and !pv.scroll_to_page_pending and
- !pv.layout_anchor_pending and
- (pv.layout_viewport_w != viewport.pixel_w or
- pv.layout_viewport_h != viewport.pixel_h))
- {
- capturePdfLayoutAnchor(pv);
- }
- if (!pv.layout_valid or pv.layout_viewport_w != viewport.pixel_w or
- pv.layout_viewport_h != viewport.pixel_h or pv.layout_fit != pv.fit)
- {
- var at: u64 = 0;
- for (pv.page_sizes, 0..) |size, page| {
- pv.page_starts[page] = at;
- const height = pdfPageHeight(size, viewport, pv.fit);
- pv.page_heights[page] = height;
- at = std.math.add(u64, at, height) catch std.math.maxInt(u64);
- if (page + 1 < pv.page_count)
- at = std.math.add(u64, at, PDF_PAGE_GAP_PX) catch std.math.maxInt(u64);
- }
- pv.document_height = at;
- pv.layout_viewport_w = viewport.pixel_w;
- pv.layout_viewport_h = viewport.pixel_h;
- pv.layout_fit = pv.fit;
- pv.layout_valid = true;
- if (pv.scroll_to_page_pending) {
- pv.document_scroll_y = @floatFromInt(pv.page_starts[pv.page]);
- pv.scroll_to_page_pending = false;
- pv.layout_anchor_pending = false;
- } else if (pv.layout_anchor_pending) {
- const anchor_page = @min(pv.layout_anchor_page, pv.page_count -| 1);
- pv.document_scroll_y = @as(f64, @floatFromInt(pv.page_starts[anchor_page])) +
- pv.layout_anchor_fraction * @as(f64, @floatFromInt(pv.page_heights[anchor_page]));
- pv.layout_anchor_pending = false;
- }
- }
- consumePdfOutlineReveal(pv, viewport);
- const max_scroll = @as(f64, @floatFromInt(pv.document_height -| viewport.pixel_h));
- pv.document_scroll_y = std.math.clamp(pv.document_scroll_y, 0, max_scroll);
- return viewport;
- }
-
- fn capturePdfLayoutAnchor(pv: *PdfView) void {
- if (comptime !pdf_enabled) return;
- if (!pv.layout_valid or pv.page_count == 0 or pv.document_height == 0) return;
- const page = pdfPageAtOffset(pv, pv.document_scroll_y);
- const start: f64 = @floatFromInt(pv.page_starts[page]);
- const height: f64 = @floatFromInt(@max(@as(u32, 1), pv.page_heights[page]));
- pv.layout_anchor_page = page;
- pv.layout_anchor_fraction = std.math.clamp((pv.document_scroll_y - start) / height, 0, 1);
- pv.layout_anchor_pending = true;
- }
-
- fn pdfPageAtOffset(pv: *const PdfView, offset: f64) usize {
- if (comptime !pdf_enabled) return 0;
- const y: u64 = @intFromFloat(std.math.clamp(
- @floor(offset),
- 0,
- @as(f64, @floatFromInt(pv.document_height -| 1)),
- ));
- var lo: usize = 0;
- var hi: usize = pv.page_count;
- while (lo + 1 < hi) {
- const mid = lo + (hi - lo) / 2;
- if (pv.page_starts[mid] <= y) lo = mid else hi = mid;
- }
- const end = pv.page_starts[lo] + pv.page_heights[lo];
- return if (y >= end and lo + 1 < pv.page_count) lo + 1 else lo;
- }
-
- fn pdfPageVisible(pv: *const PdfView, page: usize, viewport: PdfViewport) bool {
- if (comptime !pdf_enabled) return false;
- const top = @as(f64, @floatFromInt(pv.page_starts[page])) - pv.document_scroll_y;
- const bottom = top + @as(f64, @floatFromInt(pv.page_heights[page]));
- return bottom > 0 and top < @as(f64, @floatFromInt(viewport.pixel_h));
- }
-
- fn pdfVisiblePages(pv: *const PdfView, viewport: PdfViewport) PdfVisiblePages {
- var out: PdfVisiblePages = .{};
- var page = pdfPageAtOffset(pv, pv.document_scroll_y);
- if (page > 0 and pdfPageVisible(pv, page - 1, viewport)) page -= 1;
- out.first = page;
- while (page < pv.page_count) : (page += 1) {
- const top = @as(f64, @floatFromInt(pv.page_starts[page])) - pv.document_scroll_y;
- if (top >= @as(f64, @floatFromInt(viewport.pixel_h))) break;
- if (!pdfPageVisible(pv, page, viewport)) continue;
- out.len += 1;
- }
- return out;
- }
-
- 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 pdfTintColors(p: *const Pardes) pdf_impl.TintColors {
- if (comptime !pdf_enabled) return undefined;
- const th = p.theme();
- return .{
- .background = th.bg orelse th.tag_bg,
- .foreground = th.fg orelse th.tag_fg,
- };
- }
-
- fn pdfTintKey(p: *const Pardes, pv: *const PdfView) PdfTintKey {
- if (comptime !pdf_enabled) return undefined;
- return .{ .mode = pv.tint, .colors = p.pdfTintColors() };
- }
-
- const PdfPlacedGeometry = struct {
- geometry: image.NativeGeometry,
- pixel_offset_y: f32,
- };
-
- fn pdfRasterForPage(pv: *PdfView, page: usize) ?*PdfRaster {
- if (comptime !pdf_enabled) return null;
- for (pv.rasters[0..pv.rasters_len]) |*raster|
- if (raster.valid and raster.page == page) return raster;
- return null;
- }
-
- fn syncPdfRasterAliases(pv: *PdfView, raster: *const PdfRaster) void {
- if (comptime !pdf_enabled) return;
- pv.rgba = raster.rgba;
- pv.iw = raster.iw;
- pv.ih = raster.ih;
- pv.render_tried = raster.tried;
- pv.render_request = raster.request;
- pv.render_request_valid = raster.request_valid;
- pv.render_revision = raster.revision;
- }
-
- /// Which rows of a page's FULL raster the viewport shows, and where they
- /// land on screen. Split out of `pdfPlacedGeometry` because the reconcile
- /// needs the same answer BEFORE a raster exists — it is what decides how
- /// much of the page is worth rasterizing at all (see `pdfWantedBand`), and
- /// two copies of this arithmetic would be two chances to place a band one
- /// row away from the rows it rendered.
- const PdfVisibleRows = struct {
- base: image.NativeGeometry,
- /// rows [y0, y1) of the full page raster
- y0: u32,
- y1: u32,
- dst_y: u32,
- dst_h: u32,
- pixel_offset_y: f32,
- };
-
- fn pdfVisibleRows(
- pv: *const PdfView,
- viewport: PdfViewport,
- page: usize,
- iw: usize,
- ih: usize,
- ) ?PdfVisibleRows {
- if (comptime !pdf_enabled) return null;
- const page_h = pv.page_heights[page];
- const base = image.nativeGeometry(
- iw,
- ih,
- viewport.pixel_w,
- page_h,
- switch (pv.fit) {
- .width => .width,
- .height => .height,
- },
- pv.pan_x,
- 0,
- ) orelse return null;
- if (base.dst.h == 0 or base.src.h == 0) return null;
-
- const scroll_floor = @floor(pv.document_scroll_y);
- const fractional: f32 = @floatCast(pv.document_scroll_y - scroll_floor);
- const scroll_i: i64 = @intFromFloat(@min(
- scroll_floor,
- @as(f64, @floatFromInt(std.math.maxInt(i64))),
- ));
- const start_i: i64 = @intCast(@min(
- pv.page_starts[page],
- @as(u64, std.math.maxInt(i64)),
- ));
- const full_y = start_i - scroll_i + @as(i64, base.dst.y);
- const full_bottom = full_y + @as(i64, base.dst.h);
- const visible_y = @max(@as(i64, 0), full_y);
- const visible_bottom = @min(@as(i64, viewport.pixel_h), full_bottom);
- if (visible_bottom <= visible_y) return null;
-
- const rel_y0: u64 = @intCast(visible_y - full_y);
- const rel_y1: u64 = @intCast(visible_bottom - full_y);
- const src_y0: u32 = base.src.y + @as(u32, @intCast(
- rel_y0 * base.src.h / base.dst.h,
- ));
- const src_y1: u32 = base.src.y + @as(u32, @intCast(@min(
- @as(u64, base.src.h),
- (rel_y1 * base.src.h + base.dst.h - 1) / base.dst.h,
- )));
- if (src_y1 <= src_y0) return null;
- return .{
- .base = base,
- .y0 = src_y0,
- .y1 = src_y1,
- .dst_y = @intCast(visible_y),
- .dst_h = @intCast(visible_bottom - visible_y),
- .pixel_offset_y = -fractional,
- };
- }
-
- /// The same rows, expressed against the raster that actually exists: a
- /// band-rendered page holds only rows [band_y, band_y+band_h), so the
- /// source rectangle a backend samples is shifted by the band's origin.
- /// A raster that does not cover the visible rows places NOTHING rather
- /// than sampling rows it never rendered — the reconcile is what keeps that
- /// from happening, and this is the assertion that it did.
- fn pdfPlacedGeometry(
- pv: *const PdfView,
- raster: *const PdfRaster,
- viewport: PdfViewport,
- page: usize,
- ) ?PdfPlacedGeometry {
- if (comptime !pdf_enabled) return null;
- const rows = pdfVisibleRows(pv, viewport, page, raster.iw, raster.ih) orelse return null;
- const band_y: u32 = @intCast(raster.band_y);
- const band_end: u32 = @intCast(raster.band_y + raster.band_h);
- if (rows.y0 < band_y or rows.y1 > band_end) return null;
- return .{
- .geometry = .{
- .src = .{
- .x = rows.base.src.x,
- .y = rows.y0 - band_y,
- .w = rows.base.src.w,
- .h = rows.y1 - rows.y0,
- },
- .dst = .{
- .x = rows.base.dst.x,
- .y = rows.dst_y,
- .w = rows.base.dst.w,
- .h = rows.dst_h,
- },
- },
- .pixel_offset_y = rows.pixel_offset_y,
- };
- }
-
- fn pdfGeometry(p: *const Pardes, pane: *const Pane) ?image.NativeGeometry {
- if (comptime !pdf_enabled) return null;
- const mutable = @constCast(pane);
- const pv = if (mutable.pdf) |*view| view else return null;
- const viewport = p.pdfViewport(pane) orelse return null;
- const raster = pdfRasterForPage(pv, pv.page) orelse return null;
- const placed = pdfPlacedGeometry(pv, raster, viewport, pv.page) orelse return null;
- return placed.geometry;
- }
-
- fn pdfPageAtGridPoint(p: *const Pardes, pane: *const Pane, mrow: u16) ?usize {
- if (comptime !pdf_enabled) return null;
- const pv = pane.pdf orelse return null;
- if (!pv.layout_valid) return null;
- const r = for (p.panes, 0..) |slot, i| {
- if (slot == pane) break p.rects[i];
- } else return null;
- const body_y = if (p.tag_bottom) r.y else r.y + BOX_H;
- if (mrow < body_y or mrow >= body_y + (r.h -| BOX_H)) return null;
- const local_y = @as(f64, @floatFromInt(
- @as(u32, mrow - body_y) * p.cell_pixels.h + p.cell_pixels.h / 2,
- ));
- const page = pdfPageAtOffset(&pv, pv.document_scroll_y + local_y);
- const document_y = pv.document_scroll_y + local_y;
- const start: f64 = @floatFromInt(pv.page_starts[page]);
- if (document_y < start or
- document_y >= start + @as(f64, @floatFromInt(pv.page_heights[page]))) return null;
- return page;
- }
-
- 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, if (p.tag_bottom) r.y else 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.pdf_gpa, pv.page, start, end) catch return .failed;
- const text = pv.document.copySelection(
- p.pdf_gpa,
- pv.page,
- selection.start,
- selection.end,
- ) catch {
- selection.deinit(p.pdf_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 hit_page = p.pdfPageAtGridPoint(pane, mrow) orelse return false;
- if (hit_page != pane.pdf.?.page) {
- p.activatePdfPage(pane, hit_page, false);
- if (pdfRasterForPage(&pane.pdf.?, hit_page)) |raster|
- syncPdfRasterAliases(&pane.pdf.?, raster);
- }
- const pv = &pane.pdf.?;
- 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 &.{};
- const arena = p.scratch.allocator();
- const search_len = if (pv.search_results) |results| results.quads.len else 0;
- const selection_len = if (pv.selection) |selection| selection.quads.len else 0;
- const highlights = try arena.alloc(PdfHighlight, search_len + selection_len);
- var n: usize = 0;
- if (pv.search_results) |results| {
- for (results.quads) |item| {
- highlights[n] = pdf_impl.Highlight.init(item.quad, .{ 0xff, 0xd5, 0x4f, 0x70 }, .search);
- n += 1;
- }
- }
- if (pv.selection) |selection| {
- const sel = p.theme().sel_bg;
- for (selection.quads) |quad| {
- highlights[n] = pdf_impl.Highlight.init(quad, .{ sel[0], sel[1], sel[2], 0x78 }, .selection);
- n += 1;
- }
- }
- return highlights;
- }
-
- /// 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),
- ));
- }
-
- /// Move through the strip by exact display pixels. Page changes update
- /// semantic search/selection state, but never evict unchanged page pixels.
- fn scrollPdfDocument(p: *Pardes, pane: *Pane, delta_pixels: f64) bool {
- if (comptime !pdf_enabled) return false;
- const tz = tracy.zone(@src(), "pdf.scroll_notch");
- defer tz.end();
- if (!std.math.isFinite(delta_pixels) or delta_pixels == 0) return false;
- const pv = &(pane.pdf orelse return false);
- const viewport = p.ensurePdfLayout(pane, pv) orelse return false;
- const max_scroll = @as(f64, @floatFromInt(pv.document_height -| viewport.pixel_h));
- const next = std.math.clamp(pv.document_scroll_y + delta_pixels, 0, max_scroll);
- if (next == pv.document_scroll_y) return false;
- // Travel this frame, for the fling test in drawPdf. Accumulated rather
- // than compared against the last position because a wheel batch arrives
- // as a hundred separate notches and it is their SUM inside one frame
- // that says how fast the reader is going.
- pv.scroll_travel += @abs(next - pv.document_scroll_y);
- pv.document_scroll_y = next;
- const active_page = pdfPageAtOffset(pv, next);
- if (active_page != pv.page) p.activatePdfPage(pane, active_page, false);
- return true;
- }
-
- fn pdfVerticalWheel(p: *Pardes, pane: *Pane, direction: i8) void {
- if (comptime !pdf_enabled) return;
- if (!p.native_images) {
- p.stepPdfPage(pane, direction);
- return;
- }
- const rows: u32 = @intCast(@max(1, config.wheel_rows));
- const pixels = scaledPdfStep(p.cell_pixels.h, rows);
- _ = p.scrollPdfDocument(pane, @as(f64, @floatFromInt(pixels)) * direction);
- }
-
- 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));
- }
-
fn handlePdfNormal(p: *Pardes, pane: *Pane, key: Key) void {
if (comptime !pdf_enabled) return;
if (pane.pdf == null) return;
@@ -12707,111 +10963,36 @@ pub const Pardes = struct {
putPaneNormalState(pane, state);
switch (parsed) {
.pending, .ignored, .unbound => {},
- .action => |semantic| p.executePdfNormalAction(pane, semantic),
- }
- }
-
- /// PDF vertical actions use exact display pixels in the continuous native
- /// strip. The cell, half-view and full-view bases are multiplied by the
- /// parsed count. A non-native shell has no pixel strip and deliberately
- /// falls back to counted page steps for all three.
- fn movePdfVertical(p: *Pardes, pane: *Pane, direction: i8, kind: normal_input.Page, count: u32) void {
- if (comptime !pdf_enabled) return;
- if (!p.native_images) {
- const pages: i64 = @intCast(@max(@as(u32, 1), count));
- p.stepPdfPage(pane, if (direction > 0) pages else -pages);
- return;
- }
- const viewport = p.pdfViewport(pane);
- const base: u32 = switch (kind) {
- .half_down, .half_up => if (viewport) |v| @max(@as(u32, 1), v.pixel_h / 2) else p.cell_pixels.h,
- .down, .up => if (viewport) |v| v.pixel_h else p.cell_pixels.h,
- };
- const pixels = scaledPdfStep(base, count);
- _ = p.scrollPdfDocument(pane, @as(f64, @floatFromInt(pixels)) * direction);
- }
-
- fn movePdfRows(p: *Pardes, pane: *Pane, direction: i8, count: u32) void {
- if (comptime !pdf_enabled) return;
- if (!p.native_images) {
- const pages: i64 = @intCast(@max(@as(u32, 1), count));
- return p.stepPdfPage(pane, if (direction > 0) pages else -pages);
- }
- const pixels = scaledPdfStep(p.cell_pixels.h, count);
- _ = p.scrollPdfDocument(pane, @as(f64, @floatFromInt(pixels)) * direction);
- }
-
- fn pdfHorizontalEdge(p: *Pardes, pane: *Pane, end: bool) void {
- if (comptime !pdf_enabled) return;
- if (!p.native_images) return;
- const geometry = p.pdfGeometry(pane) orelse return;
- const pv = &(pane.pdf orelse return);
- const full: u32 = @intCast(pv.iw);
- if (full <= geometry.src.w) return;
- pv.pan_x = if (end) std.math.maxInt(u16) else 0;
- }
-
- /// Deliberately incomplete PDF adapter. Unsupported edit/selection/text
- /// actions have already consumed their parser state and are safe no-ops.
- fn executePdfNormalAction(p: *Pardes, pane: *Pane, semantic: normal_input.Action) void {
- if (comptime !pdf_enabled) return;
- const pv = &(pane.pdf orelse return);
- switch (semantic) {
- .escape => {
- pane.select = false;
- return p.runBuiltin(.Last, p.active, "", null);
- },
- .move => |move| switch (move.motion) {
- .down => p.movePdfRows(pane, 1, move.count),
- .up => p.movePdfRows(pane, -1, move.count),
- .left => if (p.native_images) {
- _ = p.panPdfPixels(pane, .horizontal, -1, scaledPdfStep(p.cell_pixels.w, move.count));
- },
- .right => if (p.native_images) {
- _ = p.panPdfPixels(pane, .horizontal, 1, scaledPdfStep(p.cell_pixels.w, move.count));
- },
- else => {},
- },
- .goto => |go| switch (go.target) {
- .file_start => p.setPdfPage(pane, if (go.explicit_count) go.count -| 1 else 0),
- .last_line => p.setPdfPage(pane, pv.page_count -| 1),
- .line_start, .first_nonws => p.pdfHorizontalEdge(pane, false),
- .line_end => p.pdfHorizontalEdge(pane, true),
- .line_down => p.movePdfRows(pane, 1, go.count),
- .line_up => p.movePdfRows(pane, -1, go.count),
- else => {},
- },
- .line => |line| switch (line) {
- .start, .first_nonws => p.pdfHorizontalEdge(pane, false),
- .end => p.pdfHorizontalEdge(pane, true),
- },
- .goto_line => |go| if (go.explicit) p.setPdfPage(pane, go.count -| 1),
- .page => |page| switch (page.kind) {
- .half_down, .down => p.movePdfVertical(pane, 1, page.kind, page.count),
- .half_up, .up => p.movePdfVertical(pane, -1, page.kind, page.count),
- },
- .view => |view| switch (view) {
- .scroll_down => p.movePdfRows(pane, 1, 1),
- .scroll_up => p.movePdfRows(pane, -1, 1),
- else => {},
- },
- .leader => {
- p.leader_on = true;
- p.leader_n = 0;
- },
- .command_line => {
- p.enterTagEdit(pane, -1);
- if (pane.tag_edit) pane.mode = .normal;
+ .action => |semantic| {
+ const result = pdf_pane.applyNormal(
+ &pane.pdf.?,
+ p.pdf_gpa,
+ semantic,
+ p.native_images,
+ .{ .w = p.cell_pixels.w, .h = p.cell_pixels.h },
+ pdf_pane.paneViewport(p, pane),
+ pdf_pane.paneGeometry(p, pane),
+ );
+ if (result.page_changed) pdf_pane.resetPageChrome(pane);
+ switch (result.host) {
+ .none => {},
+ .last => {
+ pane.select = false;
+ p.runBuiltin(.Last, p.active, "", null);
+ },
+ .leader => {
+ p.leader_on = true;
+ p.leader_n = 0;
+ },
+ .command_line => {
+ p.enterTagEdit(pane, -1);
+ if (pane.tag_edit) pane.mode = .normal;
+ },
+ .search => p.startSearch(pane, config.search_marker),
+ .search_forward => p.lookWalk(1),
+ .search_backward => p.lookWalk(-1),
+ }
},
- .search => p.startSearch(pane, config.search_marker),
- // A PDF is not a special case: it steps the same ring every other
- // pane does, which in practice is the +Search or +PdfSections
- // buffer its own search filled. n selects the row, Enter jumps to
- // the page.
- .search_step => |direction| return p.lookWalk(
- if (direction == .forward) @as(i32, 1) else -1,
- ),
- else => {},
}
}
@@ -12847,15 +11028,8 @@ pub const Pardes = struct {
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;
- }
+ if (pv.focusLocation(p.pdf_gpa, at.line, at.col)) pdf_pane.resetPageChrome(pane);
}
return;
}
@@ -12927,74 +11101,6 @@ pub const Pardes = struct {
return false;
}
- fn revealPdfOutlineDestination(
- p: *Pardes,
- pane: *Pane,
- destination: pdf_impl.OutlineInternalDestination,
- ) void {
- if (comptime !pdf_enabled) return;
- p.activatePdfPage(pane, destination.page, false);
- const pv = &pane.pdf.?;
- pv.scroll_to_page_pending = false;
- pv.layout_anchor_pending = false;
- pv.outline_reveal_pending = destination;
- // Consume immediately when geometry already exists; otherwise drawPdf
- // consumes the same pending value after the next layout transaction.
- _ = p.ensurePdfLayout(pane, pv);
- }
-
- /// PdfSections alone gives Spot.col a second meaning: the one-based DFS
- /// ordinal written into its generated leading token. It is checked before
- /// ordinary PDF focus so search-result columns everywhere else retain
- /// their existing logical-hit meaning.
- fn lookPdfSection(p: *Pardes, output_id: usize, path: []const u8, at: look.Spot) bool {
- if (comptime !pdf_enabled) return false;
- const output = p.panes[output_id] orelse return false;
- if (!isPdfSectionsOutput(output)) return false;
- if (at.line == 0 or at.col == 0 or at.end_line != 0 or
- !look.isPdfPath(path)) return false;
-
- // Internal section rows use the basename so their titles remain
- // visible. Resolve that token from +PdfSections' document directory
- // before matching an open PDF or opening it again.
- var joined_path: [4096]u8 = undefined;
- const output_dir = paneDir(output);
- const separator = if (std.mem.endsWith(u8, output_dir, "/")) "" else "/";
- const target_path = if (std.fs.path.isAbsolute(path))
- path
- else
- std.fmt.bufPrint(&joined_path, "{s}{s}{s}", .{ output_dir, separator, path }) catch return true;
-
- var target_id: ?usize = null;
- for (p.panes, 0..) |slot, i| {
- const candidate = slot orelse continue;
- if (candidate.pdf) |pv| if (std.mem.eql(u8, pv.path, target_path)) {
- target_id = i;
- break;
- };
- }
- if (target_id == null) {
- const free = p.freeSlot() orelse return true;
- const fresh = p.openPdfView(free, target_path, 0) catch return true;
- p.placeDoc(output_id, free, fresh);
- p.computeGeom();
- target_id = free;
- }
-
- const id = target_id.?;
- const pane = p.panes[id] orelse return true;
- const pv = &pane.pdf.?;
- const outline = p.ensurePdfOutline(pv) orelse return true;
- const destination = PdfSectionRows.resolve(outline.entries, at.col - 1) orelse return true;
- switch (destination) {
- .internal => |internal| p.revealPdfOutlineDestination(pane, internal),
- .external => |uri| if (uri.len <= 256) p.emit(.{ .open_link = .from(uri) }),
- .none => unreachable,
- }
- p.active = id;
- return true;
- }
-
/// 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
@@ -13024,7 +11130,7 @@ pub const Pardes = struct {
// 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 (comptime pdf_enabled) if (p.lookPdfSection(id, pl.path, pl.at)) return;
+ if (comptime pdf_enabled) if (pdf_pane.lookSection(p, id, pl.path, pl.at)) return;
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
@@ -13121,7 +11227,7 @@ pub const Pardes = struct {
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;
+ const nt = pdf_pane.openPane(p, free, target.path, target.at.line) catch return;
p.placeDoc(id, free, nt);
return;
};
@@ -13150,7 +11256,7 @@ pub const Pardes = struct {
.image => |target| {
if (p.focusPaneByPath(target.path, .{})) return;
const free = p.freeSlot() orelse return;
- const nt = p.openImageView(free, target.path) catch return;
+ const nt = image_pane.create(p, free, target.path, &.{}) catch return;
p.placeDoc(id, free, nt);
},
}
@@ -13194,6 +11300,10 @@ pub const Pardes = struct {
// image's no-PTY pane slot, and never type a command line at vim.
const dst = (if (p.takesCommandLine(id)) id else p.ttyForDir(paneDir(pane))) orelse return null;
term_pane.padOutputBelowEdits(p, dst);
+ if (term_pane.queuePendingCommand(p.panes[dst].?, cmd) catch |err| {
+ p.reportError(id, "queue command", err);
+ return dst;
+ }) return dst;
p.emitWrite(dst, cmd);
p.emitWrite(dst, "\r");
return dst;
@@ -13224,7 +11334,7 @@ pub const Pardes = struct {
const sp = std.mem.indexOfAny(u8, cmd, " \t");
const bi: ?Builtin = std.meta.stringToEnum(Builtin, cmd) orelse blk: {
const head = std.meta.stringToEnum(Builtin, cmd[0 .. sp orelse break :blk null]) orelse break :blk null;
- break :blk if (takesArg(head)) head else null;
+ break :blk if (builtins.registry.takesArg(head)) head else null;
};
const b = bi orelse return false;
p.exec_depth += 1;
@@ -13234,23 +11344,6 @@ pub const Pardes = struct {
return true;
}
- /// Which builtins match their name with a TAIL — `Restore <path>`,
- /// `Grep <pat>`, `Theme <name>`, and the two verbs, `Look <word>` /
- /// `Exec <cmd>`, which is what makes `` @`Look .` `` nest. Every other
- /// name must match WHOLE, so `Kill foo` is a shell command and not Kill.
- ///
- /// Asked in two places and so written in one. The second asker is the
- /// topbar's execute, and it is why this is a function: a topbar click runs
- /// the word under it WITH whatever happens to be selected (select a word,
- /// click Grep, it greps that word), and a builtin that takes no argument
- /// was getting one anyway — `Kill` with a stale sweep somewhere became
- /// `Kill <word>`, which is not Kill, so it fell through to the shell and
- /// ran as a command nobody typed. A selection is only an argument to
- /// something that has somewhere to put it.
- fn takesArg(b: Builtin) bool {
- return builtins.registry.takesArg(b);
- }
-
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);
@@ -13271,7 +11364,45 @@ pub const Pardes = struct {
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.registry.all(), 0..) |B, i| if (@intFromEnum(b) == i) return B.run(c);
+ builtins.registry.dispatch(b, c);
+ }
+
+ /// Generated setting builtins converge here. The descriptor is compile-
+ /// time data but the state is ordinary owned data; no vtable or callback
+ /// layer sits between the command enum and these fields.
+ pub fn applySettingBuiltin(p: *Pardes, setting: runtime_cfg.Setting, arg: ?[]const u8) void {
+ const previous_transition = p.settings.panel_transition;
+ const previous_tagline_percent = p.settings.font.tagline_percent;
+ switch (setting.action) {
+ .theme => {
+ const want = std.mem.trim(u8, arg orelse return, " \t\r\n");
+ for (themes, 0..) |t, i| if (std.mem.eql(u8, t.name, want)) {
+ p.setThemeIndex(i);
+ return;
+ };
+ },
+ .font => {
+ if (comptime !font_picker) return;
+ const want = std.mem.trim(u8, arg orelse return, " \t\r\n");
+ const matches = fonts.list(p.scratch.allocator(), want);
+ if (matches.len == 0) return;
+ if (!runtime_cfg.requestFont(&p.settings, matches[0].path, matches[0].name)) return;
+ p.font_request_taken = false;
+ },
+ else => _ = runtime_cfg.applySimple(&p.settings, setting, arg),
+ }
+ const transition_changed = setting.action == .transition and
+ p.settings.panel_transition != previous_transition;
+ const tagline_metrics_changed = setting.action == .tagline_size and
+ p.settings.font.tagline_percent != previous_tagline_percent;
+ if (transition_changed or tagline_metrics_changed) {
+ // The backend may still show the last acknowledged sample. Keep
+ // input inert until it has replaced that sample with canonical
+ // pixels instead of merely forgetting the producer-side tracks.
+ // A tagline-size change also invalidates the raster metrics used
+ // by a frozen old frame; snapping is the only honest old/new pair.
+ p.abandonPanelAnimations();
+ }
}
/// place a fresh doc pane. An OUTPUT buffer (+Search/+Help) is NOT a
@@ -13283,9 +11414,9 @@ pub const Pardes = struct {
/// 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.
+ /// left, shells right). Only the calling/source column donates half its
+ /// width; every other column keeps its boundary. 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;
@@ -13348,7 +11479,7 @@ pub const Pardes = struct {
return;
}
if (doc and p.ncol < MAX_COLS) {
- p.layoutInsertColumn(0, free);
+ if (!p.layoutSplitColumn(from_id, free, true)) return p.stackDocLeft(free, nt);
p.active = free;
return;
}
@@ -13371,117 +11502,28 @@ pub const Pardes = struct {
// 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: {
+ var dp: dump.Pane = if (pane.file) |*f| try file_pane.dumpPane(
+ arena,
+ pane,
+ f,
+ tag,
+ body,
+ scroll,
+ if (f.output) |o| output_pane.word(o.from) else "",
+ 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 = .{} });
- const extra = if (pane.ovl) |o| o.text.len else 0;
- const stream_buf = try arena.alloc(u8, full.len + extra);
- var stream_len: usize = 0;
- 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) {
- // stepped for EVERY line, before the skip below, or the
- // row iterator falls out of step with the dump's lines
- const prompt_pin = if (pane.mode != .tty) prompts.next() else null;
- if (skip > 0) {
- skip -= 1;
- continue;
- }
- if (row > 0) {
- stream_buf[stream_len] = '\n';
- stream_len += 1;
- }
- if (pane.mode != .tty) if (pane.ovl) |o| if (row == o.row) {
- @memcpy(stream_buf[stream_len..][0..o.text.len], o.text);
- stream_len += o.text.len;
- skip = o.rows - 1;
- continue;
- };
- // same rule the motion surface and the body use, so a dump
- // reloads showing exactly what the pane showed
- const shown = if (prompt_pin) |pin|
- if (pin.rowAndCell().row.semantic_prompt != .none)
- term_pane.promptRow(pin, raw)
- else
- raw
- else
- raw;
- @memcpy(stream_buf[stream_len..][0..shown.len], shown);
- stream_len += shown.len;
- }
- 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_buf[0..stream_len],
- .stream_b64 = try dump.encodeBytes(arena, try pane.ttyReplayBytes(arena)),
- .cursor = .{
- .col = pane.vt.screens.active.cursor.x,
- .row = pane.vt.screens.active.cursor.y,
- },
- },
- };
- };
+ break :blk try image_pane.dumpPane(arena, pane, tag, body, pv.page, pv.path, &.{});
+ } else if (pane.image) |iv|
+ try image_pane.dumpPane(arena, pane, tag, body, scroll, iv.path, iv.raw)
+ else
+ try term_pane.dumpPane(pane, arena, tag, body, scroll);
+ dp.tag_tail = if (pane.tag_init) pane.tagSlice() else null;
panes[panes_len] = dp;
panes_len += 1;
}
@@ -13496,7 +11538,10 @@ pub const Pardes = struct {
ids_len += 1;
};
if (ids_len > 0) {
- columns[columns_len] = .{ .weight = p.col_weight[c], .panes = column_ids[c][0..ids_len] };
+ columns[columns_len] = .{
+ .weight = @as(f32, @floatFromInt(p.col_weight[c])) / @as(f32, @floatFromInt(column_weight_unit)),
+ .panes = column_ids[c][0..ids_len],
+ };
columns_len += 1;
}
}
@@ -13541,87 +11586,36 @@ pub const Pardes = struct {
defer dump.free(gpa, st);
for (themes, 0..) |t, i| {
- if (std.mem.eql(u8, t.name, st.theme)) p.theme_idx = i;
+ if (std.mem.eql(u8, t.name, st.theme)) p.settings.theme = @intCast(i);
}
for (st.panes, 0..) |src, i| {
if (i >= MAX_PANES) break;
- switch (src.kind) {
- .terminal => {
+ const pane: *Pane = switch (src.kind) {
+ .terminal => terminal: {
const t = src.terminal.?;
- const pane = try gpa.create(Pane);
- pane.* = .{
- .vt = try ghostty_vt.Terminal.init(std.Io.Threaded.global_single_threaded.io(), 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.appendTtyReplay(bytes);
- 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)) });
- }
+ const restored = try term_pane.restore(p, src);
+ p.installPane(i, restored);
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.kind = .file;
- 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);
+ if (std.mem.startsWith(u8, src.tag, "TTY ")) restored.mode = .tty;
+ break :terminal restored;
},
+ .file => try file_pane.restore(p, i, src),
.image => restore_image: {
const im = src.image.?;
- const raw_gpa = if (pdf_enabled and look.isPdfPath(im.path)) gpa else p.image_gpa;
- var raw: []u8 = if (im.bytes_b64.len > 0)
- dump.decodeBytes(raw_gpa, im.bytes_b64) catch &.{}
- else
- &.{};
- errdefer if (raw.len > 0) raw_gpa.free(raw);
- 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);
- raw = &.{};
- pane.cols = @max(1, src.cols);
- pane.rows = @max(1, src.rows);
- break :restore_image;
+ if (pdf_pane.isPath(im.path)) {
+ var raw: []u8 = if (im.bytes_b64.len > 0)
+ try dump.decodeBytes(gpa, im.bytes_b64)
+ else
+ &.{};
+ errdefer if (raw.len > 0) gpa.free(raw);
+ if (comptime pdf_enabled) {
+ if (pdf_pane.openPane(p, i, im.path, src.scroll + 1) catch null) |restored| {
+ if (raw.len > 0) gpa.free(raw);
+ raw = &.{};
+ restored.cols = @max(1, src.cols);
+ restored.rows = @max(1, src.rows);
+ break :restore_image restored;
+ }
}
// If the source path is unavailable, retain its bytes
// as an ordinary file rather than dropping the pane.
@@ -13630,30 +11624,25 @@ pub const Pardes = struct {
const content = if (raw.len > 0) raw else try gpa.dupe(u8, "");
raw = &.{};
errdefer if (content.len > 0) gpa.free(content);
- const pane = try p.newDocPane(i);
- pane.file = .{ .path = path, .content = content };
- pane.kind = .file;
- pane.cur_pinned = true;
- pane.cols = @max(1, src.cols);
- pane.rows = @max(1, src.rows);
+ const restored = try p.newDocPane(i);
+ restored.file = .{ .path = path, .content = content };
+ restored.cur_pinned = true;
+ restored.cols = @max(1, src.cols);
+ restored.rows = @max(1, src.rows);
p.emit(.{ .watch = .{ .pane = @intCast(i), .on = true } });
- break :restore_image;
- };
- const path = try p.image_gpa.dupe(u8, im.path);
- errdefer p.image_gpa.free(path);
- const pane = try p.newDocPane(i);
- pane.image = .{ .path = path, .raw = raw };
- raw = &.{};
- pane.kind = .image;
- pane.cols = @max(1, src.cols);
- pane.rows = @max(1, src.rows);
+ break :restore_image restored;
+ }
+ break :restore_image try image_pane.restore(p, i, src);
},
- }
- p.panes[i].?.vweight = src.vweight;
+ };
+ p.restoreDumpTail(pane, src);
+ pane.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;
+ const scaled = @as(f64, @floatCast(col.weight)) * @as(f64, @floatFromInt(column_weight_unit));
+ const bounded = @min(scaled, @as(f64, @floatFromInt(max_column_weight)));
+ p.col_weight[c] = @max(1, @as(u64, @intFromFloat(@round(bounded))));
p.col_n[c] = @min(col.panes.len, MAX_PANES);
for (col.panes[0..p.col_n[c]], 0..) |pid, k| p.col_terms[c][k] = pid;
}
@@ -13662,30 +11651,68 @@ pub const Pardes = struct {
p.applyStartupConfig();
p.finishThemeInitialization();
p.sync();
+ p.panel_animation_enabled = true;
_ = p.takeHaptic(); // see init: a restored session is not a gesture
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 restoreDumpTail(p: *Pardes, pane: *Pane, src: dump.Pane) void {
+ if (src.tag_tail) |tail| {
+ if (!pane.appendTag(tail)) return;
+ pane.tag_init = true;
+ return;
+ }
+ const tail_class: TailClass = if (src.kind == .file)
+ tailClass(pane)
+ else
+ .generic;
+ const current = p.tagPrefix(pane) catch return;
+ if (std.mem.startsWith(u8, src.tag, current))
+ return p.restoreTailAt(pane, src.tag, current, tail_class);
+ if (src.kind != .image) return;
+ const saved = src.image.?;
+ if (pane.image) |*state| if (image_pane.legacySavedPrefix(state, src.tag)) |legacy|
+ return p.restoreTailAt(pane, src.tag, legacy, .generic);
+ if (pdf_pane.isPath(saved.path)) if (pdf_pane.legacySavedPrefix(saved.path, src.tag)) |legacy|
+ p.restoreTailAt(pane, src.tag, legacy, .generic);
+ }
+
+ /// Compatibility path for dumps without explicit tag_tail: recover what
+ /// followed the rendered prefix, upgrading every historical default while
+ /// retaining genuinely edited bytes.
fn restoreTail(p: *Pardes, pane: *Pane, saved_tag: []const u8) void {
const pfx = p.tagPrefix(pane) catch return;
+ p.restoreTailAt(pane, saved_tag, pfx, tailClass(pane));
+ }
+
+ const TailClass = enum { generic, savable_file };
+
+ fn tailClass(pane: *const Pane) TailClass {
+ if (pane.file) |f| if (output_pane.fileTraits(f.output).saves) return .savable_file;
+ return .generic;
+ }
+
+ fn restoreTailAt(
+ _: *Pardes,
+ pane: *Pane,
+ saved_tag: []const u8,
+ pfx: []const u8,
+ class: TailClass,
+ ) void {
if (!std.mem.startsWith(u8, saved_tag, pfx)) return;
const rest = saved_tag[pfx.len..];
- const legacy = if (pane.file) |f|
- if (output_pane.fileTraits(f.output).saves) legacy_file_pane_tail else legacy_pane_tail
- else
- legacy_pane_tail;
// A saved tag carries its layout gap, because the padding is real
// characters — and the pane it is restored into is very often a
// different width than the one it was dumped from. So compare what the
// tail SAYS and not where it sat: leading spaces are layout, never
// content, and a default that came back padded is still a default.
const said = std.mem.trimStart(u8, rest, " ");
- if (std.mem.eql(u8, said, std.mem.trimStart(u8, curTail(pane), " ")) or
- std.mem.eql(u8, said, std.mem.trimStart(u8, legacy, " "))) return;
+ const defaults = switch (class) {
+ .generic => [2][]const u8{ pane_tail, legacy_pane_tail },
+ .savable_file => [2][]const u8{ file_pane_tail, legacy_file_pane_tail },
+ };
+ for (defaults) |default|
+ if (std.mem.eql(u8, said, std.mem.trimStart(u8, default, " "))) return;
if (!pane.appendTag(rest)) return;
pane.tag_init = true;
}
@@ -13832,6 +11859,7 @@ pub const Pardes = struct {
/// deferred greetings. The mirror of the prototype's loop epilogue.
fn sync(p: *Pardes) void {
p.computeGeom();
+ p.syncPanelAnimations();
p.trackJump();
for (&p.panes, 0..) |*slot, id| {
const pane = slot.* orelse continue;
@@ -13861,12 +11889,11 @@ pub const Pardes = struct {
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_len > 0) {
+ term_pane.releasePendingCommandIfReady(p, id, pane);
+ // Greet only after the real size AND OSC 133 B: arbitrary startup
+ // output (or OSC A plus a prompt drawn in pieces) does not prove
+ // readline owns echo, and injecting there leaves `ls` unmarked.
+ if (pane.greet and pane.isTerminal() and p.resize_count > 0 and term_pane.promptInputReady(pane)) {
p.emit(.{ .resize_pty = .{ .pane = @intCast(id), .cols = pane.cols, .rows = pane.rows } });
p.emit(.{ .write = .{ .pane = @intCast(id), .bytes = .from("ls\r") } });
pane.greet = false;
@@ -13874,18 +11901,250 @@ pub const Pardes = struct {
}
}
- fn computeGeom(p: *Pardes) void {
+ fn panelBox(rect: Rect) panel_animation.Box {
+ return .{
+ .x = @floatFromInt(rect.x),
+ .y = @floatFromInt(rect.y),
+ .w = @floatFromInt(rect.w),
+ .h = @floatFromInt(rect.h),
+ };
+ }
+
+ /// Turn one committed layout into backend-neutral transition records.
+ /// Layout remains authoritative and takes effect immediately; these tracks
+ /// are presentation data only, so disabling an effect cannot strand stale
+ /// geometry or alter hit testing.
+ fn syncPanelAnimations(p: *Pardes) void {
+ const initializing = !p.layout_snapshot_ready or !p.panel_animation_enabled;
+ if (initializing or p.snap_panel_layout_once) {
+ const had_presented_layout = p.layout_snapshot_ready;
+ p.layout_snapshot = @splat(null);
+ p.panel_tracks = @splat(null);
+ p.nclosing_panel_tracks = 0;
+ p.panel_diff_pending = false;
+ p.panel_diff_ready = false;
+ // Resize/direct-manipulation snaps still wait for their backend
+ // presentation acknowledgement. Clearing here would make input
+ // follow canonical geometry while the last submitted pixels were
+ // still animated. Initialization has no prior frame to preserve.
+ if (initializing) {
+ p.presented_panel_tracks = @splat(null);
+ p.npresented_closing_panel_tracks = 0;
+ }
+ for (p.panes, 0..) |slot, id| {
+ const pane = slot orelse continue;
+ p.layout_snapshot[id] = .{ .serial = pane.serial, .box = panelBox(p.rects[id]) };
+ }
+ p.layout_snapshot_ready = true;
+ p.snap_panel_layout_once = false;
+ if (had_presented_layout and p.panel_presentation_ready)
+ p.panel_presentation_pending = true;
+ return;
+ }
+
+ const effect = p.settings.panel_transition;
+ if (effect.needsPreviousGrid() and (p.panel_diff_pending or p.panel_diff_ready)) {
+ var second_change = false;
+ for (p.panes, p.layout_snapshot, 0..) |slot, snapshot, id| {
+ const pane = slot orelse {
+ second_change = second_change or snapshot != null;
+ continue;
+ };
+ const target = panelBox(p.rects[id]);
+ second_change = second_change or snapshot == null or
+ snapshot.?.serial != pane.serial or !snapshot.?.box.eql(target);
+ }
+ if (second_change) {
+ // One frozen old grid cannot honestly describe two overlapping
+ // generations. Snap rapid layout churn instead of rewinding a
+ // new opener or sliding stale survivor cells as a tombstone.
+ p.abandonPanelAnimations();
+ p.layout_snapshot = @splat(null);
+ for (p.panes, 0..) |slot, id| {
+ const pane = slot orelse continue;
+ p.layout_snapshot[id] = .{ .serial = pane.serial, .box = panelBox(p.rects[id]) };
+ }
+ return;
+ }
+ }
+ var changed = false;
+ var animated_change = false;
+ for (p.panes, 0..) |slot, id| {
+ const pane = slot orelse {
+ if (p.layout_snapshot[id]) |old| {
+ changed = true;
+ if (effect.lifecycleOnly()) {
+ if (p.appendClosingPanelTrack(.{
+ .serial = old.serial,
+ .pane = @intCast(id),
+ .phase = .closing,
+ .effect = effect,
+ .from = old.box,
+ .to = panel_animation.closingBox(effect, old.box),
+ })) animated_change = true;
+ }
+ }
+ p.layout_snapshot[id] = null;
+ p.panel_tracks[id] = null;
+ // Never let pixels from a dead pane address a reused slot.
+ p.presented_panel_tracks[id] = null;
+ continue;
+ };
+ const target = panelBox(p.rects[id]);
+ const previous = p.layout_snapshot[id];
+ p.layout_snapshot[id] = .{ .serial = pane.serial, .box = target };
+
+ if (effect == .off) {
+ changed = changed or previous == null or previous.?.serial != pane.serial or
+ !previous.?.box.eql(target);
+ p.panel_tracks[id] = null;
+ continue;
+ }
+ if (previous) |old| {
+ if (old.serial == pane.serial and old.box.eql(target)) continue;
+ const same_lifetime = old.serial == pane.serial;
+ const prior = if (p.panel_tracks[id]) |track|
+ if (track.serial == pane.serial and track.active()) track else null
+ else
+ null;
+ if (effect.lifecycleOnly() and same_lifetime) {
+ // A vertical lifecycle transition deliberately leaves
+ // every survivor at canonical geometry. A still-opening
+ // lifetime remains an opener when another layout commit
+ // retargets it; an established survivor gets no record.
+ if (prior) |active| if (active.phase == .opening) {
+ var next = active;
+ next.from = panel_animation.openingBox(effect, target, p.screen_w);
+ next.to = target;
+ p.panel_tracks[id] = next;
+ changed = true;
+ animated_change = true;
+ continue;
+ };
+ p.panel_tracks[id] = null;
+ changed = true;
+ continue;
+ }
+ if (effect.lifecycleOnly() and !same_lifetime) {
+ _ = p.appendClosingPanelTrack(.{
+ .serial = old.serial,
+ .pane = @intCast(id),
+ .phase = .closing,
+ .effect = effect,
+ .from = old.box,
+ .to = panel_animation.closingBox(effect, old.box),
+ });
+ }
+ const shown = if (p.presented_panel_layout[id]) |snapshot|
+ if (snapshot.serial == pane.serial) snapshot.box else null
+ else
+ null;
+ const from = if (!same_lifetime)
+ panel_animation.openingBox(effect, target, p.screen_w)
+ else if (shown) |box|
+ box
+ else if (p.panel_presentation_ready and prior != null)
+ prior.?.from
+ else
+ old.box;
+ const next: panel_animation.Track = .{
+ .serial = pane.serial,
+ .pane = @intCast(id),
+ .phase = if (!same_lifetime or
+ (prior != null and prior.?.phase == .opening)) .opening else .moving,
+ .effect = effect,
+ .from = from,
+ .to = target,
+ };
+ p.panel_tracks[id] = next;
+ changed = true;
+ animated_change = true;
+ } else {
+ const next: panel_animation.Track = .{
+ .serial = pane.serial,
+ .pane = @intCast(id),
+ .phase = .opening,
+ .effect = effect,
+ .from = panel_animation.openingBox(effect, target, p.screen_w),
+ .to = target,
+ };
+ p.panel_tracks[id] = next;
+ changed = true;
+ animated_change = true;
+ }
+ }
+ if (animated_change and effect.needsPreviousGrid()) {
+ p.panel_diff_pending = true;
+ p.panel_diff_ready = false;
+ }
+ if (changed and p.panel_presentation_ready) p.panel_presentation_pending = true;
+ }
+
+ fn appendClosingPanelTrack(p: *Pardes, track: panel_animation.Track) bool {
+ std.debug.assert(track.phase == .closing);
+ const baseline = p.presented_cells_layout[track.pane] orelse return false;
+ if (!p.presented_cells_valid or baseline.serial != track.serial or
+ !baseline.box.eql(track.from)) return false;
+ if (p.nclosing_panel_tracks == p.closing_panel_tracks.len) {
+ // Bounded presentation history: under pathological delete/reuse
+ // churn, retire the oldest (and therefore furthest-progressed)
+ // tombstone rather than retaining a pane or allocating per Del.
+ std.mem.copyForwards(
+ panel_animation.Track,
+ p.closing_panel_tracks[0 .. p.closing_panel_tracks.len - 1],
+ p.closing_panel_tracks[1..],
+ );
+ p.nclosing_panel_tracks -= 1;
+ }
+ p.closing_panel_tracks[p.nclosing_panel_tracks] = track;
+ p.nclosing_panel_tracks += 1;
+ return true;
+ }
+
+ fn advancePanelAnimations(p: *Pardes) void {
+ for (&p.panel_tracks) |*slot| {
+ const track = if (slot.*) |*track| track else continue;
+ track.frame +|= 1;
+ if (!track.active()) slot.* = null;
+ }
+ var out: usize = 0;
+ for (p.closing_panel_tracks[0..p.nclosing_panel_tracks]) |value| {
+ var track = value;
+ track.frame +|= 1;
+ if (!track.active()) continue;
+ p.closing_panel_tracks[out] = track;
+ out += 1;
+ }
+ p.nclosing_panel_tracks = out;
+ }
+
+ fn columnBoundary(width: u16, prefix: u128, total: u128) u16 {
+ if (total == 0) return 0;
+ const pixels = (@as(u128, width) * prefix + total / 2) / total;
+ return @intCast(@min(@as(u128, width), pixels));
+ }
+
+ pub fn computeGeom(p: *Pardes) void {
if (p.ncol == 0) return;
- var wsum: f32 = 0;
+ var wsum: u128 = 0;
for (0..p.ncol) |c| wsum += p.col_weight[c];
- if (wsum <= 0) wsum = 1;
+ if (wsum == 0) wsum = 1;
- var x: u16 = 0;
+ // Round cumulative boundaries, not each width independently. Splitting
+ // one weight W into A+B=W then leaves every boundary before A and
+ // after B bit-identical, at every screen width; independent rounding
+ // can move a later column by one cell even though its own weight and
+ // the total did not change.
+ var prefix: u128 = 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)))));
+ const x = columnBoundary(p.screen_w, prefix, wsum);
+ prefix += p.col_weight[c];
+ const end: u16 = if (last)
+ p.screen_w
+ else
+ columnBoundary(p.screen_w, prefix, wsum);
+ const cw = end -| x;
p.col_x[c] = x;
p.col_w[c] = cw;
@@ -13913,12 +12172,11 @@ pub const Pardes = struct {
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];
+ return &themes[p.settings.theme];
}
/// Displayed colors for anchored UI chrome. Unlike `theme()`, this may be
@@ -13927,8 +12185,16 @@ pub const Pardes = struct {
return &p.chrome_animation.displayed;
}
- pub fn themeAnimationActive(p: *const Pardes) bool {
- return p.chrome_animation.isActive();
+ /// Whether an idle frontend should schedule another tick. All frame-based
+ /// core work belongs in this one predicate so TTY and GUI mature it alike.
+ pub fn animationActive(p: *const Pardes) bool {
+ if (p.chrome_animation.isActive() or p.look_hover_wait != null) return true;
+ const scene = p.settings.scene_effects;
+ if (scene.crt or scene.ripple or scene.glitch) return true;
+ for (p.panel_tracks) |track| if (track != null and track.?.active()) return true;
+ for (p.closing_panel_tracks[0..p.nclosing_panel_tracks]) |track|
+ if (track.active()) return true;
+ return false;
}
/// Arm the pulse. Look wins a tie because a Look that runs a command
@@ -13958,17 +12224,17 @@ pub const Pardes = struct {
/// PDFs whose pixels depend on target theme colors are marked stale, once;
/// untinted source rasters remain byte-for-byte resident.
pub fn setThemeIndex(p: *Pardes, index: usize) void {
- if (index >= themes.len or index == p.theme_idx) return;
+ if (index >= themes.len or index == @as(usize, p.settings.theme)) return;
const target_chrome = ChromeTheme.fromTheme(&themes[index]);
if (p.animate_theme_changes)
p.chrome_animation.retarget(target_chrome)
else
p.chrome_animation.snap(target_chrome);
- p.theme_idx = index;
+ p.settings.theme = @intCast(index);
if (comptime pdf_enabled) for (p.panes) |slot| {
const pane = slot orelse continue;
const pv = &(pane.pdf orelse continue);
- if (pv.tint != .disabled) p.invalidateAllPdfRasters(pv);
+ if (pv.tint != .disabled) pv.invalidateAllRasters();
};
}
@@ -13986,6 +12252,9 @@ pub const Pardes = struct {
s.rows = p.screen_h;
s.cursor = null;
s.nimages = 0;
+ s.npanel_tracks = 0;
+ s.previous_cells = &.{};
+ s.changed_cells = &.{};
const chrome = p.chromeTheme();
const th = p.theme(); // the message row paints in the editor's colours
// gaps between panes read as chrome (scrollbar track), not raw default
@@ -13993,9 +12262,8 @@ pub const Pardes = struct {
for (&p.panes, 0..) |*slot, id| {
const pane = slot.* orelse continue;
- try p.renderPane(arena, pane, p.rects[id], id == p.active);
+ try p.renderPane(arena, pane, p.rects[id], id, id == p.active);
}
-
// ---- the transient message row: the pane's last body row ----
//
// An OVERLAY, not geometry: no rect moves, no pane shrinks, and a pane
@@ -14050,7 +12318,7 @@ pub const Pardes = struct {
// far end, symmetric with the tag — which put the prompt you are
// typing as far as the pane allows from the tag you are typing
// into. Beside the tagline is where it is read, so it stays there.
- const row = if (p.tag_bottom) r.y + r.h - 1 - BOX_H else r.y + r.h - 1;
+ const row = if (p.settings.tag_bottom) r.y + r.h - 1 - BOX_H else r.y + r.h - 1;
// In the EDITOR's colours, not the tag bar's: this row is the one
// place the program talks back to you about the buffer you are in,
// and it reads as part of that buffer rather than as another strip
@@ -14097,9 +12365,19 @@ pub const Pardes = struct {
}
// global tagbar: full width, top row
- s.fill(0, 0, s.cols, TOPBAR_H, .{ .bg = .{ .rgb = chrome.tag_bg } });
+ s.fill(0, 0, s.cols, TOPBAR_H, .{
+ .bg = .{ .rgb = chrome.tag_bg },
+ // The blank tail is the same visible band as the printed words.
+ // SDL used to repair this at draw time by special-casing row zero,
+ // but native hosts consume the role carried by each cell.
+ .font_role = .tagline,
+ });
var tb_buf: [1200]u8 = undefined;
- _ = s.print(0, 0, s.cols, p.topbar(&tb_buf), .{ .fg = .{ .rgb = chrome.tag_fg }, .bg = .{ .rgb = chrome.tag_bg } });
+ _ = s.print(0, 0, s.cols, p.topbar(&tb_buf), .{
+ .fg = .{ .rgb = chrome.tag_fg },
+ .bg = .{ .rgb = chrome.tag_bg },
+ .font_role = .tagline,
+ });
// 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.
@@ -14144,7 +12422,7 @@ pub const Pardes = struct {
s.set(d.cur_x, placement.row, "▌", .{ .fg = .{ .rgb = chrome.lineno } });
}
},
- .none, .select, .tag => {
+ .none, .select, .tag => if (p.pointer_inside) {
// hover hints on a pane's own trailing edge
var c: usize = 0;
while (c + 1 < p.ncol) : (c += 1) {
@@ -14178,7 +12456,7 @@ pub const Pardes = struct {
}
// debug overlay: a small stats box drawn last, top-right ("Debug")
- if (p.show_debug) if (p.panes[p.active]) |at| {
+ if (p.settings.debug) if (p.panes[p.active]) |at| {
var nlive: usize = 0;
for (p.panes) |slot| {
if (slot != null) nlive += 1;
@@ -14200,12 +12478,13 @@ pub const Pardes = struct {
// it was asked for; nothing has asked when this is empty and the
// shell is still in whatever it booted in. Clamped because a name
// is a file stem and a path component can be as long as a path.
- const font_name = if (fonts.chosen.len == 0) "default" else fonts.chosen;
+ const effective_font = p.settings.font.effective_name.get();
+ const font_name = if (effective_font.len == 0) "default" else effective_font;
var ov_buf: [256]u8 = undefined;
const text = std.fmt.bufPrint(
&ov_buf,
"pardes resizes={d}\ntheme {s}\nfont {s}\nterms {d} cols {d}\nactive #{d} {d}x{d}\nscroll {d}/{d}\npinch {d:6.4}\ntscroll {d:7.4}",
- .{ p.resize_count, themes[p.theme_idx].name, font_name[0..@min(font_name.len, 64)], nlive, p.ncol, p.active, at.cols, at.rows, sb_off, sb_total, p.ov_pinch_scale, p.ov_touch_scroll_delta },
+ .{ p.resize_count, p.theme().name, font_name[0..@min(font_name.len, 64)], nlive, p.ncol, p.active, at.cols, at.rows, sb_off, sb_total, p.ov_pinch_scale, p.ov_touch_scroll_delta },
) catch "";
// wide enough for the longest theme name compiled in, which is the
// only row here whose width is not ours to choose
@@ -14237,10 +12516,109 @@ pub const Pardes = struct {
}
}
};
+ p.submitted_panel_layout = @splat(null);
+ for (p.panes, 0..) |slot, id| {
+ const pane = slot orelse continue;
+ p.submitted_panel_layout[id] = .{
+ .serial = pane.serial,
+ .box = panelBox(p.rects[id]),
+ };
+ }
+ p.submitted_panel_layout_ready = true;
+ // The old grid stays frozen for the transition, while terminals,
+ // watches, hover chrome, and other live data may change the new grid
+ // between samples. Rebuild the cheap byte mask every frame so the
+ // published (old, new, changed) triple is always coherent.
+ if ((p.panel_diff_pending or p.panel_diff_ready) and !try p.preparePanelDiff()) {
+ // There was no successfully presented same-sized old grid. A
+ // content effect cannot guess one: snap this transition rather
+ // than animating uninitialised/stale cells.
+ for (&p.panel_tracks) |*slot| {
+ const track = slot.* orelse continue;
+ if (track.effect.needsPreviousGrid()) slot.* = null;
+ }
+ p.nclosing_panel_tracks = 0;
+ p.panel_diff_pending = false;
+ p.panel_diff_ready = false;
+ }
+ if (p.panel_diff_ready) {
+ s.previous_cells = p.presented_cells;
+ s.changed_cells = p.panel_changed_cells;
+ }
+ // Moving panes first, then new panes, then inert closing tombstones on
+ // top. Native GUI paint planners may regroup by phase, but every host
+ // receives this same deterministic dense record set.
+ for ([_]panel_animation.Phase{ .moving, .opening }) |phase| for (p.panel_tracks) |maybe| {
+ const track = maybe orelse continue;
+ if (!track.active() or track.phase != phase) continue;
+ s.panel_tracks[s.npanel_tracks] = track;
+ s.npanel_tracks += 1;
+ };
+ for (p.closing_panel_tracks[0..p.nclosing_panel_tracks]) |track| {
+ if (!track.active()) continue;
+ s.panel_tracks[s.npanel_tracks] = track;
+ s.npanel_tracks += 1;
+ }
return s;
}
- fn renderPane(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, active: bool) !void {
+ fn preparePanelDiff(p: *Pardes) !bool {
+ const count = p.surface.cells.len;
+ if (!p.presented_cells_valid or
+ p.presented_cells_cols != p.surface.cols or
+ p.presented_cells_rows != p.surface.rows or
+ p.presented_cells.len != count) return false;
+ if (p.panel_changed_cells.len != count) {
+ const next = try p.gpa.alloc(u8, count);
+ if (p.panel_changed_cells.len > 0) p.gpa.free(p.panel_changed_cells);
+ p.panel_changed_cells = next;
+ }
+ for (p.panel_changed_cells, p.presented_cells, p.surface.cells) |*changed, *old, *new|
+ changed.* = @intFromBool(!old.visuallyEqual(new));
+ p.panel_diff_pending = false;
+ p.panel_diff_ready = true;
+ return true;
+ }
+
+ /// Paint a selection expressed in the coordinate space used by pointer
+ /// gestures. Both the persistent mouse selections and the delayed Look
+ /// preview come through this one clipping/mapping path.
+ fn paintPointerSelection(
+ s: *Surface,
+ pane: *const Pane,
+ r: Rect,
+ tx: u16,
+ tw: u16,
+ tag_y: u16,
+ body_y: u16,
+ sl: Sel,
+ bg: [3]u8,
+ fg: ?[3]u8,
+ ) void {
+ 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;
+ // A Sel row is independent of Tagbottom: zero is the tag and
+ // BOX_H upward is the body. This is the render-side inverse of
+ // pointerTextSelection.
+ const sy = if (row < BOX_H) tag_y else body_y + row - BOX_H;
+ // File line numbers occupy PREFIX_W only in the body. The tag is
+ // row zero in Sel space and starts at its real first text cell.
+ var col: i32 = if (pane.file != null and row >= BOX_H) @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)), sy);
+ cell.default = false;
+ cell.style.bg = .{ .rgb = bg };
+ if (fg) |ink| cell.style.fg = .{ .rgb = ink };
+ }
+ }
+ }
+
+ fn renderPane(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id: usize, active: bool) !void {
const tz = tracy.zone(@src(), "renderPane");
defer tz.end();
if (r.w <= config.GUTTER or r.h == 0) return;
@@ -14253,8 +12631,8 @@ pub const Pardes = struct {
// body's first. The Tagbottom builtin swaps which end each is at and
// NOTHING else in here reads r.y — that is the whole of the feature on
// the render side. r.h == 0 returned above, so the bottom row exists.
- const tag_y = if (p.tag_bottom) r.y + r.h -| BOX_H else r.y;
- const body_y = if (p.tag_bottom) r.y else r.y + BOX_H;
+ const tag_y = if (p.settings.tag_bottom) r.y + r.h -| BOX_H else r.y;
+ const body_y = if (p.settings.tag_bottom) r.y else r.y + BOX_H;
// 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
@@ -14263,7 +12641,7 @@ pub const Pardes = struct {
// ...and the same background as a colour to do arithmetic on, which the
// dimmed selections below need. A theme with a null bg cannot say what
// the host's own cell looks like, so its tag bar stands in — the
- // substitution pdfTintColors already makes.
+ // substitution pdf_pane.tintColors already makes.
const page_rgb = th.bg orelse th.tag_bg;
// text area resets to terminal-default cells (vaxis clear semantics);
@@ -14299,7 +12677,13 @@ pub const Pardes = struct {
const box_bg = if (active) chrome.box else chrome.box_dim;
const box_lum = (@as(u16, box_bg[0]) * 3 + @as(u16, box_bg[1]) * 6 + @as(u16, box_bg[2])) / 10;
const box_ink: [3]u8 = if (box_lum > 140) .{ 0x00, 0x00, 0x00 } else .{ 0xff, 0xff, 0xff };
- const box_style: CellStyle = .{ .fg = .{ .rgb = box_ink }, .bg = .{ .rgb = box_bg } };
+ const box_style: CellStyle = .{
+ .fg = .{ .rgb = box_ink },
+ .bg = .{ .rgb = box_bg },
+ // The mode badge is the left end of the tagline, not body text.
+ // Carry the same role so every GUI backend scales the whole row.
+ .font_role = .tagline,
+ };
s.fill(r.x, tag_y, config.GUTTER, BOX_H, box_style);
const box_mode = if (pane.tag_edit) pane.tag_mode else pane.mode;
s.set(r.x, tag_y, switch (box_mode) {
@@ -14309,8 +12693,13 @@ pub const Pardes = struct {
}, box_style);
// tag: live prefix (cwd/path) + editable tail, on a dim bar
- const tag_style: CellStyle = .{ .fg = .{ .rgb = chrome.tag_fg }, .bg = .{ .rgb = chrome.tag_bg } };
- s.fill(tx, tag_y, tw, BOX_H, .{ .bg = .{ .rgb = chrome.tag_bg } });
+ const tag_style: CellStyle = .{ .fg = .{ .rgb = chrome.tag_fg }, .bg = .{ .rgb = chrome.tag_bg }, .font_role = .tagline };
+ // Padding belongs to the same font row but keeps its default ink: a
+ // role must not manufacture a foreground color for blank cells.
+ s.fill(tx, tag_y, tw, BOX_H, .{
+ .bg = .{ .rgb = chrome.tag_bg },
+ .font_role = .tagline,
+ });
const tag = try p.tagText(arena, pane);
// An armed input — `/`, Find, Grep, Rename, WsSymbols, Select/Split,
// `|` — is still TYPED INTO the tag tail: same buffer, same offsets,
@@ -14328,6 +12717,15 @@ pub const Pardes = struct {
else
tag.len;
_ = s.print(tx, tag_y, tw, tag[0..tag_cut], tag_style);
+ // Paint tag hover before every real tag selection. This common point
+ // is above the native-PDF/image early returns, so their tag operands
+ // are no longer hidden, while an explicit sweep remains authoritative.
+ if (p.look_hover_preview) |preview| {
+ if (preview.pane == id and preview.serial == pane.serial) if (preview.sel) |sel| if (@min(sel.r0, sel.r1) < BOX_H) {
+ const preview_bg = mix(page_rgb, mix(page_rgb, th.sel_bg));
+ paintPointerSelection(s, pane, r, tx, tw, tag_y, body_y, sel, preview_bg, null);
+ };
+ }
// tag char selection highlight (helix v/x, or a tagline sweep),
// inclusive [lo, hi] — rendered-tag columns, so no conversion.
//
@@ -14356,40 +12754,14 @@ pub const Pardes = struct {
// 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 (comptime pdf_enabled) {
- if (hasPdf(pane) and p.native_images and p.drawPdf(pane, r, tx, tw)) {
- s.fill(r.x, body_y, 1, r.h -| BOX_H, .{ .bg = .{ .rgb = chrome.scroll_track } });
- s.fill(r.x + 1, body_y, 1, r.h -| BOX_H, .{ .bg = pane_bg });
- if (r.h > BOX_H) {
- const pv = &pane.pdf.?;
- const track_h: usize = r.h - BOX_H;
- const viewport = p.pdfViewport(pane).?;
- const total = @max(@as(u64, 1), pv.document_height);
- const len = @max(
- @as(usize, 1),
- @as(usize, @intCast(@min(
- @as(u64, track_h),
- @as(u64, track_h) * viewport.pixel_h / total,
- ))),
- );
- const offset: u64 = @intFromFloat(@floor(pv.document_scroll_y));
- const pos: usize = @intCast(@min(
- @as(u64, track_h -| 1),
- @as(u64, track_h) * offset / total,
- ));
- var sy = pos;
- while (sy < track_h and sy < pos + len) : (sy += 1)
- s.fill(r.x, body_y + @as(u16, @intCast(sy)), 1, 1, .{ .bg = .{ .rgb = chrome.scroll_thumb } });
- }
- return;
- }
- }
+ if (comptime pdf_enabled)
+ if (hasPdf(pane) and pdf_pane.draw(p, pane, r, id, tx, tw)) 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);
+ if (pane.image) |*iv| {
+ image_pane.draw(p, iv, @intCast(id), pane.serial, tx, body_y, tw, r.h -| BOX_H);
// thumbless, but the same one column as the real scrollbar below —
// that is the whole point of drawing it
s.fill(r.x, body_y, 1, r.h -| BOX_H, .{ .bg = .{ .rgb = chrome.scroll_track } });
@@ -14413,37 +12785,18 @@ pub const Pardes = struct {
_ = s.print(tx, body_y + i, tw, line, body_style);
}
- // the two file-only passes and the terminal-only one: each is the
+ // the 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);
- // the break marker, in the column bodyText kept free for it: a row
- // that continues says so where it broke. Drawn after the syntax
- // pass so nothing recolors it (it is chrome, not file bytes) and
- // before the selection passes, which still win over it — a swept
- // wrapped line should highlight whole.
- //
- // ponytail: a row continues iff the NEXT row shows the same line,
- // so the bottom row of a pane never carries one — what it continues
- // into is off screen anyway. Record the cut in bodyText next to the
- // map the day that reads as a lie.
- if (tw > config.PREFIX_W + 1) {
- var wr: u16 = 0;
- while (wr + 1 < pane.wrap_n and wr + 1 < body_h) : (wr += 1) {
- if (pane.wrap_line[wr + 1] != pane.wrap_line[wr]) continue;
- s.set(tx + tw - 1, body_y + wr, config.wrap_marker, .{
- .fg = .{ .rgb = chrome.lineno },
- .bg = pane_bg,
- });
- }
- }
+ if (p.settings.colors) file_pane.recolorSyntax(p, pane, f, r, tx, tw, body_h);
+ file_pane.drawWrapMarkers(p, pane, r, tx, tw, body_h, pane_bg);
}
const off = pane.scroll();
- if (p.colors_on and pane.isTerminal()) term_pane.recolorAnsi(p, pane, r, tx, tw, body_h, off);
+ if (p.settings.colors 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;
@@ -14470,30 +12823,34 @@ pub const Pardes = struct {
mix(th.sel_bg, mix(th.sel_bg, th.str)),
mix(th.sel_bg, mix(th.sel_bg, accent2)),
};
+ if (p.look_hover_preview) |preview| {
+ if (preview.pane == id and preview.serial == pane.serial) {
+ // Preserve body ink, then let every real user selection below
+ // win over this quiet affordance.
+ const preview_bg = mix(page_rgb, mix(page_rgb, th.sel_bg));
+ if (preview.file_word) |word|
+ file_pane.paintWordSelection(p, pane, r, word.row, word.lo, word.hi, preview_bg)
+ else if (preview.sel) |sel|
+ if (@max(sel.r0, sel.r1) >= BOX_H)
+ paintPointerSelection(s, pane, r, tx, tw, tag_y, body_y, sel, preview_bg, null);
+ }
+ }
+ const modal_hover = if (p.look_hover_preview) |preview|
+ preview.pane == id and preview.serial == pane.serial and preview.sel == null and preview.file_word == null
+ else
+ false;
for (pane.sel, 0..) |sl, b| {
if (sl.state == .none) continue;
if (sl.state == .done and b != 0) continue;
- 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;
- // a Sel row, so 0 is the tag and BOX_H upward is the body —
- // the one place the two coordinate spaces meet on the render side
- const sy = if (row < BOX_H) tag_y else body_y + row - BOX_H;
- 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)), sy);
- cell.default = false;
- cell.style.bg = .{ .rgb = sel_btn[b] };
- cell.style.fg = .{ .rgb = th.sel_fg };
- }
- }
+ paintPointerSelection(s, pane, r, tx, tw, tag_y, body_y, sl, sel_btn[b], th.sel_fg);
}
// modal line selection (helix `x`): whole body rows, normal mode only
- if (pane.mode == .normal and pane.msel.active) {
+ if ((pane.mode == .normal or modal_hover) and pane.msel.active) {
+ const hover_only = modal_hover and pane.mode != .normal;
+ const modal_bg = if (hover_only)
+ mix(page_rgb, mix(page_rgb, th.sel_bg))
+ else
+ th.sel_bg;
const lo = @min(pane.msel.r0, pane.msel.r1);
const hi = @max(pane.msel.r0, pane.msel.r1);
var row: u16 = BOX_H; // never paint the tag row
@@ -14507,8 +12864,8 @@ pub const Pardes = struct {
while (col < tw) : (col += 1) {
const cell = s.at(tx + col, body_y + row - BOX_H);
cell.default = false;
- cell.style.bg = .{ .rgb = th.sel_bg };
- cell.style.fg = .{ .rgb = th.sel_fg };
+ cell.style.bg = .{ .rgb = modal_bg };
+ if (!hover_only) cell.style.fg = .{ .rgb = th.sel_fg };
}
}
}
@@ -14525,7 +12882,8 @@ pub const Pardes = struct {
// 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_prim = (pane.mode == .normal and pane.vsel.active) or preview or
+ (modal_hover and pane.vsel.active);
const show_extra = pane.mode != .tty and pane.nsel > 0;
if (show_prim or show_extra) {
const vpfx: i32 = if (pane.file != null) config.PREFIX_W else 0;
@@ -14540,6 +12898,7 @@ pub const Pardes = struct {
.acol = if (pane.vsel.active) pane.vsel.col else pane.cur_col,
} else pane.sels[si];
const bnd = cellBounds(sr);
+ const hover_only = primary and modal_hover and pane.mode != .normal and !preview;
// an extra cursor is the selection colour turned down: the
// highlight pulled halfway back to the page, so the primary is
// the one that reads as "here" at a glance. On a light theme
@@ -14549,7 +12908,12 @@ pub const Pardes = struct {
// walks the text and its cell toward each other until neither
// is readable on the themes whose selection is already close to
// the page.
- const bg = if (primary) th.sel_bg else mix(th.sel_bg, page_rgb);
+ const bg = if (hover_only)
+ mix(page_rgb, mix(page_rgb, th.sel_bg))
+ else if (primary)
+ th.sel_bg
+ else
+ mix(th.sel_bg, page_rgb);
// ...and this walks SCREEN rows too, asking the map which line
// and which byte column of it each one shows. Unwrapped that is
// `off + vr` / `hscroll`, i.e. the arithmetic this was, and it
@@ -14561,11 +12925,11 @@ pub const Pardes = struct {
const w = pane.wrapAt(vr);
if (w.line < bnd.lo_row or w.line > bnd.hi_row) continue;
const cstart: i32 = if (w.line == bnd.lo_row)
- (if (pane.file != null) fileDisplayOffset(pane, w.line, w.at, bnd.lo_col) else bnd.lo_col - w.at) + vpfx
+ (if (pane.file != null) file_pane.displayOffset(pane, w.line, w.at, bnd.lo_col) else bnd.lo_col - w.at) + vpfx
else
vpfx;
const cend: i32 = if (w.line == bnd.hi_row)
- (if (pane.file != null) fileDisplayEndOffset(pane, w.line, w.at, bnd.hi_col) else bnd.hi_col - w.at) + vpfx
+ (if (pane.file != null) file_pane.displayEndOffset(pane, w.line, w.at, bnd.hi_col) else bnd.hi_col - w.at) + vpfx
else
@as(i32, tw) - 1;
var col: i32 = @max(cstart, vpfx);
@@ -14573,13 +12937,14 @@ pub const Pardes = struct {
const cell = s.at(tx + @as(u16, @intCast(col)), body_y + @as(u16, @intCast(vr)));
cell.default = false;
cell.style.bg = .{ .rgb = bg };
- cell.style.fg = .{ .rgb = th.sel_fg };
+ if (!hover_only) cell.style.fg = .{ .rgb = th.sel_fg };
}
}
- if (primary and !preview) continue; // the hardware cursor IS the primary's
+ if (primary and !preview and !hover_only) continue; // the hardware cursor IS the primary's
+ if (hover_only) continue; // quiet preview preserves the source ink
const cw = pane.wrapRow(sr.row, sr.col);
const crow = cw.row + @as(i32, BOX_H);
- const ccol = (if (pane.file != null) fileDisplayOffset(pane, sr.row, cw.at, sr.col) else sr.col - cw.at) + vpfx;
+ const ccol = (if (pane.file != null) file_pane.displayOffset(pane, sr.row, cw.at, sr.col) else sr.col - cw.at) + vpfx;
if (crow >= BOX_H and crow < @as(i32, r.h) and ccol >= vpfx and ccol < tw) {
const cell = s.at(tx + @as(u16, @intCast(ccol)), body_y + @as(u16, @intCast(crow - BOX_H)));
cell.default = false;
@@ -14605,7 +12970,7 @@ pub const Pardes = struct {
// Files store source-byte columns; the Surface stores display
// cells, so account for every expanded tab before the cursor.
const cx = if (pane.file != null)
- @as(i32, config.PREFIX_W) + fileDisplayOffset(pane, crow, cwp.at, ccol)
+ @as(i32, config.PREFIX_W) + file_pane.displayOffset(pane, crow, cwp.at, ccol)
else
ccol;
if (prow >= BOX_H and cx >= 0 and prow < r.h and cx < tw)
@@ -14660,7 +13025,7 @@ pub const Pardes = struct {
}
}
- fn appendImagePlace(p: *Pardes, place: ImagePlace) bool {
+ pub fn appendImagePlace(p: *Pardes, place: ImagePlace) bool {
const s = &p.surface;
if (s.nimages == s.images.len) {
const old_len = s.images.len;
@@ -14677,333 +13042,6 @@ pub const Pardes = struct {
return true;
}
- fn pdfVisibleContains(visible: PdfVisiblePages, page: usize) bool {
- return page >= visible.first and page - visible.first < visible.len;
- }
-
- /// How far a frame must travel before its pages are worth only their
- /// visible rows. One screenful: at that speed a page is on screen for this
- /// frame and gone by the next, so the rows outside the viewport are pixels
- /// nobody will ever see. Below it, nothing changes — a reader nudging
- /// through a document keeps getting whole pages, which is what makes the
- /// following frames free.
- fn pdfFlinging(pv: *const PdfView, viewport: PdfViewport) bool {
- if (comptime !pdf_enabled) return false;
- return pv.scroll_travel >= @as(f64, @floatFromInt(viewport.pixel_h));
- }
-
- /// Band heights are rounded out to this many rows, so that consecutive
- /// pages of a fling ask for buffers of the SAME length and the retired-
- /// buffer relay can hand one page's bytes to the next instead of going back
- /// to the allocator every frame. Rounding out also makes the covered rows
- /// outlast a few frames of ordinary scrolling.
- ///
- /// 64 by measurement, not by taste: on the 5363-page Intel manual, grain 64
- /// runs a fling at a median 2.38-2.43 ms per frame against 2.49-2.52 at 128
- /// and 2.93-3.03 at 256 (rounding out pixels nobody sees), while 32 matches
- /// 64's median with a visibly worse tail — the buffer lengths stop repeating
- /// and the relay starts missing. 48 and 96 are indistinguishable from 64.
- const pdf_band_grain: usize = 64;
-
- /// The rows of `page` worth rasterizing this frame: the whole page at
- /// reading speed, and the visible strip (grain-rounded) during a fling.
- fn pdfWantedBand(
- pv: *const PdfView,
- viewport: PdfViewport,
- page: usize,
- shape: pdf_impl.Raster,
- flinging: bool,
- ) pdf_impl.Raster.Band {
- if (!flinging) return shape.wholePage();
- const rows = pdfVisibleRows(pv, viewport, page, shape.width, shape.height) orelse
- return shape.wholePage();
- const grain = @max(@as(usize, 1), pdf_band_grain);
- const first = (@as(usize, rows.y0) / grain) * grain;
- const last = std.math.divCeil(usize, @as(usize, rows.y1), grain) catch
- return shape.wholePage();
- return shape.band(first, last * grain - first);
- }
-
- /// The raster shape a retained slot was rendered against. Identical to what
- /// `measureRenderAt` would answer for the same request — which is exactly
- /// why the reconcile may use it instead of asking, as long as the request
- /// has not changed (a changed request is `stale` and re-measures).
- fn pdfSlotShape(slot: *const PdfRaster) pdf_impl.Raster {
- const stride = slot.iw * 4;
- return .{
- .width = slot.iw,
- .height = slot.ih,
- .stride = stride,
- .len = stride * slot.ih,
- };
- }
-
- fn reconcilePdfRasters(
- p: *Pardes,
- pv: *PdfView,
- request: pdf_impl.RenderRequest,
- highlights: []const PdfHighlight,
- visible: PdfVisiblePages,
- viewport: PdfViewport,
- ) void {
- if (comptime !pdf_enabled) return;
- const tz = tracy.zone(@src(), "pdf.reconcile");
- defer tz.end();
- const tint_key = p.pdfTintKey(pv);
-
- // Remove first, and remove EVERYTHING outside the visible set: owned
- // RGBA never accumulates with document length, and backends see stale
- // keys disappear in this same frame. Removing before rendering is also
- // what fills the buffer relay, so the pages arriving below take the
- // departing pages' bytes rather than the allocator's.
- //
- // Only the end-of-frame visible set can ever be SEEN: a page the
- // viewport merely swept across inside one event batch is never drawn,
- // so a fling rasterizes what it lands on, not what it passed through.
- var index: usize = 0;
- while (index < pv.rasters_len) {
- if (pdfVisibleContains(visible, pv.rasters[index].page)) {
- index += 1;
- continue;
- }
- p.retirePdfRaster(pv, &pv.rasters[index]);
- pv.rasters_len -= 1;
- if (index != pv.rasters_len) pv.rasters[index] = pv.rasters[pv.rasters_len];
- }
-
- const flinging = pdfFlinging(pv, viewport);
- var page = visible.first;
- const end = visible.first + visible.len;
- while (page < end) : (page += 1) {
- var raster = pdfRasterForPage(pv, page);
- if (raster == null) {
- if (pv.rasters_len == pv.rasters.len) continue;
- pv.rasters[pv.rasters_len] = .{ .valid = true, .page = page };
- pv.rasters_len += 1;
- raster = &pv.rasters[pv.rasters_len - 1];
- }
- const slot = raster.?;
- const decorated = page == pv.page and highlights.len > 0;
- // Everything that makes the pixels wrong rather than merely
- // insufficient. Checked first and cheaply: a request or tint change
- // means the raster's very dimensions may differ, so nothing below
- // may reason about the band it holds.
- const stale = !slot.tried or !slot.request_valid or
- !slot.request.eql(request) or slot.decorated != decorated or
- slot.tint_key == null or !slot.tint_key.?.eql(tint_key) or
- slot.rgba.len == 0 or slot.band_h == 0;
- // ...and then coverage, against the raster ALREADY here — its
- // full-page dimensions are this request's, or `stale` would be
- // true. Asking MuPDF to measure again per frame per page is what
- // this avoids: at reading speed a covered page must cost nothing.
- const uncovered = !stale and uncovered: {
- const want = pdfWantedBand(pv, viewport, page, pdfSlotShape(slot), flinging);
- break :uncovered slot.band_y > want.y or
- slot.band_y + slot.band_h < want.y + want.height;
- };
- if (stale or uncovered) {
- slot.tried = true;
- slot.request = request;
- slot.request_valid = true;
- // Measure first so the band is a range of rows of THIS
- // request's raster and a retired buffer can be reclaimed
- // against a length that is already known.
- const shape_or_null = shape: {
- const tz_measure = tracy.zone(@src(), "pdf.measure");
- defer tz_measure.end();
- break :shape pv.document.measureRenderAt(page, request) catch null;
- };
- const shape = shape_or_null orelse continue;
- const want = pdfWantedBand(pv, viewport, page, shape, flinging);
- // Render into a separate buffer and swap only on complete
- // success; the old pixels stay presentable until then and then
- // become the next page's buffer.
- if (p.claimPdfRgba(pv, want.len)) |fresh| {
- const filled = filled: {
- {
- const tz_render = tracy.zone(@src(), "pdf.render_into");
- defer tz_render.end();
- pv.document.renderIntoAt(
- page,
- request,
- shape,
- want,
- if (decorated) highlights else &.{},
- fresh,
- ) catch break :filled false;
- }
- const tz_tint = tracy.zone(@src(), "pdf.tint");
- defer tz_tint.end();
- pdf_impl.tintRgba(fresh, tint_key.mode, tint_key.colors) catch
- break :filled false;
- break :filled true;
- };
- if (!filled) {
- p.retirePdfRgba(pv, fresh);
- continue;
- }
- p.retirePdfRgba(pv, slot.rgba);
- slot.rgba = fresh;
- slot.iw = shape.width;
- slot.ih = shape.height;
- slot.band_y = want.y;
- slot.band_h = want.height;
- slot.decorated = decorated;
- slot.tint_key = tint_key;
- pv.next_raster_revision +%= 1;
- if (pv.next_raster_revision == 0) pv.next_raster_revision = 1;
- slot.revision = pv.next_raster_revision;
- }
- }
- if (page == pv.page and slot.rgba.len > 0) syncPdfRasterAliases(pv, slot);
- }
- p.trimPdfSpares(pv);
- }
-
- /// Attach every page intersecting the document viewport. Raster entries
- /// are released as soon as their page leaves that exact visible set;
- /// scrolling a retained page changes geometry only.
- fn drawPdf(p: *Pardes, pane: *Pane, r: Rect, tx: u16, tw: u16) bool {
- if (comptime !pdf_enabled) return false;
- const tz = tracy.zone(@src(), "pdf.draw");
- defer tz.end();
- const pv = &(pane.pdf orelse return false);
- if (r.h <= BOX_H) return false;
- const viewport = p.ensurePdfLayout(pane, pv) orelse return false;
- const request = p.pdfRenderRequest(pane) orelse return false;
- p.resolvePdfSearch(pv);
- const highlights = p.pdfHighlights(pv) catch &.{};
- var visible = pdfVisiblePages(pv, viewport);
- p.reconcilePdfRasters(pv, request, highlights, visible, viewport);
-
- p.rearmPdfRevealForViewport(pane, pv);
- p.revealPdfSearch(pane, pv);
- // Reveal can move within the current page; placements below use its
- // final document coordinate, but unchanged raster pixels are reused.
- visible = pdfVisiblePages(pv, viewport);
- p.reconcilePdfRasters(pv, request, highlights, visible, viewport);
- const id: u8 = @intCast(for (p.panes, 0..) |slot, i| {
- if (slot == pane) break i;
- } else 0);
- var placed_any = false;
- var page = visible.first;
- const visible_end = visible.first + visible.len;
- while (page < visible_end) : (page += 1) {
- const slot = pdfRasterForPage(pv, page) orelse continue;
- if (slot.rgba.len == 0) continue;
- const placed = pdfPlacedGeometry(pv, slot, viewport, page) orelse continue;
- if (!p.appendImagePlace(.{
- .pane = id,
- .serial = pane.serial,
- .native = .{
- .revision = slot.revision,
- .page = @intCast(page),
- .fit = switch (pv.fit) {
- .width => .width,
- .height => .height,
- },
- .pan_x = pv.pan_x,
- .geometry = placed.geometry,
- .pixel_offset_y = placed.pixel_offset_y,
- },
- .x = tx,
- .y = if (p.tag_bottom) r.y else r.y + BOX_H,
- .w = tw,
- .h = r.h - BOX_H,
- .rgba = slot.rgba,
- .iw = slot.iw,
- // the TEXTURE is the band that is here, not the page it is cut
- // from: rgba.len == iw * band_h * 4, and the placement's source
- // rectangle is already band-local
- .ih = slot.band_h,
- })) break;
- placed_any = true;
- }
- // This frame has now shown whatever its travel earned; the next frame's
- // speed is its own. Reset only when something was actually drawn: a pane
- // too short to place a page has not spent the reader's scrolling.
- if (placed_any) pv.scroll_travel = 0;
- return placed_any;
- }
-
- /// 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.image_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 body_y = if (p.tag_bottom) r.y else r.y + BOX_H;
- const use_petscii = iv.petscii or !p.native_images;
- if (!use_petscii) {
- if (!p.appendImagePlace(.{
- .pane = @intCast(for (p.panes, 0..) |slot, i| {
- if (slot == pane) break i;
- } else 0),
- .serial = pane.serial,
- .x = tx,
- .y = body_y,
- .w = body_cols,
- .h = body_rows,
- .rgba = iv.rgba,
- .iw = iv.iw,
- .ih = iv.ih,
- })) return;
- 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.image_gpa.free(iv.grid);
- const pal = switch (iv.pmode) {
- .commodore => image.petscii.commodore,
- .terminal => image.ansiPalette(),
- };
- const g = image.petscii.render(p.image_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 = body_y + @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
@@ -15017,38 +13055,15 @@ pub const Pardes = struct {
return buf;
}
if (hasPdf(pane)) {
- const text = try p.ensurePdfText(pane);
- const scroll = if (comptime pdf_enabled) pane.pdf.?.text_scroll else 0;
- const max_rows = pane.rows -| BOX_H;
- var total: usize = 0;
- var visible: usize = 0;
- var count_lines = std.mem.splitScalar(u8, text, '\n');
- var count_row: usize = 0;
- while (count_lines.next()) |line| : (count_row += 1) {
- if (count_row < scroll) continue;
- if (visible >= max_rows) break;
- total += line.len + @intFromBool(visible > 0);
- visible += 1;
- }
- const out = try arena.alloc(u8, total);
- var at: usize = 0;
- 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 >= max_rows) break;
- if (wrote > 0) {
- out[at] = '\n';
- at += 1;
- }
- @memcpy(out[at..][0..line.len], line);
- at += line.len;
- wrote += 1;
- }
- return out;
+ if (comptime pdf_enabled) return pdf_pane.visibleText(
+ &pane.pdf.?,
+ p.pdf_gpa,
+ arena,
+ pane.rows -| BOX_H,
+ );
+ unreachable;
}
- if (pane.file) |*f| return file_pane.bodyText(arena, pane, f, p.wrap_on);
+ if (pane.file) |*f| return file_pane.bodyText(arena, pane, f, p.settings.wrap);
return term_pane.bodyText(arena, pane);
}
};
@@ -15210,184 +13225,6 @@ test "hopping between two panes does not grow the jump stack" {
try std.testing.expectEqual(depth, p.njumps);
}
-/// A results buffer with rows we control: three rows whose HEAD is a look-able
-/// location, one of them carrying a second location further along (which a
-/// row-grained walk must not stop on), and one row with nothing look-able on
-/// it at all. Returns its slot. `pat` is the recorded pattern and is what
-/// keeps two of these APART — fillResults refills a buffer whose origin,
-/// argument and directory all match.
-fn walkFixture(p: *Pardes, id: usize, cwd: []const u8, pat: []const u8) !usize {
- const rows = try std.fmt.allocPrint(p.gpa,
- \\build.zig:1:1 first
- \\(mise.toml) and build.zig.zon:3:2-9 two on one row
- \\nothing look-able on this row at all
- \\uucode_config.zig:7:1 last
- \\
- , .{});
- try output_pane.fillResults(p, id, cwd, .search, pat, rows, null);
- return p.panes[id].?.search_pane orelse error.MissingResults;
-}
-
-test "n/N select look-able text and open nothing" {
- if (platform == .web) return;
- const gpa = std.testing.allocator;
- var cwdbuf: [4096]u8 = undefined;
- const cwd = std.mem.span(@as([*:0]u8, @ptrCast(std.c.getcwd(&cwdbuf, cwdbuf.len) orelse return)));
- var pathbuf: [4096]u8 = undefined;
- const boot = try std.fmt.bufPrint(&pathbuf, "{s}/mise.toml", .{cwd});
-
- const p = try Pardes.init(gpa, .{ .cols = 100, .rows = 40, .file = boot });
- defer p.deinit();
- p.update(.{ .resize = .{ .cols = 100, .rows = 40 } });
- const rid = try walkFixture(p, p.active, cwd, "one");
- const rp = p.panes[rid].?;
- const panes_before = p.freeSlot();
-
- // Nothing has looked yet, so the walk's list is the one unvisited output
- // buffer. The first n lands on row 0's location, FOCUSES that buffer, and
- // opens nothing whatsoever.
- p.update(.{ .key = .{ .cp = 'n' } });
- try std.testing.expectEqual(rid, p.active);
- try std.testing.expectEqual(panes_before, p.freeSlot());
- try std.testing.expectEqual(@as(i32, 0), rp.cur_row);
- try std.testing.expectEqual(@as(i32, 0), rp.cur_col);
- // EXPLICIT, anchored on the span's last cell: what Enter's look acts on
- try std.testing.expect(rp.vsel.active and rp.vsel.explicit);
- try std.testing.expectEqualStrings("build.zig:1:1", p.currentSelText(rp) orelse "");
-
- // ONE STOP PER ROW, at its head: this is a results LIST (Grain.line), so
- // row 1 gives its parenthesised leading path — wrappers peeled off the
- // selection — and the `build.zig.zon:3:2-9` further along it is part of
- // the same hit, not a second place to stand.
- try std.testing.expectEqual(output_pane.Grain.line, output_pane.grain(rp.file.?.output));
- p.update(.{ .key = .{ .cp = 'n' } });
- try std.testing.expectEqual(@as(i32, 1), rp.cur_row);
- try std.testing.expectEqualStrings("mise.toml", p.currentSelText(rp) orelse "");
-
- // Row 2 has nothing look-able at all and is skipped entirely.
- p.update(.{ .key = .{ .cp = 'n' } });
- try std.testing.expectEqual(@as(i32, 3), rp.cur_row);
- try std.testing.expectEqualStrings("uucode_config.zig:7:1", p.currentSelText(rp) orelse "");
-
- // Past the last span the ring comes round to the first.
- p.update(.{ .key = .{ .cp = 'n' } });
- try std.testing.expectEqual(@as(i32, 0), rp.cur_row);
-
- // ...and Enter on a selection is what finally opens something.
- p.update(.{ .key = .{ .cp = Key.enter } });
- try std.testing.expect(p.freeSlot() != panes_before);
- try std.testing.expect(std.mem.endsWith(u8, p.panes[p.active].?.file.?.path, "/build.zig"));
-
- // The FILE that opened is free text, not a list, so it keeps the word
- // grain — the breaking change is the results buffer's alone.
- try std.testing.expectEqual(
- output_pane.Grain.word,
- output_pane.grain(p.panes[p.active].?.file.?.output),
- );
-}
-
-test "N is the exact inverse of n, across panes and the ring's seam" {
- if (platform == .web) return;
- const gpa = std.testing.allocator;
- var cwdbuf: [4096]u8 = undefined;
- const cwd = std.mem.span(@as([*:0]u8, @ptrCast(std.c.getcwd(&cwdbuf, cwdbuf.len) orelse return)));
- var pathbuf: [4096]u8 = undefined;
- const boot = try std.fmt.bufPrint(&pathbuf, "{s}/mise.toml", .{cwd});
-
- const p = try Pardes.init(gpa, .{ .cols = 100, .rows = 40, .file = boot });
- defer p.deinit();
- p.update(.{ .resize = .{ .cols = 100, .rows = 40 } });
-
- // TWO buffers, so the walk has a pane boundary to cross and a seam to wrap
- // over — the two places a hand-rolled inverse gets it wrong.
- const first = try walkFixture(p, p.active, cwd, "one");
- p.update(.{ .key = .{ .cp = 'n', .alt = true } }); // a shell to hang the second off
- p.sync();
- const second = try walkFixture(p, p.active, cwd, "two");
- try std.testing.expect(first != second);
-
- const Mark = struct { pane: usize, row: i32, col: i32 };
- const here = struct {
- fn at(pp: *Pardes) Mark {
- const pane = pp.panes[pp.active].?;
- return .{ .pane = pp.active, .row = pane.cur_row, .col = pane.cur_col };
- }
- }.at;
-
- // The FIRST press only acquires a position — before it the cursor is
- // wherever the shell left it, which is not a place on the ring — so the
- // symmetry claim starts one step in. Everything after that is exact.
- p.update(.{ .key = .{ .cp = 'n' } });
- const base = here(p);
-
- // Twelve more forward is further than either buffer holds, so the trail
- // crosses both pane boundaries and wraps over the seam; record every
- // position it passes through.
- var trail: [12]Mark = undefined;
- for (&trail) |*m| {
- p.update(.{ .key = .{ .cp = 'n' } });
- m.* = here(p);
- }
- // ...and twelve back must retrace it exactly, position by position, ending
- // on the one the count started from.
- var i = trail.len;
- while (i > 0) {
- i -= 1;
- p.update(.{ .key = .{ .cp = 'N' } });
- const want = if (i == 0) base else trail[i - 1];
- try std.testing.expectEqual(want.pane, p.active);
- try std.testing.expectEqual(want.row, p.panes[p.active].?.cur_row);
- try std.testing.expectEqual(want.col, p.panes[p.active].?.cur_col);
- }
-
- // The walk visited both buffers rather than circling inside one.
- var saw_first = false;
- var saw_second = false;
- for (trail) |m| {
- if (m.pane == first) saw_first = true;
- if (m.pane == second) saw_second = true;
- }
- try std.testing.expect(saw_first and saw_second);
-}
-
-test "n/N select a command row whole, and a look puts its pane at the head" {
- if (platform == .web) return;
- const gpa = std.testing.allocator;
- var cwdbuf: [4096]u8 = undefined;
- const cwd = std.mem.span(@as([*:0]u8, @ptrCast(std.c.getcwd(&cwdbuf, cwdbuf.len) orelse return)));
- var pathbuf: [4096]u8 = undefined;
- const boot = try std.fmt.bufPrint(&pathbuf, "{s}/mise.toml", .{cwd});
-
- const p = try Pardes.init(gpa, .{ .cols = 100, .rows = 40, .file = boot });
- defer p.deinit();
- p.update(.{ .resize = .{ .cols = 100, .rows = 40 } });
-
- // A COMMAND list: its rows are words to run, so a step takes the whole
- // line — there is no path inside `Theme acme` to pick out.
- p.runBuiltin(.ThemeSel, p.active, "", null);
- const tid = p.panes[p.active].?.search_pane orelse return error.MissingThemeList;
- const tp = p.panes[tid].?;
- try std.testing.expect(output_pane.fileTraits(tp.file.?.output).commands);
- p.update(.{ .key = .{ .cp = 'n' } });
- try std.testing.expectEqual(tid, p.active);
- const row0 = std.mem.trimEnd(u8, modal.lineSlice(tp.file.?.content, 0), " \t\r");
- try std.testing.expectEqualStrings(row0, p.currentSelText(tp) orelse "");
- // ...and Tab RUNS what n selected, which is how a theme is worn now.
- const theme_before = p.theme_idx;
- p.update(.{ .key = .{ .cp = 'n' } });
- p.update(.{ .key = .{ .cp = Key.tab } });
- try std.testing.expect(p.theme_idx != theme_before);
-
- // A LOOK is what puts a pane at the head of the walk. Look from the boot
- // pane and the next n steps THERE, not in the theme list, even though the
- // look moved focus into whatever it opened.
- p.runBuiltin(.Look, 0, "", "build.zig");
- p.sync();
- try std.testing.expect(p.active != 0);
- p.update(.{ .key = .{ .cp = 'n' } });
- try std.testing.expectEqual(@as(usize, 0), p.active);
-}
-
/// Drain the queue and say whether the shell was asked to do `want`.
fn drainedEffect(p: *Pardes, want: std.meta.Tag(Effect)) bool {
var seen = false;
@@ -15559,7 +13396,7 @@ test "entering tty walks the shell cursor to the column clicked past the prompt"
sp.cur_col = 3;
sp.cur_pinned = true;
while (p.nextEffect()) |_| {}
- p.enterTty(shell);
+ term_pane.enterTty(p, shell);
// The walk is arrow keys the shell understands. Seven lefts: readline's
// cursor sits past the '9' and the click was on the '3'.