diff options
| author | Gabriel Schneider <[email protected]> | 2026-08-16 15:49:12 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-18 23:44:42 -0300 |
| commit | 1551e409c31992437cb2fa864f576d45c8433801 (patch) | |
| tree | e2fae8451f87b735a1360c7c2e383fdc40165789 /src/pardes.zig | |
| parent | be2a9957708cbf0c478ca861c4a1f0f227bbfe10 (diff) | |
| download | pardes-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.zig | 8281 |
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'. |
