diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-27 22:27:42 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-10-01 00:12:14 -0300 |
| commit | 82551f77833a09b313bd3e58371582abd1aedcc4 (patch) | |
| tree | b2d0b53d1c2346bdbaefda8b3a5e965df377d26c /src/pardes.zig | |
| parent | 5be25178e4bfc2f390164e06697155cb0237babc (diff) | |
| download | pardes-82551f77833a09b313bd3e58371582abd1aedcc4.tar.gz pardes-82551f77833a09b313bd3e58371582abd1aedcc4.zip | |
Move looking out of pardes.zig into look.zig
Pure move, no behaviour change (acme keeps this in look.c): expanding the
word under a click (ExpandedWord, expandedWord, expandedSel, cursorWordSel),
the n/N walk (LookFrom, lookEdge, lookStand, lookWalkPanes, lookPast,
wholeRowSpan, lookSpanIn, lookWalk, landLookSpot, noteLookSource,
armLookWalk), search results (Search, SearchStart, submitSearch,
lookFirstHit, runSearch, searchStep, jumpResult), the look-hover preview
(LookHoverWait, LookHoverPreview, FileWordSpan, PdfWordPreview,
invalidateLookHover, cancelLookHover, lookHoverPane, noteLookHover,
refreshLookHoverFromRaw, advanceLookHover) and lookAt with its targets
(focusPaneLine, selectSpan, openPaneTarget, focusPaneByPath,
clearNavigationSelection, resolveLookTarget, locationText,
canonicalLookLocation, pdfLinkLocation, followPdfLink), with five tests, go
verbatim to the end of look.zig after its word and target resolution.
The methods become free functions taking `p: *Pardes`; their 143 call sites
change from `p.lookAt(..)` to `look.lookAt(p, ..)` (tests reach them as
`pardes.look.x`). Inside look.zig the moved code's `look.` prefix drops.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Diffstat (limited to 'src/pardes.zig')
| -rw-r--r-- | src/pardes.zig | 1163 |
1 files changed, 44 insertions, 1119 deletions
diff --git a/src/pardes.zig b/src/pardes.zig index 7a451b3a..bdde1455 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -435,6 +435,7 @@ pub const Loc = struct { }; test { + _ = @import("look.zig"); _ = @import("mouse.zig"); _ = @import("normal.zig"); _ = @import("edit.zig"); @@ -505,7 +506,7 @@ pub const test_api = if (@import("builtin").is_test) struct { } pub fn searchStep(p: *Pardes, id: usize, delta: i32) bool { - return p.searchStep(id, delta); + return look.searchStep(p, id, delta); } pub fn heldSelection(p: *Pardes, first: usize) ?[]const u8 { @@ -1509,7 +1510,7 @@ test "a Look on a directory does not type ls into an occupied terminal" { var host: FakeTtyQuery = .{}; host.install(p); host.taken[0] = true; - p.lookAt(0, tmp); + look.lookAt(p, 0, tmp); var spawned = false; while (p.nextEffect()) |effect| switch (effect) { @@ -1522,7 +1523,7 @@ test "a Look on a directory does not type ls into an occupied terminal" { // ...and the same look reuses that very pane once its program is gone: the // core keeps no state of its own about it, so recovery needs nothing reset host.taken[0] = false; - p.lookAt(0, tmp); + look.lookAt(p, 0, tmp); var sent: [64]u8 = undefined; var sent_len: usize = 0; while (p.nextEffect()) |effect| switch (effect) { @@ -2575,7 +2576,7 @@ pub fn wordAtCol(str: []const u8, col: usize) []const u8 { /// `a` IS `b` or lives under it — how Grep drops a pane's directory when /// another pane's directory already covers it -fn pathUnder(a: []const u8, b: []const u8) bool { +pub fn pathUnder(a: []const u8, b: []const u8) bool { const root = std.mem.trimEnd(u8, b, "/"); if (!std.mem.startsWith(u8, a, root)) return false; return a.len == root.len or a[root.len] == '/'; @@ -2711,152 +2712,14 @@ test "tabbed file aligns syntax cursor and mouse at the nearest insertion gap" { try std.testing.expectEqual(blank_x - virtual, virtual_surface.cursor.?.x); const local_blank: i32 = @intCast(blank_x - rect.x - config.GUTTER); - try std.testing.expect(p.expandedSel(pane, .{ .state = .dragging, .c0 = local_blank, .c1 = local_blank, .r0 = BOX_H, .r1 = BOX_H }) == null); + try std.testing.expect(look.expandedSel(p, pane, .{ .state = .dragging, .c0 = local_blank, .c1 = local_blank, .r0 = BOX_H, .r1 = BOX_H }) == null); // The immediately-adjacent EOL cell and an in-line separator are blank // too. Pointer expansion must not lean left into the preceding word. const adjacent: i32 = @intCast(config.PREFIX_W + panes.File.displayWidth(line)); - try std.testing.expect(p.expandedSel(pane, .{ .state = .dragging, .c0 = adjacent, .c1 = adjacent, .r0 = BOX_H, .r1 = BOX_H }) == null); + try std.testing.expect(look.expandedSel(p, pane, .{ .state = .dragging, .c0 = adjacent, .c1 = adjacent, .r0 = BOX_H, .r1 = BOX_H }) == null); const separator: i32 = @intCast(config.PREFIX_W + config.tab_width + "return".len); - try std.testing.expect(p.expandedSel(pane, .{ .state = .dragging, .c0 = separator, .c1 = separator, .r0 = BOX_H, .r1 = BOX_H }) == null); -} - -test "Look hover waits without mutating the pane and input cancels it" { - const delay = config.look_preview_delay_frames orelse return; - try std.testing.expect(delay > 0); - - const gpa = std.testing.allocator; - const p = try Pardes.init(gpa, .{ .cols = 60, .rows = 12, .tty_only = true }); - defer p.deinit(); - while (p.nextEffect()) |_| {} - const pane = try p.setTestFile("alpha beta gamma\n"); - while (p.nextEffect()) |_| {} - const other_id = p.freeSlot() orelse return error.NoSparePaneForHoverTest; - _ = try p.newShell(other_id, ""); - try std.testing.expect(layout.splitColumn(p, 0, other_id, false)); - p.sync(); - while (p.nextEffect()) |_| {} - - var frame = std.heap.ArenaAllocator.init(gpa); - defer frame.deinit(); - _ = try p.render(frame.allocator()); - const rect = p.rects[p.active]; - const word_x = rect.x + config.GUTTER + config.PREFIX_W + 7; - const body_y = if (p.settings.tag_bottom) rect.y else rect.y + BOX_H; - const motion = Event{ .mouse = .{ .button = .none, .kind = .motion, .col = word_x, .row = body_y } }; - - const selections_before = pane.sel; - const active_before = p.active; - const cursor_before = .{ pane.cur_row, pane.cur_col }; - const jumps_before = .{ p.njumps, p.jcur, p.n_look_src }; - const effects_before = p.effects_len; - - p.update(motion); - try std.testing.expect(p.look_hover_wait != null); - try std.testing.expect(p.look_hover_preview == null); - try std.testing.expect(p.animationActive()); - - var ticks: u16 = 0; - while (ticks + 1 < delay) : (ticks += 1) { - p.update(.tick); - // Repeated motion reports for one cell are noise, not a fresh delay. - if (ticks == delay / 2) p.update(motion); - try std.testing.expect(p.look_hover_preview == null); - } - p.update(.tick); - const preview = p.look_hover_preview orelse return error.MissingLookHoverPreview; - const preview_sel = preview.sel orelse return error.MissingLookHoverSelection; - try std.testing.expectEqual(.done, preview_sel.state); - try std.testing.expectEqual(@as(i32, config.PREFIX_W + 6), preview_sel.c0); - try std.testing.expectEqual(@as(i32, config.PREFIX_W + 9), preview_sel.c1); - try std.testing.expect(!p.animationActive()); - - // Resolving and displaying the operand is observational only. - try std.testing.expect(std.meta.eql(selections_before, pane.sel)); - try std.testing.expectEqual(active_before, p.active); - try std.testing.expectEqual(cursor_before, .{ pane.cur_row, pane.cur_col }); - try std.testing.expectEqual(jumps_before, .{ p.njumps, p.jcur, p.n_look_src }); - try std.testing.expectEqual(effects_before, p.effects_len); - - _ = frame.reset(.retain_capacity); - const surface = try p.render(frame.allocator()); - const expected_bg = colors.mix(p.theme().bg orelse p.theme().tag_bg, colors.mix(p.theme().bg orelse p.theme().tag_bg, p.theme().sel_bg)); - try std.testing.expectEqual(Color{ .rgb = expected_bg }, surface.at(word_x, body_y).style.bg); - - // Background traffic in another pane does not change the text or geometry - // under this pointer and must not strand a stationary hover unarmed. - p.update(.{ .output = .{ .pane = @intCast(other_id), .bytes = "busy\r\n" } }); - try std.testing.expect(p.look_hover_preview != null); - - const seam_x = p.col_x[0] + p.col_w[0] - 1; - p.update(.{ .mouse = .{ .button = .none, .kind = .motion, .col = seam_x, .row = body_y } }); - _ = frame.reset(.retain_capacity); - const hovered_seam = try p.render(frame.allocator()); - try std.testing.expectEqualStrings("╎", hovered_seam.at(seam_x, body_y).grapheme()); - p.update(.pointer_leave); - try std.testing.expect(p.look_hover_wait == null); - try std.testing.expect(p.look_hover_preview == null); - _ = frame.reset(.retain_capacity); - const left_seam = try p.render(frame.allocator()); - try std.testing.expect(!std.mem.eql(u8, "╎", left_seam.at(seam_x, body_y).grapheme())); - - const blank_x = rect.x + config.GUTTER + config.PREFIX_W + 30; - p.update(.{ .mouse = .{ .button = .none, .kind = .motion, .col = blank_x, .row = body_y } }); - for (0..delay) |_| p.update(.tick); - try std.testing.expect(p.look_hover_wait == null); - try std.testing.expect(p.look_hover_preview == null); - try std.testing.expect(!p.animationActive()); - - // File PREFIX_W belongs only to body line numbers. A preview on the tag's - // first character must be paintable all the way at tag column zero — which - // is TAG_TEXT_INSET, not GUTTER: the grip keeps the gutter and the gap - // beside it separates the grip's hit area from the text. - const tag_y = if (p.settings.tag_bottom) rect.y + rect.h - BOX_H else rect.y; - p.look_hover_preview = .{ - .col = rect.x + TAG_TEXT_INSET, - .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 + TAG_TEXT_INSET, 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); + try std.testing.expect(look.expandedSel(p, pane, .{ .state = .dragging, .c0 = separator, .c1 = separator, .r0 = BOX_H, .r1 = BOX_H }) == null); } test "editable workspace and column tags replace selections and persist" { @@ -3214,46 +3077,6 @@ pub const Effect = union(enum) { } }; -const LookHoverWait = struct { - col: u16, - row: u16, - pane: usize, - serial: u32, - frames: u16 = 0, - body_hit: ?Mouse.BodyHit = null, - tag_hit: ?TagHit = null, -}; - -const LookHoverPreview = struct { - col: u16, - row: u16, - pane: usize, - serial: u32, - sel: ?Pane.Sel, - file_word: ?FileWordSpan = null, - body_hit: ?Mouse.BodyHit = null, - tag_hit: ?TagHit = null, -}; - -const FileWordSpan = struct { - row: i32, - lo: i32, - hi: i32, // exclusive -}; - -const PdfWordPreview = if (pdf_enabled) struct { - col: u16, - row: u16, - pane: usize, - serial: u32, - probe: panes.Pdf.WordProbe, - - fn deinit(preview: *@This(), gpa: std.mem.Allocator) void { - preview.probe.deinit(gpa); - preview.* = undefined; - } -} else void; - test "a new column takes width only from the column that created it" { if (platform == .web) return; const p = try Pardes.init(std.testing.allocator, .{ .cols = 124, .rows = 24 }); @@ -3712,68 +3535,6 @@ test "core composes character motion out of the new grid, not a fade" { try std.testing.expect(try p.composeAsciiTransitions(arena.allocator(), &surface) == &surface); } -test "stationary Look hover follows only acknowledged panel samples" { - _ = config.look_preview_delay_frames orelse return; - const p = try Pardes.init(std.testing.allocator, .{ .cols = 120, .rows = 20, .tty_only = true }); - defer p.deinit(); - const pane = p.panes[0].?; - const rect = p.rects[0]; - const target: layout.Box = .{ - .x = @floatFromInt(rect.x), - .y = @floatFromInt(rect.y), - .w = @floatFromInt(rect.w), - .h = @floatFromInt(rect.h), - }; - var track: layout.Track = .{ - .serial = pane.serial, - .pane = 0, - .phase = .moving, - .effect = .slide, - .from = .{ .x = 20, .y = 5, .w = 40, .h = 10 }, - .to = target, - }; - p.acknowledgePanelPresentation(&.{track}); - p.update(.{ .mouse = .{ .button = .none, .kind = .motion, .col = 30, .row = 8 } }); - const first = p.look_hover_wait orelse return error.MissingInitialHoverWait; - - // Advancing the producer-side track alone cannot move the semantic cell - // under a stationary pointer. - track.frame = track.effect.frames() - 1; - p.presentation.tracks[0] = track; - p.refreshLookHoverFromRaw(); - try std.testing.expectEqual(first, p.look_hover_wait.?); - - // Once that exact sample is acknowledged, the same raw cell maps through - // the new panel rectangle and the debounce starts over for the new target. - p.acknowledgePanelPresentation(&.{track}); - const moved = p.look_hover_wait orelse return error.MissingRemappedHoverWait; - try std.testing.expect(first.col != moved.col or first.row != moved.row); - try std.testing.expectEqual(@as(u16, 0), moved.frames); - - // Re-acknowledging the same semantic target is host repaint noise, not a - // new hover, so it preserves the accumulated delay. - p.look_hover_wait.?.frames = 3; - p.acknowledgePanelPresentation(&.{track}); - try std.testing.expectEqual(@as(u16, 3), p.look_hover_wait.?.frames); - - // A zero-sized opening sample covers its future target but exposes no - // clickable content. Successful presentation therefore cancels the hover. - track.phase = .opening; - track.effect = .zoom; - track.frame = 0; - track.from = .{ - .x = target.x + target.w * 0.5, - .y = target.y + target.h * 0.5, - }; - p.acknowledgePanelPresentation(&.{track}); - try std.testing.expect(p.look_hover_wait == null); - try std.testing.expect(p.look_hover_preview == null); - - track.frame = track.effect.frames() - 1; - p.acknowledgePanelPresentation(&.{track}); - try std.testing.expect(p.look_hover_wait != null); -} - test "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 }); @@ -3964,9 +3725,9 @@ pub const Pardes = struct { pointer_tag_hit: ?TagHit = null, raw_hover_intent: bool = false, pointer_inside: bool = false, - look_hover_wait: ?LookHoverWait = null, - look_hover_preview: ?LookHoverPreview = null, - pdf_hover_preview: if (pdf_enabled) ?PdfWordPreview else void = if (pdf_enabled) null else {}, + look_hover_wait: ?look.LookHoverWait = null, + look_hover_preview: ?look.LookHoverPreview = null, + pdf_hover_preview: if (pdf_enabled) ?look.PdfWordPreview else void = if (pdf_enabled) null else {}, wheel_guard: u8 = 0, ctrl_w_pending: bool = false, multi_on: bool = false, @@ -4196,7 +3957,7 @@ pub const Pardes = struct { } pub fn deinit(p: *Pardes) void { - p.cancelLookHover(); + look.cancelLookHover(p); for (0..MAX_PANES) |id| p.dropPendingWrite(id); for (&p.panes) |*slot| if (slot.*) |pane| { p.teardownPane(pane); @@ -4349,15 +4110,15 @@ pub const Pardes = struct { // ...and so do bytes still queued for the pty it no longer has. p.dropPendingWrite(id); }; - if (p.lookHoverPane()) |h| if (h < p.panes.len and p.panes[h] == pane) p.cancelLookHover(); + if (look.lookHoverPane(p)) |h| if (h < p.panes.len and p.panes[h] == pane) look.cancelLookHover(p); p.retired_panes.retire(pane); } // Retired panes keep their payloads until the following frame or core teardown. fn teardownPane(p: *Pardes, pane: *Pane) void { - if (p.lookHoverPane()) |hovered| { + if (look.lookHoverPane(p)) |hovered| { if (hovered < p.panes.len and p.panes[hovered] == pane) - p.cancelLookHover(); + look.cancelLookHover(p); } p.shell_rows.dropPane(pane); for (0..pane.pointer_selections.len) |slot| pane.clearPointerSelection(slot); @@ -4466,29 +4227,6 @@ pub const Pardes = struct { } else null; } - fn noteLookSource(p: *Pardes, id: usize) void { - const pane = p.panes[id] orelse return; - var w: usize = 0; - for (p.look_src[0..p.n_look_src]) |s| { - if (s == pane.serial) continue; - p.look_src[w] = s; - w += 1; - } - if (w == p.look_src.len) { - std.mem.copyForwards(u32, p.look_src[0 .. w - 1], p.look_src[1..w]); - w -= 1; - } - p.look_src[w] = pane.serial; - p.n_look_src = w + 1; - p.look_walk_owner = pane.serial; - } - - pub fn armLookWalk(p: *Pardes, id: usize) void { - const pane = p.panes[id] orelse return; - if (pane.file) |file| if (file.content.len == 0) return; - p.look_walk_owner = pane.serial; - } - pub fn emitWrite(p: *Pardes, id: usize, bytes: []const u8) void { var off: usize = 0; // Anything already parked for this pane owns the stream's position, so @@ -4531,10 +4269,10 @@ pub const Pardes = struct { }; if (std.mem.eql(u8, cur, cwd)) return; pane.setOwnedCwd(cwd) catch |err| return p.reportError(id, "directory", err); - if (p.lookHoverPane() == id) p.cancelLookHover(); + if (look.lookHoverPane(p) == id) look.cancelLookHover(p); } - fn takesCommandLine(p: *const Pardes, id: usize) bool { + pub fn takesCommandLine(p: *const Pardes, id: usize) bool { const pane = p.panes[id] orelse return false; if (!pane.isTerminal()) return false; // A mounted shell sits behind sudo's process supervisor. Its OSC 133 @@ -4638,19 +4376,13 @@ pub const Pardes = struct { pub fn reloadWatchedFile(p: *Pardes, id: u8, bytes: []const u8) bool { p.invalidate(); - p.invalidateLookHover(id); + look.invalidateLookHover(p, id); const applied = p.applyWatchedFileChanged(id, bytes); _ = p.scratch.reset(.retain_capacity); p.sync(); return applied; } - pub fn invalidateLookHover(p: *Pardes, id: usize) void { - if (p.lookHoverPane() != id) return; - p.raw_hover_intent = false; - p.cancelLookHover(); - } - pub fn emit(p: *Pardes, e: Effect) void { if (p.effects_len == p.effects.len) return; const tail = (p.effects_head + p.effects_len) % p.effects.len; @@ -4886,7 +4618,7 @@ pub const Pardes = struct { if (!ctlfs.changesPane(req)) return ctlfs.handle(p, req); p.needs_frame = true; p.raw_hover_intent = false; - p.cancelLookHover(); + look.cancelLookHover(p); const reply = ctlfs.handle(p, req); // The request was a whole step of its own, so it settles the way a // step does: the cursor and scroll reconciled, the scripted panes @@ -4912,23 +4644,23 @@ pub const Pardes = struct { .tick => {}, .mouse => |m| if (!(m.button == .none and m.kind == .motion)) { p.raw_hover_intent = false; - p.cancelLookHover(); + look.cancelLookHover(p); }, - .output => |o| if (p.lookHoverPane() == o.pane) { + .output => |o| if (look.lookHoverPane(p) == o.pane) { p.raw_hover_intent = false; - p.cancelLookHover(); + look.cancelLookHover(p); }, - .file_changed => |changed| if (p.lookHoverPane() == changed.pane) { + .file_changed => |changed| if (look.lookHoverPane(p) == changed.pane) { p.raw_hover_intent = false; - p.cancelLookHover(); + look.cancelLookHover(p); }, - .pdf_scroll => |scroll| if (p.lookHoverPane() == scroll.pane) { + .pdf_scroll => |scroll| if (look.lookHoverPane(p) == scroll.pane) { p.raw_hover_intent = false; - p.cancelLookHover(); + look.cancelLookHover(p); }, else => { p.raw_hover_intent = false; - p.cancelLookHover(); + look.cancelLookHover(p); }, } switch (ev) { @@ -5024,7 +4756,7 @@ pub const Pardes = struct { .tick => { p.chrome_animation.advance(); p.presentation.advance(); - p.advanceLookHover(); + look.advanceLookHover(p); }, } _ = p.scratch.reset(.retain_capacity); @@ -5083,7 +4815,7 @@ pub const Pardes = struct { return pane_tail; } - fn paneIdOf(p: *const Pardes, pane: *const Pane) ?usize { + pub fn paneIdOf(p: *const Pardes, pane: *const Pane) ?usize { for (p.panes, 0..) |slot, id| if (slot) |sp| { if (sp == pane) return id; }; @@ -5563,53 +5295,6 @@ pub const Pardes = struct { if (col) |c| p.topbar_col = @intCast(@min(c, modal.prevGrapheme(bar, bar.len))); } - const ExpandedWord = struct { lo: usize, hi: usize }; - - fn expandedWord(line: []const u8, col: usize) ?ExpandedWord { - if (col >= line.len) return null; - const b = config.wordBounds(line, col); - const command_run = b.hi > b.lo and config.commandWord(line[b.lo..b.hi]) != null; - if (!config.isFileChar(line[col]) and !command_run) return null; - if (b.hi <= b.lo) return null; - return .{ .lo = b.lo, .hi = b.hi }; - } - - /// acme: a no-drag middle/right click expands to the word under it — - /// file-ish, or a whole `` @`...` `` run (config.wordBounds is the spelling) - fn expandedSel(p: *Pardes, pane: *Pane, at: Pane.Sel) ?Pane.Sel { - var sl = at; - if (sl.c0 != sl.c1 or sl.r0 != sl.r1) return sl; - const text = edit.paneText(p, pane) catch return null; - var it = std.mem.splitAny(u8, text, "\n"); - var v: i32 = 0; - while (it.next()) |line| : (v += 1) { - if (v != sl.r0) continue; - const display_col: usize = @intCast(@max(0, sl.c0)); - const col = panes.File.renderedLineByteCol(pane, v, line, display_col); - const b = expandedWord(line, col) orelse return null; - sl.c0 = @intCast(panes.File.renderedLineDisplayCol(pane, v, line, b.lo)); - sl.c1 = @intCast(panes.File.renderedLineDisplayCol(pane, v, line, b.hi) - 1); - return sl; - } - return null; - } - - /// the word under the modal cursor as a pane-local selection (paneText - /// coords: row 0 is the tag; file panes carry the line-number prefix) - fn cursorWordSel(p: *Pardes, pane: *Pane) Pane.Sel { - const w = pane.wrapRow(pane.cur_row, pane.cur_col); - const vrow = w.row + @as(i32, BOX_H); - const vcol = if (pane.file != null) - panes.File.displayOffset(pane, pane.cur_row, w.at, pane.cur_col) + @as(i32, panes.File.gutterWidth(pane)) - else blk: { - const lines = edit.paneCursorLines(p, pane) catch break :blk pane.cur_col; - const local = pane.cur_row; - if (local < 0 or @as(usize, @intCast(local)) >= lines.len) break :blk pane.cur_col; - break :blk panes.File.lineDisplayOffset(lines[@intCast(local)], @intCast(@max(0, w.at)), @intCast(@max(0, pane.cur_col))); - }; - return .{ .state = .done, .c0 = vcol, .c1 = vcol, .r0 = vrow, .r1 = vrow }; - } - fn chordEachSel(p: *Pardes, pane: *Pane, cmd: Builtin) bool { if (pane.nsel == 0) return false; const text = edit.flatSurface(p, pane) catch return false; @@ -5660,7 +5345,7 @@ pub const Pardes = struct { preview: ?Pane.Sel = null, /// File words stay logical so a soft-wrapped operand is not cut into /// unrelated rendered fragments. Null for tags and non-file panes. - file_word: ?FileWordSpan = null, + file_word: ?look.FileWordSpan = null, /// A newly expanded word is installed in the gesture's button slot; /// an existing selection is only borrowed and must not replace it. expanded: ?Pane.Sel = null, @@ -5731,7 +5416,7 @@ pub const Pardes = struct { if (pane.file != null and clicked.r0 >= BOX_H and clicked.r0 == clicked.r1 and clicked.c0 == clicked.c1) { const line = panes.File.sourceLine(pane, row); const source_col: usize = @intCast(@max(0, col)); - const b = expandedWord(line, source_col) orelse return result; + const b = look.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]; @@ -5751,7 +5436,7 @@ pub const Pardes = struct { return result; } - const expanded = p.expandedSel(pane, clicked) orelse return result; + const expanded = look.expandedSel(p, pane, clicked) orelse return result; result.text = edit.selectionText(p, pane, expanded) catch null; if (result.text == null or result.text.?.len == 0) return result; result.preview = expanded; @@ -5920,7 +5605,7 @@ pub const Pardes = struct { }, .search => |at| { if (submit) - p.submitSearch(p.active) + look.submitSearch(p, p.active) else if (normal.selRegexArmed(pane)) |_| normal.applySelRegex(p, pane, "", false); pane.tag_tail_len = @min(at, pane.tag_tail_len); @@ -5953,7 +5638,7 @@ pub const Pardes = struct { return; } } - const sel = p.expandedSel(pane, p.cursorWordSel(pane)) orelse return; + const sel = look.expandedSel(p, pane, look.cursorWordSel(p, pane)) orelse return; const word = edit.selectionText(p, pane, sel) catch return; p.runBuiltin(cmd, p.active, "", word); return; @@ -6003,9 +5688,6 @@ pub const Pardes = struct { // ---- search (`/`, Find, n/N): results into an output buffer, look the rows ---- - const Search = enum { text, find, grep }; - const SearchStart = enum { top, cursor }; - pub fn paneDir(pane: *const Pane) []const u8 { switch (pane.cwd) { .inherited => |src| return paneDir(src), @@ -6130,205 +5812,6 @@ pub const Pardes = struct { } }); } - fn submitSearch(p: *Pardes, id: usize) void { - const pane = p.panes[id] orelse return; - if (normal.selRegexArmed(pane)) |a| return normal.applySelRegex(p, pane, a.pat, a.split); - const armed = pane.promptText() orelse return; - const slash = std.mem.indexOfScalar(u8, armed, '/') orelse return; - if (std.mem.startsWith(u8, armed, config.rename_marker)) return p.lspRequest(id, .rename, armed[slash + 1 ..]); - if (std.mem.startsWith(u8, armed, config.symbol_marker)) return p.lspRequest(id, .workspace_symbols, armed[slash + 1 ..]); - const kind: Search = if (std.mem.startsWith(u8, armed, config.find_marker)) - .find - else if (std.mem.startsWith(u8, armed, config.grep_marker)) - .grep - else - .text; - p.runSearch(id, armed[slash + 1 ..], kind, .top) catch |err| return p.reportError(id, "search", err); - if (kind == .text) p.lookFirstHit(id); - } - - fn lookFirstHit(p: *Pardes, id: usize) void { - const pane = p.panes[id] orelse return; - const rid = pane.search_pane orelse return; - const rp = p.panes[rid] orelse return; - if ((rp.file orelse return).content.len == 0) return; - p.active = rid; - p.lookWalk(1); - if (p.active != rid) return; // walked out of the list; leave it there - const txt = edit.currentSelText(p, rp) orelse return; - // spent, exactly as the look chord spends it (handleKey) - rp.vsel.active = false; - rp.msel.active = false; - rp.select = false; - p.runBuiltin(config.look_cmd, rid, "", txt); - } - - pub fn runSearch(p: *Pardes, id: usize, pat_raw: []const u8, kind: Search, start: SearchStart) !void { - const pane = p.panes[id] orelse return; - const pat = std.mem.trim(u8, pat_raw, " \t\r\n"); - if (pat.len == 0) return; - const arena = p.scratch.allocator(); - // where the pane lives: a file's directory, a shell's cwd — the walk - // root, and the directory the results buffer is named in. - const dir = paneDir(pane); - const out = try arena.alloc(u8, filesystem.search_max_output_bytes); - var out_len: usize = 0; - var nrows: usize = 0; - var anchor: ?usize = null; - if (kind == .grep) { - var roots: [MAX_PANES][]const u8 = undefined; - var nroots: usize = 0; - for (p.panes) |slot| { - const pp = slot orelse continue; - const d = paneDir(pp); - var covered = false; - var k: usize = 0; - while (k < nroots) { - if (pathUnder(d, roots[k])) { - covered = true; - break; - } - if (pathUnder(roots[k], d)) { // this one swallows a root already kept - nroots -= 1; - roots[k] = roots[nroots]; - continue; - } - k += 1; - } - if (covered) continue; - roots[nroots] = d; - nroots += 1; - } - for (roots[0..nroots]) |r| - out_len += try filesystem.grep(arena, p.gpa, r, dir, pat, out[out_len..]); - } else if (kind == .find) { - out_len = try filesystem.find(arena, dir, pat, out); - } else if (pane.hasPdf()) { - if (comptime pdf_enabled) { - const found = try panes.Pdf.searchRows( - &pane.pdf.?, - p.pdf_gpa, - arena, - pat, - start == .cursor, - out, - ); - out_len = found.bytes; - nrows = found.rows; - anchor = found.anchor; - } - } else { - const lines = try edit.paneCursorLines(p, pane); - // a real file names itself; a terminal or an output buffer has no path - const has_path = if (pane.file) |f| f.output == null else false; - var idbuf: [16]u8 = undefined; - // relative to `dir`, like Find's rows — and `dir` IS this file's - // own directory, so that is exactly its basename - const loc: []const u8 = if (has_path) - std.fs.path.basename(pane.file.?.path) - else - std.fmt.bufPrint(&idbuf, config.pane_addr ++ "{d}", .{id}) catch return error.PathTooLong; - const cl: usize = @intCast(@max(0, pane.cur_row)); - const cc: usize = @intCast(@max(0, pane.cur_col)); - for (lines, 0..) |ln, i| { - const at = std.ascii.indexOfIgnoreCase(ln, pat) orelse continue; - const row = try std.fmt.allocPrint(arena, "{s}:{d}:{d}{c}{d} {s}\n", .{ - loc, i + 1, at + 1, config.range_sep, at + pat.len, std.mem.trimEnd(u8, ln, " \t"), - }); - if (row.len > out.len - out_len) break; - if (start == .cursor and (i < cl or (i == cl and at <= cc))) anchor = nrows; - @memcpy(out[out_len..][0..row.len], row); - out_len += row.len; - nrows += 1; - } - } - const content = try p.gpa.dupe(u8, out[0..out_len]); - // the buffer records WHICH search filled it, pattern and all: Find and - // Grep are builtins (words you can execute), the bare `/` is a key - const from: panes.Output.Origin = switch (kind) { - .text => .search, - .find => .{ .cmd = .Find }, - .grep => .{ .cmd = .Grep }, - }; - try panes.Output.fillResults(p, id, dir, from, pat, content, anchor); - } - - pub fn searchStep(p: *Pardes, id: usize, delta: i32) bool { - const pane = p.panes[id] orelse return false; - const rid = pane.search_pane orelse return false; - const rp = p.panes[rid] orelse return false; - const rf = if (rp.file) |*f| f else return false; - // one question covers both hazards: a freed slot can be reused by an - // unrelated pane, and a buffer of PROSE has nowhere to step to - const tr = panes.Output.fileTraits(rf.output); - if (!tr.steps) return false; - // fresh results: n starts at the first row, N has nothing behind it - const nrows: i64 = @intCast(std.mem.count(u8, rf.content, "\n") + - @intFromBool(rf.content.len > 0 and rf.content[rf.content.len - 1] != '\n')); - var step: i64 = if (pane.search_row) |c| @as(i64, @intCast(c)) + delta else if (delta > 0) 0 else -1; - if (delta == 0) return true; - const direction: i64 = if (delta > 0) 1 else -1; - while (step >= 0 and step < nrows) : (step += direction) { - if (!panes.Output.isResultRow(rf, @intCast(step))) continue; - _ = p.jumpResult(id, @intCast(step)); - p.active = id; - break; - } - return true; - } - - fn jumpResult(p: *Pardes, id: usize, row: usize) bool { - const pane = p.panes[id] orelse return false; - const rid = pane.search_pane orelse return false; - const rp = p.panes[rid] orelse return false; - const rf = if (rp.file) |*f| f else return false; - const tr = panes.Output.fileTraits(rf.output); - const ln = modal.lineSlice(rf.content, row); - if (ln.len == 0) return false; - const r: i32 = @intCast(row); - pane.search_row = row; - rp.msel = .{ .active = true, .r0 = r, .r1 = r }; - rp.vsel.active = false; - rp.nsel = 0; - rp.cur_row = r; - rp.cur_pinned = true; - const off = rp.scroll(); - const last = off + @as(i32, rp.rows) - 1; - if (r < off) rp.scrollBy(r - off) else if (r > last) rp.scrollBy(r - last); - var realbuf: [4096]u8 = undefined; - const span = if (tr.commands) - wholeRowSpan(ln) - else - look.lineSpan(p, ln, paneDir(rp), &realbuf) orelse blk: { - const target = panes.Output.location(ln); - break :blk if (target.at.line > 0) - look.Span{ .start = 0, .end = target.end } - else - null; - }; - if (span) |selected| { - rp.cur_col = @intCast(selected.start); - rp.look_at = .{ - .row = r, - .col0 = @intCast(selected.start), - .col1 = @intCast(selected.end - 1), - }; - } else { - rp.cur_col = 0; - rp.look_at = null; - } - if (tr.commands) { - p.runBuiltin(config.exec_cmd, rid, "", std.mem.trim(u8, ln, " \t\r")); - } else if (span) |selected| { - p.lookAt(rid, ln[selected.start..selected.end]); - // This is the newly visited result, not a selection carried into - // the destination. Look cleared the previous source selection. - rp.msel = .{ .active = true, .r0 = r, .r1 = r }; - } - p.armLookWalk(rid); - return true; - } - pub fn lspRequest(p: *Pardes, id: usize, kind: lsp.Kind, arg: []const u8) void { if (!normal.multiOnce(p)) return; // one question per keystroke, from the primary if (!lsp.supports.contains(kind)) return; @@ -6520,7 +6003,7 @@ pub const Pardes = struct { const nrows = std.mem.count(u8, rows, "\n"); if (panes.Output.traits(from).jumps and nrows == 1) { const ln = std.mem.trimEnd(u8, rows, "\n"); - return p.lookAt(w.pane, ln[0..panes.Output.location(ln).end]); + return look.lookAt(p, w.pane, ln[0..panes.Output.location(ln).end]); } const dir = if (pane.file) |f| (std.fs.path.dirname(f.path) orelse "/") else pane.cwdSlice(); @@ -6536,260 +6019,18 @@ pub const Pardes = struct { .line = @as(usize, @intCast(@max(0, w.row))) + 1, .col = @as(usize, @intCast(@max(0, w.col))) + 1, }); - _ = p.jumpResult(w.pane, row); + _ = look.jumpResult(p, w.pane, row); } } // ---- n/N: the walk over look-able text ---- - const max_look_rows = 8192; - - const LookFrom = struct { row: i32, col: ?i32, strict: bool = false }; - - fn lookEdge(delta: i32) LookFrom { - return .{ .row = if (delta > 0) 0 else std.math.maxInt(i32), .col = null }; - } - - fn lookStand(pane: *Pane) LookFrom { - if (pane.look_at) |s| if (s.row == pane.cur_row and s.col0 == pane.cur_col) - return .{ .row = s.row, .col = s.col0, .strict = true }; - return .{ .row = pane.cur_row, .col = pane.cur_col }; - } - - fn lookWalkPanes(p: *Pardes, out: *[MAX_PANES]usize) []const usize { - var n: usize = 0; - var i = p.n_look_src; - while (i > 0) { - i -= 1; - const id = p.paneBySerial(p.look_src[i]) orelse continue; - out[n] = id; - n += 1; - } - const looked = n; - for (p.panes, 0..) |slot, id| { - const pane = slot orelse continue; - const f = pane.file orelse continue; - if (f.output == null) continue; - for (out[0..looked]) |k| { - if (k == id) break; - } else { - // insertion by serial descending — at most MAX_PANES - // comparisons, which is not a sort worth naming - var at = n; - while (at > looked and p.panes[out[at - 1]].?.serial < pane.serial) : (at -= 1) - out[at] = out[at - 1]; - out[at] = id; - n += 1; - } - } - if (n == 0) { - if (p.panes[p.active] == null) return out[0..0]; - out[0] = p.active; - n = 1; - } - return out[0..n]; - } - - fn lookPast(col0: i32, from: LookFrom, on_start_row: bool, delta: i32) bool { - if (!on_start_row) return true; - const c = from.col orelse return true; - if (delta > 0) return if (from.strict) col0 > c else col0 >= c; - return if (from.strict) col0 < c else col0 <= c; - } - - fn wholeRowSpan(ln: []const u8) ?look.Span { - var lo: usize = 0; - while (lo < ln.len and (ln[lo] == ' ' or ln[lo] == '\t')) lo += 1; - const hi = std.mem.trimEnd(u8, ln, " \t\r").len; - return if (hi > lo) .{ .start = lo, .end = hi } else null; - } - - fn lookSpanIn(p: *Pardes, pane: *Pane, from: LookFrom, delta: i32, budget: *usize) ?Pane.LookSpot { - const lines = edit.paneCursorLines(p, pane) catch return null; - const nrows: i32 = @intCast(lines.len); - if (nrows == 0) return null; - const grain: panes.Output.Grain = if (pane.file) |*f| panes.Output.grain(f.output) else .word; - const dir = paneDir(pane); - var realbuf: [4096]u8 = undefined; - const start = std.math.clamp(from.row, 0, nrows - 1); - var r = start; - while (r >= 0 and r < nrows) : (r += delta) { - if (budget.* == 0) return null; - budget.* -= 1; - const ln = lines[@intCast(r)]; - if (pane.file) |*f| if (!panes.Output.isWalkRow(f, @intCast(r), ln)) continue; - const on_start = r == start; - switch (grain) { - .word => { - var best: ?Pane.LookSpot = null; - var i: usize = 0; - while (i < ln.len) { - while (i < ln.len and (ln[i] == ' ' or ln[i] == '\t')) i += 1; - const t0 = i; - while (i < ln.len and ln[i] != ' ' and ln[i] != '\t') i += 1; - if (i == t0) break; - const sp = look.wordSpan(p, ln[t0..i], dir, &realbuf) orelse continue; - const col0: i32 = @intCast(t0 + sp.start); - if (!lookPast(col0, from, on_start, delta)) continue; - best = .{ .row = r, .col0 = col0, .col1 = @intCast(t0 + sp.end - 1) }; - if (delta > 0) break; // first one forward; keep the last one back - } - if (best) |b| return b; - }, - // one span per row, so there is nothing to keep and nothing to - // scan past: the row either offers it or it does not - .line, .whole => { - const sp = span: { - if (grain == .whole) break :span wholeRowSpan(ln); - if (pane.file) |f| if (@as(usize, @intCast(r)) < f.location_rows.len) { - const end = f.location_rows[@intCast(r)].location_end; - if (end > 0) break :span @as(?look.Span, .{ .start = 0, .end = end }); - }; - break :span look.lineSpan(p, ln, dir, &realbuf); - } orelse continue; - const col0: i32 = @intCast(sp.start); - if (lookPast(col0, from, on_start, delta)) - return .{ .row = r, .col0 = col0, .col1 = @intCast(sp.end - 1) }; - }, - } - } - return null; - } - - pub fn lookWalk(p: *Pardes, delta: i32) void { - var buf: [MAX_PANES]usize = undefined; - const order = p.lookWalkPanes(&buf); - if (order.len == 0) return; - const owner = if (p.look_walk_owner) |serial| p.paneBySerial(serial) else null; - const active_at = std.mem.indexOfScalar(usize, order, p.active) orelse 0; - const at = if (owner) |wanted| - std.mem.indexOfScalar(usize, order, wanted) orelse active_at - else - active_at; - var from = lookStand(p.panes[order[at]] orelse return); - var budget: usize = max_look_rows; - var k: usize = 0; - while (k <= order.len) : (k += 1) { - // `+ 2 * len` only so the backward subtraction stays unsigned - const idx = (if (delta > 0) at + k else at + 2 * order.len - k) % order.len; - const pane = p.panes[order[idx]] orelse continue; - if (p.lookSpanIn(pane, from, delta, &budget)) |spot| - return p.landLookSpot(order[idx], pane, spot); - if (budget == 0) return; // gave up mid-pane: stay put, stay reversible - from = lookEdge(delta); - } - } - - fn landLookSpot(p: *Pardes, id: usize, pane: *Pane, spot: Pane.LookSpot) void { - edit.pinPaneCursor(p, pane); // fresh out of tty mode the cursor still tracks the shell - pane.vsel = .{ .active = true, .row = spot.row, .col = spot.col1, .explicit = true }; - pane.msel.active = false; - pane.nsel = 0; - pane.cur_row = spot.row; - pane.cur_col = spot.col0; - pane.cur_pinned = true; - pane.look_at = spot; - p.look_walk_owner = pane.serial; - p.active = id; - pane.ensureCursorVisible(); - } + pub const max_look_rows = 8192; // ---- edit operations (d / c / y / p) ---- // ---- dumb undo/redo: whole-state snapshots, one per edit op ---- - pub fn cancelLookHover(p: *Pardes) void { - if (comptime pdf_enabled) if (p.pdf_hover_preview) |*preview| { - if (preview.pane < p.panes.len) if (p.panes[preview.pane]) |pane| { - if (pane.serial == preview.serial) if (pane.pdf) |*pv| - pv.invalidateRaster(preview.probe.page); - }; - preview.deinit(p.pdf_gpa); - p.pdf_hover_preview = null; - }; - p.look_hover_wait = null; - p.look_hover_preview = null; - } - - fn lookHoverPane(p: *const Pardes) ?usize { - if (comptime pdf_enabled) if (p.pdf_hover_preview) |preview| return preview.pane; - if (p.look_hover_preview) |preview| return preview.pane; - if (p.look_hover_wait) |waiting| return waiting.pane; - return null; - } - - fn noteLookHover(p: *Pardes, col: u16, row: u16, body_hit: ?Mouse.BodyHit, tag_hit: ?TagHit) void { - const delay = config.look_preview_delay_frames orelse return; - _ = delay; - const id = mouse.paneAt(p, col, row) orelse return p.cancelLookHover(); - const pane = p.panes[id] orelse return p.cancelLookHover(); - _ = edit.pointerTextSelection(p, id, col, row, body_hit, tag_hit) orelse return p.cancelLookHover(); - - if (comptime pdf_enabled) if (p.pdf_hover_preview) |shown| - if (shown.col == col and shown.row == row and shown.pane == id and shown.serial == pane.serial) return; - if (p.look_hover_preview) |shown| - if (shown.col == col and shown.row == row and shown.pane == id and shown.serial == pane.serial and mouse.sameBodyCell(shown.body_hit, body_hit) and mouse.sameTagCell(shown.tag_hit, tag_hit)) return; - if (p.look_hover_wait) |waiting| - if (waiting.col == col and waiting.row == row and waiting.pane == id and waiting.serial == pane.serial and mouse.sameBodyCell(waiting.body_hit, body_hit) and mouse.sameTagCell(waiting.tag_hit, tag_hit)) return; - - p.cancelLookHover(); - p.look_hover_wait = .{ .col = col, .row = row, .pane = id, .serial = pane.serial, .body_hit = body_hit, .tag_hit = tag_hit }; - } - - pub fn refreshLookHoverFromRaw(p: *Pardes) void { - if (!p.pointer_inside or !p.raw_hover_intent) return; - const mapped = p.presentation.pointer(p.screen_w, p.screen_h, p.pointer_raw_col, p.pointer_raw_row) orelse - return p.cancelLookHover(); - p.hover_col = mapped.col; - p.hover_row = mapped.row; - const body_hit: ?Mouse.BodyHit = if (p.pointer_body_hit) |mapped_hit| - mouse.bodyHitForPane(p, mouse.paneAt(p, mapped.col, mapped.row) orelse return p.cancelLookHover(), mapped_hit, false) orelse return p.cancelLookHover() - else - null; - p.noteLookHover(mapped.col, mapped.row, body_hit, p.pointer_tag_hit); - } - - fn advanceLookHover(p: *Pardes) void { - const delay = config.look_preview_delay_frames orelse return; - const waiting = if (p.look_hover_wait) |*w| w else return; - waiting.frames +|= 1; - if (waiting.frames < delay) return; - const pane = p.panes[waiting.pane] orelse return p.cancelLookHover(); - if (pane.serial != waiting.serial) return p.cancelLookHover(); - const pointed = edit.pointerTextSelection(p, waiting.pane, waiting.col, waiting.row, waiting.body_hit, waiting.tag_hit) orelse - return p.cancelLookHover(); - if (comptime pdf_enabled) if (!pointed.on_tag and panes.Pdf.paneNativeReady(p, pane)) { - const probe = panes.Pdf.probeAt(p, pane, waiting.col, waiting.row) orelse - return p.cancelLookHover(); - const ready = waiting.*; - p.look_hover_wait = null; - p.pdf_hover_preview = .{ - .col = ready.col, - .row = ready.row, - .pane = ready.pane, - .serial = ready.serial, - .probe = probe, - }; - pane.pdf.?.invalidateRaster(probe.page); - return; - }; - const operand = p.pointerOperand(pane, pointed.sel); - if (operand.text == null or operand.text.?.len == 0) return p.cancelLookHover(); - var preview = operand.preview; - if (preview) |*sel| sel.state = .done; - p.look_hover_preview = .{ - .col = waiting.col, - .row = waiting.row, - .pane = waiting.pane, - .serial = waiting.serial, - .sel = preview, - .file_word = operand.file_word, - .body_hit = waiting.body_hit, - .tag_hit = waiting.tag_hit, - }; - p.look_hover_wait = null; - } - pub fn acknowledgePanelPresentation(p: *Pardes, tracks: []const layout.Track) void { p.presentation.acknowledge(p, tracks); if (p.pointer_inside and p.drag != .none) { @@ -6799,12 +6040,12 @@ pub const Pardes = struct { mouse.dragUpdate(p, mapped.col, mapped.row, p.pointer_body_hit); } } - p.refreshLookHoverFromRaw(); + look.refreshLookHoverFromRaw(p); } pub fn abandonPanelAnimations(p: *Pardes) void { p.presentation.cancel(); - p.cancelLookHover(); + look.cancelLookHover(p); } /// A native scene pass proved unavailable. Config reads these exact bits, @@ -6947,289 +6188,6 @@ pub const Pardes = struct { // ---- the ONE dispatcher: look (right/Enter) and execute (middle/Tab) ---- - pub fn focusPaneLine(p: *Pardes, id: usize, at: look.Spot, landing: enum { center, keep }) void { - if (id >= MAX_PANES) return; - const pane = p.panes[id] orelse return; - p.active = id; - pane.normal.clear(); - if (pane.hasPdf()) { - if (landing == .keep) return; - if (comptime pdf_enabled) { - const pv = &pane.pdf.?; - if (pv.focusLocation(p.pdf_gpa, at.line, at.col)) panes.Pdf.resetPageChrome(pane); - } - return; - } - if (at.line == 0) return; - if (pane.file) |*f| { - if (at.line > panes.File.nlines(p.gpa, f)) return; - if (landing == .center) { - const next = (at.line - 1) -| pane.rows / 2; // center, clamp at top - if (next != f.scroll) { - f.scroll = next; - f.syntax_dirty = true; - } - } - } - pane.cur_row = @intCast(at.line - 1); - pane.cur_col = if (at.col > 0) @intCast(at.col - 1) else 0; - pane.cur_pinned = true; - if (at.end_line != 0) return p.selectSpan(pane, at); - pane.ensureCursorVisible(); - } - - fn selectSpan(p: *Pardes, pane: *Pane, at: look.Spot) void { - const text = edit.flatSurface(p, pane) catch return; - // saturating, not `- 1`: `f.zig:0-5` is a legal thing to type and a - // 1-based zero is the same nothing an absent number is - const acol: i32 = @intCast(at.col -| 1); - const ecol: i32 = if (at.end_col > 0) @intCast(at.end_col - 1) else std.math.maxInt(i32); - const r = Pane.cellRange(text, @intCast(at.line -| 1), acol, @intCast(at.end_line -| 1), ecol); - pane.setRange(text, 0, r, true); - } - - fn openPaneTarget(p: *const Pardes, path: []const u8, at: look.Spot) ?look.Target { - for (p.panes, 0..) |slot, i| { - const tt = slot orelse continue; - if (tt.image) |iv| if (std.mem.eql(u8, iv.path, path)) { - return .{ .pane = .{ .id = i, .at = .{} } }; - }; - if (comptime pdf_enabled) if (tt.pdf) |pv| if (std.mem.eql(u8, pv.path, path)) { - return .{ .pane = .{ .id = i, .at = at } }; - }; - const ff = if (tt.file) |*f| f else continue; - if (!std.mem.eql(u8, ff.path, path)) continue; - return .{ .pane = .{ .id = i, .at = at } }; - } - return null; - } - - fn focusPaneByPath(p: *Pardes, path: []const u8, at: look.Spot) bool { - const target = p.openPaneTarget(path, at) orelse return false; - p.clearNavigationSelection(p.panes[target.pane.id].?); - p.focusPaneLine(target.pane.id, target.pane.at, .center); - return true; - } - - /// Navigation starts a new selection at the destination. - pub fn clearNavigationSelection(p: *Pardes, pane: *Pane) void { - pane.vsel = .{}; - pane.msel = .{}; - pane.nsel = 0; - pane.select = false; - pane.normal.clear(); - pane.append_at = null; - pane.sticky_col = -1; - for (0..pane.sel.len) |slot| pane.clearPointerSelection(slot); - pane.sel = @splat(.{}); - if (comptime pdf_enabled) if (pane.pdf) |*state| { - state.clearDrag(); - state.clearSelection(p.pdf_gpa); - }; - if (p.drag == .select and p.panes[p.drag.select.id] == pane) p.drag = .none; - } - - /// Resolve without changing focus or falling back to search. PDF links use - /// the same location rules as an ordinary Look before offering a choice. - fn resolveLookTarget(p: *Pardes, id: usize, txt: []const u8, realbuf: *[4096]u8) look.Target { - const trimmed = std.mem.trim(u8, txt, " \t\r\n"); - const pl = look.parsePathLine(trimmed); - if (pl.path.len > 0) if (p.openPaneTarget(pl.path, pl.at)) |target| return target; - var found: look.Target = .none; - var seen: [MAX_PANES][]const u8 = undefined; - var nseen: usize = 0; - var n: usize = 0; - cand: while (n <= p.njumps) : (n += 1) { - const k: usize = if (n == 0) id else p.jumps[p.njumps - n].pane; - if (n > 0 and k == id) continue; // tried first, skip the rerun - const op = p.panes[k] orelse continue; - const dir = paneDir(op); - for (seen[0..nseen]) |s| if (std.mem.eql(u8, s, dir)) continue :cand; - seen[nseen] = dir; - nseen += 1; - if (pl.path.len > 0 and pl.path[0] != '/') { - var joinbuf: [2048]u8 = undefined; - if (std.fmt.bufPrint(&joinbuf, "{s}/{s}", .{ dir, pl.path }) catch null) |j| - if (p.openPaneTarget(j, pl.at)) |target| return target; - } - found = look.resolve(p, txt, dir, realbuf); - if (found != .none or pl.path.len == 0 or pl.path[0] == '/') break; - } - return found; - } - - fn locationText(p: *Pardes, path: []const u8, at: look.Spot) ?[]const u8 { - if (std.mem.indexOfAny(u8, path, "\r\n") != null) return null; - const arena = p.scratch.allocator(); - if (at.line == 0) return arena.dupe(u8, path) catch null; - if (at.end_line != 0) { - if (at.col == 0 and at.end_col == 0) - return std.fmt.allocPrint(arena, "{s}:{d}-{d}", .{ path, at.line, at.end_line }) catch null; - return std.fmt.allocPrint(arena, "{s}:{d}:{d}-{d}:{d}", .{ path, at.line, at.col, at.end_line, at.end_col }) catch null; - } - if (at.col != 0) return std.fmt.allocPrint(arena, "{s}:{d}:{d}", .{ path, at.line, at.col }) catch null; - return std.fmt.allocPrint(arena, "{s}:{d}", .{ path, at.line }) catch null; - } - - fn canonicalLookLocation(p: *Pardes, id: usize, text: []const u8) ?[]const u8 { - const trimmed = std.mem.trim(u8, text, " \t\r\n"); - if (trimmed.len == 0) return null; - var realbuf: [4096]u8 = undefined; - switch (p.resolveLookTarget(id, trimmed, &realbuf)) { - .none => return null, - .url => |url| { - if (url.len > 256) return null; - for (url) |c| if (c <= 0x20 or c == 0x7f) return null; - return p.scratch.allocator().dupe(u8, url) catch null; - }, - .file => |file| return p.locationText(file.path, file.at), - .image => |iv| return p.locationText(iv.path, .{}), - .dir => |dir| return p.locationText(dir, .{}), - .pane => |target| { - if (target.id >= p.panes.len) return null; - const pane = p.panes[target.id] orelse return null; - if (pane.file) |file| return p.locationText(file.path, target.at); - if (comptime pdf_enabled) if (pane.pdf) |doc| return p.locationText(doc.path, target.at); - if (pane.image) |iv| return p.locationText(iv.path, target.at); - var path: [32]u8 = undefined; - return p.locationText(std.fmt.bufPrint(&path, config.pane_addr ++ "{d}", .{target.id}) catch return null, target.at); - }, - } - } - - pub fn pdfLinkLocation(p: *Pardes, pane: *const Pane, link: if (pdf_enabled) pdf.Link else void) ?[]const u8 { - if (comptime !pdf_enabled) return null; - const state = pane.pdf orelse return null; - const id = p.paneIdOf(pane) orelse return null; - return switch (link.destination) { - .none => null, - .internal => |dest| if (dest.page < state.document.pages) - p.locationText(state.path, .{ .line = dest.page + 1 }) - else - null, - .external => |uri| p.canonicalLookLocation(id, uri), - }; - } - - pub fn followPdfLink(p: *Pardes, id: usize, probe: *const panes.Pdf.WordProbe) void { - if (comptime !pdf_enabled) return; - const pane = p.panes[id] orelse return; - const link = probe.link orelse return; - const target = p.pdfLinkLocation(pane, link) orelse return; - p.clearNavigationSelection(pane); - if (p.canonicalLookLocation(id, probe.text)) |visible| { - if (!std.mem.eql(u8, visible, target)) { - const content = std.fmt.allocPrint(p.gpa, "{s}\n{s}\n", .{ visible, target }) catch |err| - return p.reportError(id, "PDF link choices", err); - panes.Output.fillResults(p, id, std.fs.path.dirname(pane.pdf.?.path) orelse ".", .pdf_links, "", content, null) catch |err| - return p.reportError(id, "PDF link choices", err); - return; - } - } - switch (link.destination) { - .internal => |dest| { - p.noteHaptic(.look); - p.noteLookSource(id); - p.active = id; - panes.Pdf.revealOutlineDestination(p, pane, dest); - }, - .external => p.lookAt(id, target), - .none => {}, - } - } - - pub fn lookAt(p: *Pardes, id: usize, operand: []const u8) void { - const pane = p.panes[id] orelse return; - p.noteHaptic(.look); - p.noteLookSource(id); - const command = std.mem.trim(u8, operand, " \t\r\n"); - if (config.commandWord(command)) |cmd| { - if (p.execute(id, cmd)) |dst| p.active = dst; - return; - } - // The operand may borrow selected terminal/PDF text released below. - const txt = p.scratch.allocator().dupe(u8, operand) catch return; - const trimmed = std.mem.trim(u8, txt, " \t\r\n"); - p.clearNavigationSelection(pane); - const pl = look.parsePathLine(trimmed); - if (comptime pdf_enabled) if (panes.Pdf.lookSection(p, id, pl.path, pl.at)) return; - var realbuf: [4096]u8 = undefined; - const found = p.resolveLookTarget(id, txt, &realbuf); - switch (found) { - .none => { - const bmode = if (pane.tag_edit) pane.tag_mode else pane.mode; - if (pane.image != null or bmode == .tty) return; - p.runSearch(id, trimmed, .text, .cursor) catch |err| { - p.reportError(id, "search", err); - return; - }; - const at = pane.search_row; - _ = p.searchStep(id, 1); - if (at != null and pane.search_row == at) { - pane.search_row = null; - _ = p.searchStep(id, 1); - } - }, - .pane => |t| { - if (t.id >= MAX_PANES) return; - const target = p.panes[t.id] orelse return; - p.clearNavigationSelection(target); - p.focusPaneLine(t.id, t.at, .center); - }, - .url => |u| if (u.len <= 256) p.emit(.{ .open_link = .from(u) }), - .dir => |dir| { - for (p.panes, 0..) |slot, i| { - if (slot) |tt| if (std.mem.eql(u8, tt.cwdSlice(), dir) and p.takesCommandLine(i)) { - p.clearNavigationSelection(tt); - p.active = i; - p.emitWrite(i, "ls\r"); - return; - }; - } - const free = p.freeSlot() orelse return p.reportError(id, "look", error.NoPaneSlots); - const nt = p.newShell(free, dir) catch |err| return p.reportError(id, "look", err); - nt.greet = true; - const src = layout.splitParent(p, id); - const f = layout.findPane(p, src).?; - layout.insert(p, f.col, f.idx + 1, free); - layout.splitBelow(p, src, nt); - p.active = free; - p.stillborn_joiner = nt.serial; - }, - .file => |target| { - if (comptime pdf_enabled) if (target.kind == .pdf) { - if (p.focusPaneByPath(target.path, target.at)) return; - const free = p.freeSlot() orelse return p.reportError(id, "look", error.NoPaneSlots); - const nt = panes.Pdf.openPane(p, free, target.path, target.at.line) catch |err| - return p.reportError(id, "look", err); - p.placeDoc(id, free, nt); - return; - }; - // focus an existing pane on this path (rescrolled), else open - if (p.focusPaneByPath(target.path, target.at)) return; - const free = p.freeSlot() orelse return p.reportError(id, "look", error.NoPaneSlots); - const nt = panes.File.open(p, free, target.path, target.at.line) catch |err| - return p.reportError(id, "look", err); - if (target.at.col > 0) nt.cur_col = @intCast(target.at.col - 1); - p.placeDoc(id, free, nt); - layout.compute(p); - nt.file.?.scroll -|= @max(1, p.rects[free].h -| BOX_H) / 2; - if (target.at.end_line != 0) { - const centered = nt.file.?.scroll; - p.selectSpan(nt, target.at); - nt.file.?.scroll = centered; - } - }, - .image => |target| { - if (p.focusPaneByPath(target.path, .{})) return; - const free = p.freeSlot() orelse return p.reportError(id, "look", error.NoPaneSlots); - const nt = panes.Image.create(p, free, target.path, &.{}) catch |err| - return p.reportError(id, "look", err); - p.placeDoc(id, free, nt); - }, - } - } - const max_exec_depth = 8; pub fn execute(p: *Pardes, id: usize, txt: []const u8) ?usize { @@ -7845,10 +6803,10 @@ pub const Pardes = struct { if (j.pane >= MAX_PANES) return; const pane = p.panes[j.pane] orelse return; if (pane.serial != j.serial) return; - if (p.panes[p.active]) |source| p.clearNavigationSelection(source); - if (p.active != j.pane) p.clearNavigationSelection(pane); + if (p.panes[p.active]) |source| look.clearNavigationSelection(p, source); + if (p.active != j.pane) look.clearNavigationSelection(p, pane); p.jcur = @intCast(next); - p.focusPaneLine(j.pane, .{ .line = j.line, .col = j.col }, .center); + look.focusPaneLine(p, j.pane, .{ .line = j.line, .col = j.col }, .center); } /// Recompute geometry, push grid-size changes to each emulator + pty, fire @@ -7921,7 +6879,7 @@ pub const Pardes = struct { return p.presentation.animating(); } - fn noteHaptic(p: *Pardes, pulse: Haptic) void { + pub fn noteHaptic(p: *Pardes, pulse: Haptic) void { if (comptime platform != .macos) return; if (p.haptic == .look) return; p.haptic = pulse; @@ -9262,7 +8220,7 @@ test "Esc back into a file leaves its view where it was" { try std.testing.expect(dp.cur_row < @as(i32, @intCast(view)) + @as(i32, dp.rows)); // The other arm still centres: a look target, a `:NN`, a search hit. - p.focusPaneLine(doc, .{ .line = @intCast(row + 1), .col = 1 }, .center); + look.focusPaneLine(p, doc, .{ .line = @intCast(row + 1), .col = 1 }, .center); try std.testing.expect(dp.file.?.scroll != view); try std.testing.expectEqual(@as(usize, @intCast(row)) -| @as(usize, dp.rows) / 2, dp.file.?.scroll); } @@ -9724,39 +8682,6 @@ test "board heap: every allocation failure during boot is a clean OutOfMemory" { ); } -test "Look copies its operand before releasing pointer selection storage" { - const p = try Pardes.init(std.testing.allocator, .{ .cols = 60, .rows = 12 }); - defer p.deinit(); - const pane = try p.setTestFile("alpha beta\ngamma delta\nepsilon zeta\n"); - const operand = try p.gpa.dupe(u8, "/test.txt:3:2"); - pane.sel[0] = .{ .state = .done, .source_id = 1 }; - pane.pointer_selections[0] = .{ - .gesture = pane.sel[0], - .rows = try p.gpa.alloc(Pane.PointerRow, 0), - .raw_text = operand, - }; - p.drag = .{ .select = .{ .id = 0, .button = config.select_button } }; - p.lookAt(0, operand); - try std.testing.expectEqual(@as(i32, 2), pane.cur_row); - try std.testing.expectEqual(@as(i32, 1), pane.cur_col); - try std.testing.expect(pane.pointer_selections[0] == null); - try std.testing.expectEqual(.none, pane.sel[0].state); - try std.testing.expectEqual(.none, p.drag); -} - -test "Look ignores missing and out of bounds pane addresses" { - const p = try Pardes.init(std.testing.allocator, .{ .cols = 60, .rows = 12 }); - defer p.deinit(); - const pane = try p.setTestFile("alpha\nbravo\n"); - for ([_][]const u8{ config.pane_addr ++ "15:2", config.pane_addr ++ "999999:2" }) |operand| { - pane.setRange(pane.file.?.content, 0, .{ .anchor = 0, .head = 3 }, true); - p.lookAt(0, operand); - try std.testing.expectEqual(@as(usize, 0), p.active); - try std.testing.expectEqual(@as(i32, 0), pane.cur_row); - try std.testing.expect(!pane.vsel.active); - } -} - test "jump history clears selections and lands at the recorded cursor" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 60, .rows = 12 }); defer p.deinit(); |
