diff options
Diffstat (limited to 'src/pardes.zig')
| -rw-r--r-- | src/pardes.zig | 1231 |
1 files changed, 762 insertions, 469 deletions
diff --git a/src/pardes.zig b/src/pardes.zig index 25b1ecb3..4684ee56 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -23,6 +23,7 @@ const ghostty_vt = @import("ghostty-vt"); const uucode = @import("uucode"); const mvzr = @import("mvzr"); const modal = @import("modal.zig"); +const normal_input = @import("normal_input.zig"); const look = @import("look.zig"); const syntax = @import("syntax.zig"); const tracy = @import("tracy.zig"); @@ -184,12 +185,55 @@ test "MuPDF pane renders, navigates, searches, and round-trips its page" { 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, @@ -285,6 +329,23 @@ test "MuPDF pane renders, navigates, searches, and round-trips its page" { 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); @@ -301,7 +362,8 @@ test "MuPDF pane renders, navigates, searches, and round-trips its page" { const results_id = pane.search_pane orelse return error.MissingPdfSearchResults; const results = p.panes[results_id].?.file.?.content; try std.testing.expect(std.mem.indexOf(u8, results, "design.pdf:") != null); - try std.testing.expect(p.searchStep(0, 1)); + p.update(.{ .key = .{ .cp = 'n' } }); + try std.testing.expect(pane.search_row != null); try std.testing.expectEqualStrings("Pardes", pane.pdf.?.search_query); // Search state is owned and untruncated, and changing pages invalidates @@ -322,6 +384,87 @@ test "MuPDF pane renders, navigates, searches, and round-trips its page" { try std.testing.expectEqual(pane.pdf.?.page, restored.panes[0].?.pdf.?.page); } +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.pipe_edit 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.search_edit and pane.tag_edit); + p.update(.{ .key = .{ .cp = Key.escape } }); + try std.testing.expect(!pane.search_edit and !pane.tag_edit); +} + test "PDF continuous strip renders every intersecting short page" { if (!pdf_enabled or platform == .web) return; @@ -918,13 +1061,12 @@ test "PDF native mouse selection, Look, and highlights share page geometry" { _ = frame.reset(.retain_capacity); _ = try p.render(frame.allocator()); - // A fresh modal range supersedes native selection, and the select-button's - // no-drag click retains its longstanding "clear selection" contract. + // 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); - pane.vsel.active = false; - try std.testing.expect(p.beginPdfSelection(pane, word_col, selected_row)); + 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); @@ -2131,6 +2273,141 @@ fn isPrefix(key: Key, cp: u21) bool { return key.cp == cp and !key.ctrl and !key.alt; } +/// The single physical-key -> BODY-NORMAL vocabulary seam. A key may carry +/// several roles (`h` is both move-left and `gh`'s line-start); the pure +/// normal_input parser chooses among them from its explicit prefix state. +/// Both text panes and PDF panes call this exact function before adapting the +/// resulting semantic Action. +fn normalInput(key: Key) normal_input.Input { + var out: normal_input.Input = .{ + .cp = key.cp, + .ctrl = key.ctrl, + .alt = key.alt, + }; + const roles = &out.roles; + if (hit(key, config.escape)) roles.insert(.escape); + + if (isPrefix(key, config.goto_prefix)) roles.insert(.prefix_goto); + if (isPrefix(key, config.view_prefix)) roles.insert(.prefix_view); + if (isPrefix(key, config.match_prefix)) roles.insert(.prefix_match); + if (isPrefix(key, config.find_char_fwd)) roles.insert(.prefix_find_fwd); + if (isPrefix(key, config.find_char_back)) roles.insert(.prefix_find_back); + if (isPrefix(key, config.till_char_fwd)) roles.insert(.prefix_till_fwd); + if (isPrefix(key, config.till_char_back)) roles.insert(.prefix_till_back); + if (isPrefix(key, config.replace_prefix)) roles.insert(.prefix_replace); + if (isPrefix(key, config.next_prefix)) roles.insert(.prefix_next); + if (isPrefix(key, config.prev_prefix)) roles.insert(.prefix_prev); + + if (hit(key, config.goto_file_start)) roles.insert(.goto_file_start); + if (hit(key, config.goto_last_line)) roles.insert(.goto_last_line); + if (hit(key, config.goto_line_start)) roles.insert(.goto_line_start); + if (hit(key, config.goto_line_end)) roles.insert(.goto_line_end); + if (hit(key, config.goto_first_nonws)) roles.insert(.goto_first_nonws); + if (hit(key, config.goto_line_down)) roles.insert(.goto_line_down); + if (hit(key, config.goto_line_up)) roles.insert(.goto_line_up); + if (hit(key, config.goto_column)) roles.insert(.goto_column); + if (hit(key, config.goto_view_top)) roles.insert(.goto_view_top); + if (hit(key, config.goto_view_center)) roles.insert(.goto_view_center); + if (hit(key, config.goto_view_bottom)) roles.insert(.goto_view_bottom); + if (hit(key, config.goto_definition)) roles.insert(.goto_definition); + if (hit(key, config.goto_declaration)) roles.insert(.goto_declaration); + if (hit(key, config.goto_type_definition)) roles.insert(.goto_type_definition); + if (hit(key, config.goto_implementation)) roles.insert(.goto_implementation); + if (hit(key, config.goto_references)) roles.insert(.goto_references); + + if (hit(key, config.view_top)) roles.insert(.view_top); + if (hit(key, config.view_center)) roles.insert(.view_center); + if (hit(key, config.view_bottom)) roles.insert(.view_bottom); + if (hit(key, config.view_scroll_down)) roles.insert(.view_scroll_down); + if (hit(key, config.view_scroll_up)) roles.insert(.view_scroll_up); + + if (isPrefix(key, config.match_inside)) roles.insert(.match_inside); + if (isPrefix(key, config.match_around)) roles.insert(.match_around); + if (isPrefix(key, config.surround_add)) roles.insert(.surround_add); + if (isPrefix(key, config.surround_replace)) roles.insert(.surround_replace); + if (isPrefix(key, config.surround_delete)) roles.insert(.surround_delete); + + if (hit(key, config.goto_paragraph)) roles.insert(.goto_paragraph); + if (hit(key, config.add_newline)) roles.insert(.add_newline); + if (hit(key, config.goto_diagnostic)) roles.insert(.goto_diagnostic); + if (hit(key, config.goto_diagnostic_end)) roles.insert(.goto_diagnostic_end); + + if (hit(key, config.move_left)) roles.insert(.move_left); + if (hit(key, config.move_right)) roles.insert(.move_right); + if (hit(key, config.move_down)) roles.insert(.move_down); + if (hit(key, config.move_up)) roles.insert(.move_up); + if (hit(key, config.next_word_start)) roles.insert(.next_word_start); + if (hit(key, config.prev_word_start)) roles.insert(.prev_word_start); + if (hit(key, config.next_word_end)) roles.insert(.next_word_end); + if (hit(key, config.next_long_word_start)) roles.insert(.next_long_word_start); + if (hit(key, config.prev_long_word_start)) roles.insert(.prev_long_word_start); + if (hit(key, config.next_long_word_end)) roles.insert(.next_long_word_end); + if (hit(key, config.repeat_find)) roles.insert(.repeat_find); + if (hit(key, config.line_start)) roles.insert(.line_start); + if (hit(key, config.line_end)) roles.insert(.line_end); + if (hit(key, config.line_first_nonws)) roles.insert(.line_first_nonws); + if (hit(key, config.goto_line)) roles.insert(.goto_line); + if (hit(key, config.half_page_down)) roles.insert(.half_page_down); + if (hit(key, config.half_page_up)) roles.insert(.half_page_up); + if (hit(key, config.page_down)) roles.insert(.page_down); + if (hit(key, config.page_up)) roles.insert(.page_up); + + if (hit(key, config.insert)) roles.insert(.insert); + if (hit(key, config.append)) roles.insert(.append); + if (hit(key, config.insert_line_start)) roles.insert(.insert_line_start); + if (hit(key, config.insert_line_end)) roles.insert(.insert_line_end); + if (hit(key, config.open_below)) roles.insert(.open_below); + if (hit(key, config.open_above)) roles.insert(.open_above); + + if (hit(key, config.select_mode)) roles.insert(.select_mode); + if (hit(key, config.select_line)) roles.insert(.select_line); + if (hit(key, config.select_line_bounds)) roles.insert(.select_line_bounds); + if (hit(key, config.shrink_to_line_bounds)) roles.insert(.shrink_to_line_bounds); + if (hit(key, config.collapse_selection)) roles.insert(.collapse_selection); + if (hit(key, config.flip_selection)) roles.insert(.flip_selection); + if (hit(key, config.select_all)) roles.insert(.select_all); + if (hit(key, config.copy_sel_below)) roles.insert(.copy_sel_below); + if (hit(key, config.copy_sel_above)) roles.insert(.copy_sel_above); + if (hit(key, config.keep_primary_sel)) roles.insert(.keep_primary_sel); + if (hit(key, config.remove_primary_sel)) roles.insert(.remove_primary_sel); + if (hit(key, config.rotate_sel_fwd)) roles.insert(.rotate_sel_fwd); + if (hit(key, config.rotate_sel_back)) roles.insert(.rotate_sel_back); + if (hit(key, config.split_sel_newline)) roles.insert(.split_sel_newline); + if (hit(key, config.merge_sels)) roles.insert(.merge_sels); + if (hit(key, config.merge_consecutive_sels)) roles.insert(.merge_consecutive_sels); + if (hit(key, config.trim_sels)) roles.insert(.trim_sels); + if (hit(key, config.select_regex)) roles.insert(.select_regex); + if (hit(key, config.split_regex)) roles.insert(.split_regex); + + if (hit(key, config.delete)) roles.insert(.delete); + if (hit(key, config.delete_noyank)) roles.insert(.delete_noyank); + if (hit(key, config.change)) roles.insert(.change); + if (hit(key, config.yank)) roles.insert(.yank); + if (hit(key, config.replace_with_yank)) roles.insert(.replace_with_yank); + if (hit(key, config.paste_after)) roles.insert(.paste_after); + if (hit(key, config.paste_before)) roles.insert(.paste_before); + if (hit(key, config.switch_case)) roles.insert(.switch_case); + if (hit(key, config.to_lowercase)) roles.insert(.to_lowercase); + if (hit(key, config.to_uppercase)) roles.insert(.to_uppercase); + if (hit(key, config.join_lines)) roles.insert(.join_lines); + if (hit(key, config.indent)) roles.insert(.indent); + if (hit(key, config.unindent)) roles.insert(.unindent); + if (hit(key, config.format)) roles.insert(.format); + if (hit(key, config.increment)) roles.insert(.increment); + if (hit(key, config.decrement)) roles.insert(.decrement); + if (hit(key, config.comment_toggle)) roles.insert(.comment_toggle); + if (hit(key, config.undo)) roles.insert(.undo); + if (hit(key, config.redo)) roles.insert(.redo); + + if (hit(key, config.leader)) roles.insert(.leader); + if (hit(key, config.command_line)) roles.insert(.command_line); + if (hit(key, config.pipe_selection)) roles.insert(.pipe_selection); + if (hit(key, config.search)) roles.insert(.search); + if (hit(key, config.search_next)) roles.insert(.search_next); + if (hit(key, config.search_prev)) roles.insert(.search_prev); + return out; +} + /// Pixel dimensions are meaningful only to the native PDF placement path. /// Keep resize events layout-only when that path is not part of the build. pub const CellPixels = if (pdf_enabled) struct { @@ -2539,18 +2816,16 @@ pub const Pane = struct { /// cursor up one grapheme and rebuilds the appended-over selection from /// here (helix doc.restore_cursor). Null outside `a` sessions. append_at: ?struct { row: i32, col: i32 } = null, - /// match-mode second key in flight (`m` + i/a/s/r/d awaiting its char) + /// Compact storage for normal_input.State's match sub-prefix. pending2: u21 = 0, - /// captured first char argument (`mr<from><to>` holds <from> here) + /// Compact storage for normal_input.State's `mr<from><to>` held char. pending_ch: u21 = 0, - /// count prefix accumulator (0 = none). ponytail: capped at 0xffff — - /// nobody types a bigger count, and it keeps the row math in i32 range. + /// Compact storage for normal_input.State's count (0 = none). count: u32 = 0, /// last f/F/t/T motion, for Alt-. repeat find_op: u8 = 0, find_ch: u21 = 0, - /// one pending prefix codepoint (0 = none): `g` `z` `m` `[` `]`, or an - /// op waiting for its char argument (`f` `F` `t` `T` `r`) + /// Compact storage for normal_input.State's typed prefix. pending: u21 = 0, /// `/` search and the Find builtin: the pattern is typed into the tag tail /// (no popup); search_at is the tail length before the marker was appended @@ -4034,32 +4309,13 @@ pub const Pardes = struct { p.runBuiltin(cmd, p.active, "", word); return; } - if (pane.mode == .normal and p.pdfNavigationKey(pane, key)) return; - // normal-mode `p`/`P` paste the core's yank register directly (helix: - // the default register, NOT the system clipboard — most terminals - // refuse the OSC 52 read, so a round trip would never come back). - // Skipped while a prefix waits for its char argument (f/r/m and - // friends take `p` literally). - if (pane.mode == .normal and pane.pending == 0 and (hit(key, config.paste_after) or hit(key, config.paste_before))) { - const before = hit(key, config.paste_before); - if (pane.nsel == 0) - p.normalPaste(pane, before) - else - p.replaySels(pane, if (before) .paste_before else .paste_after, key); - return; - } + // PDFs share BODY-NORMAL recognition with text, then deliberately + // adapt only navigation and the cross-pane command/search actions. + // Returning here keeps unsupported edits away from placeholder cells. + if (pane.mode == .normal and hasPdf(pane)) return p.handlePdfNormal(pane, key); switch (pane.mode) { .normal => { - const id = p.active; - const serial = pane.serial; p.handleNormal(pane, key); - // A new keyboard/modal range supersedes a mouse-native PDF - // selection. Otherwise currentSelText would keep returning the - // stale MuPDF text and make v/x appear to select nothing. - const live = p.panes[id] orelse return; - if (live.serial == serial and hasPdfSelection(live) and - (live.vsel.active or live.msel.active or live.nsel > 0)) - p.clearPdfSelection(live); }, .insert => { if (hit(key, config.escape)) { @@ -4646,16 +4902,16 @@ pub const Pardes = struct { /// The helix keys that act on the selection LIST rather than on the text. /// Each reads the whole list and writes a whole list back; none is a - /// motion, which is why they are exempt from the per-range replay - /// (wholeKey). The goal columns are dropped on the way through — every + /// motion, which is why Action.scope marks them exempt from per-range + /// replay. The goal columns are dropped on the way through — every /// one of these is a fresh intent about WHERE the cursors are, the same /// reason setPaneRange resets sticky_col. - fn multiSelKey(pane: *Pane, pl: PaneLines, text: []const u8, key: Key, cnt: usize) void { + fn multiSelAction(pane: *Pane, pl: PaneLines, text: []const u8, kind: normal_input.Multi, cnt: usize) void { var rs: [MAX_SELS]modal.HxRange = undefined; const got = paneRanges(pane, text, pl.row0, &rs); const n = got.n; const expl = (pane.vsel.active and pane.vsel.explicit) or pane.msel.active; - if (hit(key, config.remove_primary_sel)) { + if (kind == .remove_primary) { if (n < 2) return; // helix: "no selections remaining" var out: [MAX_SELS]modal.HxRange = undefined; var m: usize = 0; @@ -4668,12 +4924,12 @@ pub const Pardes = struct { // previous one when the primary was last return setPaneRanges(pane, pl, text, out[0..m], &.{}, @min(got.pri, m - 1), expl); } - if (hit(key, config.rotate_sel_fwd) or hit(key, config.rotate_sel_back)) { + if (kind == .rotate_forward or kind == .rotate_backward) { const step = cnt % n; - const pri = if (hit(key, config.rotate_sel_fwd)) (got.pri + step) % n else (got.pri + (n - step)) % n; + const pri = if (kind == .rotate_forward) (got.pri + step) % n else (got.pri + (n - step)) % n; return setPaneRanges(pane, pl, text, rs[0..n], &.{}, pri, expl); } - if (hit(key, config.merge_sels)) { + if (kind == .merge) { // helix merge_selections: first.merge(last) — the ranges are // sorted, so that is simply the whole span const lo = @min(rs[0].anchor, rs[0].head); @@ -4682,7 +4938,7 @@ pub const Pardes = struct { const one: modal.HxRange = if (rev) .{ .anchor = hi, .head = lo } else .{ .anchor = lo, .head = hi }; return setPaneRanges(pane, pl, text, &.{one}, &.{}, 0, expl); } - if (hit(key, config.merge_consecutive_sels)) { + if (kind == .merge_consecutive) { // ranges that TOUCH become one; setPaneRanges already merges the // ones that overlap, so widening each by a grapheme says exactly // "consecutive counts as overlapping" and nothing else @@ -4703,7 +4959,7 @@ pub const Pardes = struct { } return setPaneRanges(pane, pl, text, out[0..m], &.{}, pri, expl); } - if (hit(key, config.split_sel_newline)) { + if (kind == .split_newline) { // helix selection::split_on_newline — one range per line the // selection covers, the newlines themselves left out var out: [MAX_SELS]modal.HxRange = undefined; @@ -4734,7 +4990,7 @@ pub const Pardes = struct { if (m == 0) return; return setPaneRanges(pane, pl, text, out[0..m], &.{}, 0, true); // helix keeps primary 0 } - if (hit(key, config.trim_sels)) { + if (kind == .trim) { // helix trim_selections: whitespace off both ends; ranges that are // empty or all whitespace are dropped entirely var out: [MAX_SELS]modal.HxRange = undefined; @@ -4772,7 +5028,7 @@ pub const Pardes = struct { // one column here. Everything else in this file measures the same way // (hscroll, the mouse, the renderer's gutter), and fixing it means // teaching all of them tab stops at once. - const below = hit(key, config.copy_sel_below); + const below = kind == .copy_below; var out: [MAX_SELS]modal.HxRange = undefined; var m: usize = 0; var pri: usize = 0; @@ -4918,6 +5174,78 @@ pub const Pardes = struct { setPaneRanges(pane, pl, text, out[0..m], &.{}, 0, true); // helix keeps primary 0 (its own TODO) } + /// Pane keeps compact codepoints for dump/layout stability; this pair is + /// the only bridge to the parser's typed state. Recognition never reads + /// these representation fields directly. + fn paneNormalState(pane: *const Pane) normal_input.State { + const prefix: normal_input.Prefix = if (pane.pending == config.goto_prefix) + .goto + else if (pane.pending == config.view_prefix) + .view + else if (pane.pending == config.match_prefix) + .match + else if (pane.pending == config.find_char_fwd) + .find_fwd + else if (pane.pending == config.find_char_back) + .find_back + else if (pane.pending == config.till_char_fwd) + .till_fwd + else if (pane.pending == config.till_char_back) + .till_back + else if (pane.pending == config.replace_prefix) + .replace + else if (pane.pending == config.next_prefix) + .next + else if (pane.pending == config.prev_prefix) + .prev + else + .none; + const match_sub: normal_input.MatchSub = if (pane.pending2 == config.match_inside) + .inside + else if (pane.pending2 == config.match_around) + .around + else if (pane.pending2 == config.surround_add) + .surround_add + else if (pane.pending2 == config.surround_replace) + .surround_replace + else if (pane.pending2 == config.surround_delete) + .surround_delete + else + .none; + return .{ + .count = pane.count, + .prefix = prefix, + .match_sub = match_sub, + .held_char = pane.pending_ch, + }; + } + + fn putPaneNormalState(pane: *Pane, state: normal_input.State) void { + pane.count = state.count; + pane.pending = switch (state.prefix) { + .none => 0, + .goto => config.goto_prefix, + .view => config.view_prefix, + .match => config.match_prefix, + .find_fwd => config.find_char_fwd, + .find_back => config.find_char_back, + .till_fwd => config.till_char_fwd, + .till_back => config.till_char_back, + .replace => config.replace_prefix, + .next => config.next_prefix, + .prev => config.prev_prefix, + }; + pane.pending2 = switch (state.match_sub) { + .none => 0, + .inside => config.match_inside, + .around => config.match_around, + .surround_add => config.surround_add, + .surround_replace => config.surround_replace, + .surround_delete => config.surround_delete, + }; + pane.pending_ch = state.held_char; + } + /// Everything one keystroke may CONSUME on the way through the modal /// handler. A key means the same thing at every cursor, so the replay puts /// all of it back before each pass and keeps whatever the PRIMARY's pass @@ -4962,7 +5290,10 @@ pub const Pardes = struct { }; /// what a replayed key does at each cursor - const Replay = enum { normal, insert, paste_after, paste_before }; + const Replay = union(enum) { + normal: normal_input.Action, + insert: Key, + }; /// Run one keystroke at EVERY cursor, by replaying the single-selection /// handler once per range. This IS the multiple-cursor mechanism, and it @@ -4976,7 +5307,7 @@ pub const Pardes = struct { /// * a finished pass's result is recorded as a distance from the END of /// the surface, which an edit strictly before it cannot change (the /// text and the position shift by exactly the same amount). - fn replaySels(p: *Pardes, pane: *Pane, what: Replay, key: Key) void { + fn replaySels(p: *Pardes, pane: *Pane, what: Replay) void { const id = p.active; const serial = pane.serial; // the whole selection in pane coordinates, document order. pane.sels @@ -5038,10 +5369,8 @@ pub const Pardes = struct { pane.msel.active = false; pane.sticky_col = list[i].sticky; switch (what) { - .normal => p.normalKey(pane, key), - .insert => p.insertKey(pane, key), - .paste_after => p.normalPaste(pane, false), - .paste_before => p.normalPaste(pane, true), + .normal => |normal_action| p.executeNormalAction(pane, normal_action), + .insert => |insert_key| p.insertKey(pane, insert_key), } // the stop check comes FIRST: the pass that set it may have freed // this very pane (a builtin closing it), so nothing below may read @@ -5097,8 +5426,8 @@ pub const Pardes = struct { } /// The last line a goto may land on: helix skips the empty trailing line. - /// Called from the three `g`/`G` branches that need it and NOWHERE else — - /// it used to be a `const` at the top of normalKey, which meant every + /// Called from the three `g`/`G` Action branches that need it and nowhere + /// else — it used to be eager at the top of body-normal execution, so every /// keystroke of every kind paid a full count of the buffer's newlines. /// "Does the buffer end in a newline" is the same question as the walk to /// the last line start that stood here, and it is one byte instead of a @@ -5257,407 +5586,295 @@ pub const Pardes = struct { /// multi-cursor family rewrites the list wholesale, and the rest would /// either fight the replay (Esc, undo) or fire n times (`:`, `/`, n/N, SPC). /// - /// A key that turns one range into MANY belongs here and writes its result - /// with setPaneRanges — `Alt-s` is the worked example and `s`/`S` are the - /// same shape a regex later. A key that merely EDITS wants the opposite: - /// leave it out and the replay runs it at every cursor for free. Ctrl-c is - /// the exception that proves it: it edits, but comment-or-uncomment is ONE - /// decision over every line every cursor touches, and a replay would take - /// that decision n times. - fn wholeKey(pane: *Pane, key: Key) bool { - if (hit(key, config.escape)) return true; // Esc means the same whatever is pending - if (pane.pending != 0) return false; // a prefix's char argument is literal - return hit(key, config.copy_sel_below) or hit(key, config.copy_sel_above) or - hit(key, config.keep_primary_sel) or hit(key, config.remove_primary_sel) or - hit(key, config.rotate_sel_fwd) or hit(key, config.rotate_sel_back) or - hit(key, config.split_sel_newline) or hit(key, config.merge_sels) or - hit(key, config.merge_consecutive_sels) or hit(key, config.trim_sels) or - hit(key, config.select_regex) or hit(key, config.split_regex) or - hit(key, config.comment_toggle) or - hit(key, config.undo) or hit(key, config.redo) or - hit(key, config.command_line) or hit(key, config.search) or - hit(key, config.pipe_selection) or - hit(key, config.search_next) or hit(key, config.search_prev) or - hit(key, config.leader); - } - fn handleNormal(p: *Pardes, pane: *Pane, key: Key) void { - if (pane.nsel == 0 or wholeKey(pane, key)) return p.normalKey(pane, key); - p.replaySels(pane, .normal, key); + var state = paneNormalState(pane); + const parsed = normal_input.parse(&state, normalInput(key)); + putPaneNormalState(pane, state); + const semantic = switch (parsed) { + .pending, .ignored, .unbound => return, + .action => |value| value, + }; + if (pane.nsel == 0 or semantic.scope() == .once) + return p.executeNormalAction(pane, semantic); + p.replaySels(pane, .{ .normal = semantic }); } - fn normalKey(p: *Pardes, pane: *Pane, key: Key) void { + /// Text-pane adapter for the semantic BODY-NORMAL vocabulary. Parsing is + /// complete before this function runs; this switch reads document state + /// only to execute the already-recognized action. + fn executeNormalAction(p: *Pardes, pane: *Pane, semantic: normal_input.Action) void { pane.pinCursor(); const pl = p.paneCursorLines(pane) catch return; const text = p.flatSurface(pane, pl) catch return; const lines = pl.lines; - - // Esc: the fast document<->terminal hop, running the SAME Toggleterm - // builtin `SPC w t` does. Clear the normal-mode residue it used to - // cancel first: select mode and pending prefixes/count end here, while - // the selection itself SURVIVES (helix; `;` collapses it). Scoped Esc - // meanings (leader/tag/topbar/search cancel, insert -> normal, raw tty - // forwarding) are intercepted in handleKey before this body-normal - // handler and therefore stay unchanged. - if (hit(key, config.escape)) { - pane.select = false; - pane.pending = 0; - pane.pending2 = 0; - pane.pending_ch = 0; - pane.count = 0; - return p.runBuiltin(.Toggleterm, p.active, "", null); - } - // count prefix: digits accumulate while no prefix op is in flight - // (`0` stays line-start until a count begins) - if (pane.pending == 0 and !key.ctrl and !key.alt and key.cp >= '0' and key.cp <= '9' and !(key.cp == '0' and pane.count == 0)) { - if (pane.count < 0xffff) pane.count = pane.count * 10 + (key.cp - '0'); - return; - } - // any key but a prefix setter consumes the count (setters re-arm it) - const cnt: usize = @max(1, pane.count); - const has_count = pane.count != 0; - const saved_count = pane.count; - pane.count = 0; - const range = paneRange(pane, text, pl.row0); - const cur = modal.hxCursor(text, range); // block-cursor gap offset + const cur = modal.hxCursor(text, range); - // prefix continuations - if (pane.pending == config.goto_prefix) { - pane.pending = 0; - if (hit(key, config.goto_file_start)) { - const line = if (has_count) @min(cnt - 1, maxLine(text)) else 0; - return pointMove(pane, pl, text, range, modal.lineStartOffset(text, line)); - } - if (hit(key, config.goto_last_line)) return pointMove(pane, pl, text, range, modal.lineStartOffset(text, maxLine(text))); - if (hit(key, config.goto_line_start)) return pointMove(pane, pl, text, range, modal.lineStartOffset(text, modal.hxLineOf(text, cur))); - if (hit(key, config.goto_line_end)) { - const line = modal.hxLineOf(text, cur); - const ls = modal.lineStartOffset(text, line); - return pointMove(pane, pl, text, range, @max(ls, modal.prevGrapheme(text, modal.hxLineEndIdx(text, line)))); - } - if (hit(key, config.goto_first_nonws)) { - const line = modal.hxLineOf(text, cur); - const ls = modal.lineStartOffset(text, line); - const slice = text[ls..modal.hxLineEndIdx(text, line)]; - const nw = modal.firstNonWs(slice); - if (nw == slice.len) return; // all-whitespace line: no move (helix) - return pointMove(pane, pl, text, range, ls + nw); - } - if (hit(key, config.goto_line_down)) return verticalMove(pane, pl, text, range, true, cnt); - if (hit(key, config.goto_line_up)) return verticalMove(pane, pl, text, range, false, cnt); - if (hit(key, config.goto_column)) { - const line = modal.hxLineOf(text, cur); - const ls = modal.lineStartOffset(text, line); - return pointMove(pane, pl, text, range, @min(ls + (cnt - 1), modal.hxLineEndIdx(text, line))); - } - if (hit(key, config.goto_view_top)) return gotoWindow(pane, pl, text, range, .top, cnt); - if (hit(key, config.goto_view_center)) return gotoWindow(pane, pl, text, range, .center, cnt); - if (hit(key, config.goto_view_bottom)) return gotoWindow(pane, pl, text, range, .bottom, cnt); - // helix's five LSP gotos, all under `g` and nowhere else. They ask - // and return; the answer arrives later as an lsp_resp Event and - // either jumps or opens a list (lspResponse). - if (hit(key, config.goto_definition)) return p.lspRequest(p.active, .definition, ""); - if (hit(key, config.goto_declaration)) return p.lspRequest(p.active, .declaration, ""); - if (hit(key, config.goto_type_definition)) return p.lspRequest(p.active, .type_definition, ""); - if (hit(key, config.goto_implementation)) return p.lspRequest(p.active, .implementation, ""); - if (hit(key, config.goto_references)) return p.lspRequest(p.active, .references, ""); - return; - } - if (pane.pending == config.view_prefix) { - pane.pending = 0; - if (hit(key, config.view_top)) { - pane.scrollBy(pane.cur_row - pane.scroll()); - pane.ensureCursorVisible(); - return; - } - if (hit(key, config.view_center)) { - pane.scrollBy(pane.cur_row - (pane.scroll() + @divTrunc(@as(i32, pane.rows), 2) - 1)); - pane.ensureCursorVisible(); - return; - } - if (hit(key, config.view_bottom)) { - pane.scrollBy(pane.cur_row - (pane.scroll() + @as(i32, pane.rows) - 1)); - pane.ensureCursorVisible(); - return; - } - if (hit(key, config.view_scroll_down)) return scrollViewMove(pane, pl, text, range, 1); - if (hit(key, config.view_scroll_up)) return scrollViewMove(pane, pl, text, range, -1); - if (hit(key, config.half_page_down)) return halfPageMove(pane, pl, text, range, true); - if (hit(key, config.half_page_up)) return halfPageMove(pane, pl, text, range, false); - if (hit(key, config.page_down)) return scrollViewMove(pane, pl, text, range, @as(i32, pane.rows)); - if (hit(key, config.page_up)) return scrollViewMove(pane, pl, text, range, -@as(i32, pane.rows)); - return; - } - if (pane.pending == config.find_char_fwd or pane.pending == config.find_char_back or - pane.pending == config.till_char_fwd or pane.pending == config.till_char_back) - { - const op: u8 = @intCast(pane.pending); - pane.pending = 0; - if (key.ctrl or key.alt or key.cp >= 0xF0000) return; // not a char - pane.find_op = op; - pane.find_ch = key.cp; - return findMove(pane, pl, text, range, key.cp, op == config.find_char_fwd or op == config.till_char_fwd, op == config.till_char_fwd or op == config.till_char_back, cnt); - } - if (pane.pending == config.replace_prefix) { - pane.pending = 0; - if (key.ctrl or key.alt or key.cp >= 0xF0000 or key.cp > 0x7f) return; - return p.normalReplaceChar(pane, @intCast(key.cp)); - } - if (pane.pending == config.match_prefix) { - if (pane.pending2 == 0) { - if (hit(key, config.match_bracket)) { - pane.pending = 0; - const mc = modal.matchBracket(lines, modal.hxPos(text, cur)) orelse return; - return pointMove(pane, pl, text, range, modal.hxOff(text, mc)); - } - if (isPrefix(key, config.match_inside) or isPrefix(key, config.match_around) or - isPrefix(key, config.surround_add) or isPrefix(key, config.surround_replace) or - isPrefix(key, config.surround_delete)) - { - pane.pending2 = key.cp; // pending stays 'm': the char arg is next - return; - } - pane.pending = 0; - return; - } - const sub = pane.pending2; - if (key.ctrl or key.alt or key.cp >= 0xF0000) { - pane.pending = 0; - pane.pending2 = 0; - pane.pending_ch = 0; - return; - } - if (sub == config.surround_replace and pane.pending_ch == 0) { - pane.pending_ch = key.cp; // mr<from><to>: hold <from>, wait for <to> - return; - } - pane.pending = 0; - pane.pending2 = 0; - const from = pane.pending_ch; - pane.pending_ch = 0; - switch (sub) { - config.match_inside, config.match_around => return p.textobjectSelect(pane, pl, key.cp, sub == config.match_around), - config.surround_add => return p.surroundAdd(pane, key.cp), - config.surround_delete => return p.surroundDelete(pane, pl, key.cp), - config.surround_replace => return p.surroundReplace(pane, pl, from, key.cp), - else => return, - } - } - if (pane.pending == config.next_prefix or pane.pending == config.prev_prefix) { - const fwd = pane.pending == config.next_prefix; - pane.pending = 0; - if (hit(key, config.goto_paragraph)) { - const r2 = modal.hxParaMove(text, range, cnt, fwd, pane.select); + switch (semantic) { + .escape => { + pane.select = false; + return p.runBuiltin(.Toggleterm, p.active, "", null); + }, + .goto => |go| switch (go.target) { + .file_start => { + const line = if (go.explicit_count) @min(@as(usize, go.count) - 1, maxLine(text)) else 0; + return pointMove(pane, pl, text, range, modal.lineStartOffset(text, line)); + }, + .last_line => return pointMove(pane, pl, text, range, modal.lineStartOffset(text, maxLine(text))), + .line_start => return pointMove(pane, pl, text, range, modal.lineStartOffset(text, modal.hxLineOf(text, cur))), + .line_end => { + const line = modal.hxLineOf(text, cur); + const ls = modal.lineStartOffset(text, line); + return pointMove(pane, pl, text, range, @max(ls, modal.prevGrapheme(text, modal.hxLineEndIdx(text, line)))); + }, + .first_nonws => { + const line = modal.hxLineOf(text, cur); + const ls = modal.lineStartOffset(text, line); + const slice = text[ls..modal.hxLineEndIdx(text, line)]; + const nw = modal.firstNonWs(slice); + if (nw == slice.len) return; + return pointMove(pane, pl, text, range, ls + nw); + }, + .line_down => return verticalMove(pane, pl, text, range, true, go.count), + .line_up => return verticalMove(pane, pl, text, range, false, go.count), + .column => { + const line = modal.hxLineOf(text, cur); + const ls = modal.lineStartOffset(text, line); + return pointMove(pane, pl, text, range, @min(ls + (@as(usize, go.count) - 1), modal.hxLineEndIdx(text, line))); + }, + .view_top => return gotoWindow(pane, pl, text, range, .top, go.count), + .view_center => return gotoWindow(pane, pl, text, range, .center, go.count), + .view_bottom => return gotoWindow(pane, pl, text, range, .bottom, go.count), + }, + .view => |view| switch (view) { + .top => { + pane.scrollBy(pane.cur_row - pane.scroll()); + pane.ensureCursorVisible(); + }, + .center => { + pane.scrollBy(pane.cur_row - (pane.scroll() + @divTrunc(@as(i32, pane.rows), 2) - 1)); + pane.ensureCursorVisible(); + }, + .bottom => { + pane.scrollBy(pane.cur_row - (pane.scroll() + @as(i32, pane.rows) - 1)); + pane.ensureCursorVisible(); + }, + .scroll_down => return scrollViewMove(pane, pl, text, range, 1), + .scroll_up => return scrollViewMove(pane, pl, text, range, -1), + }, + .find => |find| { + const op: u8 = switch (find.kind) { + .forward => @intCast(config.find_char_fwd), + .backward => @intCast(config.find_char_back), + .till_forward => @intCast(config.till_char_fwd), + .till_backward => @intCast(config.till_char_back), + }; + pane.find_op = op; + pane.find_ch = find.char; + return findMove( + pane, + pl, + text, + range, + find.char, + find.kind == .forward or find.kind == .till_forward, + find.kind == .till_forward or find.kind == .till_backward, + find.count, + ); + }, + .replace_char => |char| return p.normalReplaceChar(pane, char), + .match_bracket => { + const mc = modal.matchBracket(lines, modal.hxPos(text, cur)) orelse return; + return pointMove(pane, pl, text, range, modal.hxOff(text, mc)); + }, + .textobject => |object| return p.textobjectSelect(pane, pl, object.char, object.around), + .surround_add => |char| return p.surroundAdd(pane, char), + .surround_delete => |char| return p.surroundDelete(pane, pl, char), + .surround_replace => |replace| return p.surroundReplace(pane, pl, replace.from, replace.to), + .paragraph => |paragraph| { + const r2 = modal.hxParaMove(text, range, paragraph.count, paragraph.direction == .forward, pane.select); return setPaneRange(pane, pl, text, r2, false); - } - if (hit(key, config.add_newline)) return p.addNewline(pane, fwd, cnt); - // ]d / [d: step the diagnostics list, asking for one if it is not - // up yet. The list is an ordinary results buffer, so "step" is the - // n/N stepper — `d` differs from `n` only in what it guarantees is - // showing. ]D / [D jump to the last/first, helix's pair. - if (hit(key, config.goto_diagnostic)) { - if (output_pane.resultsFrom(p, pane, .{ .query = .diagnostics }) and p.searchStep(p.active, if (fwd) 1 else -1)) return; - return p.lspRequest(p.active, .diagnostics, ""); - } - if (hit(key, config.goto_diagnostic_end)) { - if (!output_pane.resultsFrom(p, pane, .{ .query = .diagnostics })) return p.lspRequest(p.active, .diagnostics, ""); - if (!fwd) { // [D = first + }, + .add_newline => |newline| return p.addNewline(pane, newline.direction == .forward, newline.count), + .diagnostic => |diagnostic| { + const fwd = diagnostic.direction == .forward; + if (!diagnostic.endpoint) { + if (output_pane.resultsFrom(p, pane, .{ .query = .diagnostics }) and + p.searchStep(p.active, if (fwd) 1 else -1)) return; + return p.lspRequest(p.active, .diagnostics, ""); + } + if (!output_pane.resultsFrom(p, pane, .{ .query = .diagnostics })) + return p.lspRequest(p.active, .diagnostics, ""); + if (!fwd) { pane.search_row = null; _ = p.searchStep(p.active, 1); return; } const rp = p.panes[pane.search_pane orelse return] orelse return; const rf = rp.file orelse return; - pane.search_row = std.mem.count(u8, rf.content, "\n"); // one past the last + pane.search_row = std.mem.count(u8, rf.content, "\n"); _ = p.searchStep(p.active, -1); - return; - } - return; - } - // prefix setters (the count survives into the continuation) - if (isPrefix(key, config.goto_prefix) or isPrefix(key, config.view_prefix) or - isPrefix(key, config.find_char_fwd) or isPrefix(key, config.find_char_back) or - isPrefix(key, config.till_char_fwd) or isPrefix(key, config.till_char_back) or - isPrefix(key, config.replace_prefix) or isPrefix(key, config.next_prefix) or - isPrefix(key, config.prev_prefix)) - { - pane.pending = key.cp; - pane.count = saved_count; - return; - } - if (isPrefix(key, config.match_prefix)) { - pane.pending = config.match_prefix; - pane.pending2 = 0; - pane.pending_ch = 0; - return; - } - // movement - if (hit(key, config.move_left)) { - var t = cur; - for (0..cnt) |_| t = modal.prevGrapheme(text, t); - return pointMove(pane, pl, text, range, t); - } - if (hit(key, config.move_right)) { - var t = cur; - for (0..cnt) |_| t = modal.nextGrapheme(text, t); - return pointMove(pane, pl, text, range, t); - } - if (hit(key, config.move_down)) return verticalMove(pane, pl, text, range, true, cnt); - if (hit(key, config.move_up)) return verticalMove(pane, pl, text, range, false, cnt); - if (hit(key, config.next_word_start)) return wordMove(pane, pl, text, range, cnt, .next_word_start); - if (hit(key, config.prev_word_start)) return wordMove(pane, pl, text, range, cnt, .prev_word_start); - if (hit(key, config.next_word_end)) return wordMove(pane, pl, text, range, cnt, .next_word_end); - if (hit(key, config.next_long_word_start)) return wordMove(pane, pl, text, range, cnt, .next_long_word_start); - if (hit(key, config.prev_long_word_start)) return wordMove(pane, pl, text, range, cnt, .prev_long_word_start); - if (hit(key, config.next_long_word_end)) return wordMove(pane, pl, text, range, cnt, .next_long_word_end); - if (hit(key, config.repeat_find)) { - // repeat the last f/F/t/T; a count repeats the motion N times - if (pane.find_op == 0 or pane.find_ch > 0x7f) return; - const fwd = pane.find_op == config.find_char_fwd or pane.find_op == config.till_char_fwd; - const till = pane.find_op == config.till_char_fwd or pane.find_op == config.till_char_back; - var r = range; - var moved = false; - for (0..cnt) |_| { - const cc = modal.hxCursor(text, r); - const t = modal.hxFindTarget(text, cc, @intCast(pane.find_ch), fwd, till, 1) orelse break; - r = if (pane.select) - modal.hxPutCursor(text, r, t, true) - else - modal.hxPutCursor(text, .{ .anchor = cc, .head = cc }, t, true); - moved = true; - } - if (!moved) return; - return setPaneRange(pane, pl, text, r, false); - } - if (hit(key, config.line_start)) - return pointMove(pane, pl, text, range, modal.lineStartOffset(text, modal.hxLineOf(text, cur))); - if (hit(key, config.line_end)) { - const line = modal.hxLineOf(text, cur); - const ls = modal.lineStartOffset(text, line); - return pointMove(pane, pl, text, range, @max(ls, modal.prevGrapheme(text, modal.hxLineEndIdx(text, line)))); - } - if (hit(key, config.line_first_nonws)) { - const line = modal.hxLineOf(text, cur); - const ls = modal.lineStartOffset(text, line); - const slice = text[ls..modal.hxLineEndIdx(text, line)]; - const nw = modal.firstNonWs(slice); - if (nw == slice.len) return; - return pointMove(pane, pl, text, range, ls + nw); - } - if (hit(key, config.goto_line)) { - // helix goto_line: only acts WITH a count (bare G is a no-op; - // ge is goto-last-line) - if (!has_count) return; - const line = @min(cnt - 1, maxLine(text)); - return pointMove(pane, pl, text, range, modal.lineStartOffset(text, line)); - } - if (hit(key, config.half_page_down)) return halfPageMove(pane, pl, text, range, true); - if (hit(key, config.half_page_up)) return halfPageMove(pane, pl, text, range, false); - if (hit(key, config.page_down)) return scrollViewMove(pane, pl, text, range, @as(i32, pane.rows)); - if (hit(key, config.page_up)) return scrollViewMove(pane, pl, text, range, -@as(i32, pane.rows)); - // insert entry - if (hit(key, config.insert)) return p.enterInsert(pane, .at, cnt); - if (hit(key, config.append)) return p.enterInsert(pane, .append, cnt); - if (hit(key, config.insert_line_start)) return p.enterInsert(pane, .line_start, cnt); - if (hit(key, config.insert_line_end)) return p.enterInsert(pane, .line_end, cnt); - if (hit(key, config.open_below)) return p.enterInsert(pane, .open_below, cnt); - if (hit(key, config.open_above)) return p.enterInsert(pane, .open_above, cnt); - // selections - if (hit(key, config.select_mode)) { - if (pane.select) { - pane.select = false; // exit select mode, selection kept (helix) - } else { - pane.select = true; - if (pane.msel.active) { - // adopt the legacy line highlight as a char range - setPaneRange(pane, pl, text, range, true); - } else if (!pane.vsel.active) { - pane.vsel = .{ .active = true, .row = pane.cur_row, .col = pane.cur_col, .explicit = true }; - } else { - pane.vsel.explicit = true; + }, + .move => |move| switch (move.motion) { + .left => { + var target = cur; + for (0..move.count) |_| target = modal.prevGrapheme(text, target); + return pointMove(pane, pl, text, range, target); + }, + .right => { + var target = cur; + for (0..move.count) |_| target = modal.nextGrapheme(text, target); + return pointMove(pane, pl, text, range, target); + }, + .down => return verticalMove(pane, pl, text, range, true, move.count), + .up => return verticalMove(pane, pl, text, range, false, move.count), + .next_word_start => return wordMove(pane, pl, text, range, move.count, .next_word_start), + .prev_word_start => return wordMove(pane, pl, text, range, move.count, .prev_word_start), + .next_word_end => return wordMove(pane, pl, text, range, move.count, .next_word_end), + .next_long_word_start => return wordMove(pane, pl, text, range, move.count, .next_long_word_start), + .prev_long_word_start => return wordMove(pane, pl, text, range, move.count, .prev_long_word_start), + .next_long_word_end => return wordMove(pane, pl, text, range, move.count, .next_long_word_end), + }, + .repeat_find => |count| { + if (pane.find_op == 0 or pane.find_ch > 0x7f) return; + const fwd = pane.find_op == config.find_char_fwd or pane.find_op == config.till_char_fwd; + const till = pane.find_op == config.till_char_fwd or pane.find_op == config.till_char_back; + var repeated = range; + var moved = false; + for (0..count) |_| { + const cc = modal.hxCursor(text, repeated); + const target = modal.hxFindTarget(text, cc, @intCast(pane.find_ch), fwd, till, 1) orelse break; + repeated = if (pane.select) + modal.hxPutCursor(text, repeated, target, true) + else + modal.hxPutCursor(text, .{ .anchor = cc, .head = cc }, target, true); + moved = true; } - } - pane.cur_pinned = true; - pane.pending = 0; - return; - } - if (hit(key, config.select_line)) return lineSelect(pane, pl, text, range, cnt); - if (hit(key, config.select_line_bounds)) return lineBoundsSelect(pane, pl, text, range); - if (hit(key, config.shrink_to_line_bounds)) return shrinkSelToLineBounds(pane, pl, text, range); - if (hit(key, config.collapse_selection)) return setPaneRange(pane, pl, text, .{ .anchor = cur, .head = cur }, false); - if (hit(key, config.flip_selection)) { - const expl = (pane.vsel.active and pane.vsel.explicit) or pane.msel.active; - return setPaneRange(pane, pl, text, .{ .anchor = range.head, .head = range.anchor }, expl); - } - if (hit(key, config.select_all)) return setPaneRange(pane, pl, text, .{ .anchor = 0, .head = text.len }, false); - // multiple cursors: `,` is the one that needs nothing but the list - if (hit(key, config.keep_primary_sel)) { - pane.nsel = 0; // helix Selection::single(primary) - return; - } - if (hit(key, config.remove_primary_sel) or hit(key, config.rotate_sel_fwd) or - hit(key, config.rotate_sel_back) or hit(key, config.copy_sel_below) or - hit(key, config.copy_sel_above) or hit(key, config.split_sel_newline) or - hit(key, config.merge_sels) or hit(key, config.merge_consecutive_sels) or - hit(key, config.trim_sels)) return multiSelKey(pane, pl, text, key, cnt); - // the two that need a REGEX first: arm the tag input and let the - // pattern's own keystrokes drive the preview (startSelRegex) - if (hit(key, config.select_regex)) return p.startSelRegex(pane, false); - if (hit(key, config.split_regex)) return p.startSelRegex(pane, true); - // edits - if (hit(key, config.delete)) return p.normalDelete(pane, true); - if (hit(key, config.delete_noyank)) return p.normalDelete(pane, false); - if (hit(key, config.change)) return p.normalChange(pane); - if (hit(key, config.yank)) return p.normalYank(pane); - if (hit(key, config.replace_with_yank)) return p.normalReplaceYank(pane); - if (hit(key, config.switch_case)) return p.normalCase(pane, .toggle); - if (hit(key, config.to_lowercase)) return p.normalCase(pane, .lower); - if (hit(key, config.to_uppercase)) return p.normalCase(pane, .upper); - if (hit(key, config.join_lines)) return p.normalJoin(pane); - if (hit(key, config.indent)) return p.normalIndent(pane, cnt, true); - if (hit(key, config.unindent)) return p.normalIndent(pane, cnt, false); - // helix's `=` (format_selections) — its neighbour on the keyboard and - // in the keymap. Unbound in pardes until now. - if (hit(key, config.format)) return p.lspRequest(p.active, .format, ""); - if (hit(key, config.increment)) return p.normalAdjustNumber(pane, @intCast(cnt)); - if (hit(key, config.decrement)) return p.normalAdjustNumber(pane, -@as(i64, @intCast(cnt))); - if (hit(key, config.comment_toggle)) return p.normalToggleComment(pane); - if (hit(key, config.undo)) return p.doUndo(pane); - if (hit(key, config.redo)) return p.doRedo(pane); - // SPC — the leader: a key path from here runs a BUILTIN with no - // arguments, the same builtins the topbar and the tags hold (`?` at - // any depth lists what the prefix can still reach). Helix spends Space - // on pickers, which pardes does not have; acme's builtins are exactly - // what a leader is for. Body normal mode only — a tag is always - // insert, and a tty pane's keys belong to the program. - if (hit(key, config.leader)) { - p.leader_on = true; - p.leader_n = 0; - return; - } - // `:` — vim's command line with acme's vocabulary: focus the pane's own - // tag in normal mode, parked at the tail's start. Motions walk the - // words and the execute chord runs the one under the cursor, then - // hands the body back (`:w<Tab>` = Save). Always the start, not the - // remembered tag column, so `:w` is the same keystrokes every time. - if (hit(key, config.command_line)) { - p.enterTagEdit(pane, -1); - if (pane.tag_edit) pane.mode = .normal; - return; - } - // `|` — Helix's pipe command. Only a real, savable file can be - // replaced; output buffers, terminals, images and PDFs stay inert. - if (hit(key, config.pipe_selection)) return p.startPipe(pane); - // search: `/` types a pattern into the tag, n/N walk the results. Same - // keys on every kind of pane; a terminal with no search armed falls - // back to n/N as a motion over the lookable tokens in its output. - if (hit(key, config.search)) return p.startSearch(pane, config.search_marker); - if (hit(key, config.search_next) or hit(key, config.search_prev)) { - const delta: i32 = if (hit(key, config.search_next)) 1 else -1; - if (p.searchStep(p.active, delta)) return; - if (pane.isTerminal()) return p.lookStep(pane, pl, delta); - return; + if (!moved) return; + return setPaneRange(pane, pl, text, repeated, false); + }, + .line => |line_kind| switch (line_kind) { + .start => return pointMove(pane, pl, text, range, modal.lineStartOffset(text, modal.hxLineOf(text, cur))), + .end => { + const line = modal.hxLineOf(text, cur); + const ls = modal.lineStartOffset(text, line); + return pointMove(pane, pl, text, range, @max(ls, modal.prevGrapheme(text, modal.hxLineEndIdx(text, line)))); + }, + .first_nonws => { + const line = modal.hxLineOf(text, cur); + const ls = modal.lineStartOffset(text, line); + const slice = text[ls..modal.hxLineEndIdx(text, line)]; + const nw = modal.firstNonWs(slice); + if (nw == slice.len) return; + return pointMove(pane, pl, text, range, ls + nw); + }, + }, + .goto_line => |go| { + if (!go.explicit) return; + const line = @min(@as(usize, go.count) - 1, maxLine(text)); + return pointMove(pane, pl, text, range, modal.lineStartOffset(text, line)); + }, + .page => |page| switch (page.kind) { + // Counts were parsed historically but these four text view + // actions intentionally move exactly one viewport unit. + .half_down => return halfPageMove(pane, pl, text, range, true), + .half_up => return halfPageMove(pane, pl, text, range, false), + .down => return scrollViewMove(pane, pl, text, range, @as(i32, pane.rows)), + .up => return scrollViewMove(pane, pl, text, range, -@as(i32, pane.rows)), + }, + .insert => |insert| { + const where: InsertAt = switch (insert.kind) { + .at => .at, + .append => .append, + .line_start => .line_start, + .line_end => .line_end, + .open_below => .open_below, + .open_above => .open_above, + }; + return p.enterInsert(pane, where, insert.count); + }, + .select => |select| switch (select.kind) { + .mode => { + if (pane.select) { + pane.select = false; + } else { + pane.select = true; + if (pane.msel.active) { + setPaneRange(pane, pl, text, range, true); + } else if (!pane.vsel.active) { + pane.vsel = .{ .active = true, .row = pane.cur_row, .col = pane.cur_col, .explicit = true }; + } else { + pane.vsel.explicit = true; + } + } + pane.cur_pinned = true; + }, + .line => return lineSelect(pane, pl, text, range, select.count), + .line_bounds => return lineBoundsSelect(pane, pl, text, range), + .shrink_to_line_bounds => return shrinkSelToLineBounds(pane, pl, text, range), + .collapse => return setPaneRange(pane, pl, text, .{ .anchor = cur, .head = cur }, false), + .flip => { + const explicit = (pane.vsel.active and pane.vsel.explicit) or pane.msel.active; + return setPaneRange(pane, pl, text, .{ .anchor = range.head, .head = range.anchor }, explicit); + }, + .all => return setPaneRange(pane, pl, text, .{ .anchor = 0, .head = text.len }, false), + }, + .multi => |multi| { + if (multi.kind == .keep_primary) { + pane.nsel = 0; + return; + } + return multiSelAction(pane, pl, text, multi.kind, multi.count); + }, + .select_regex => |split| return p.startSelRegex(pane, split), + .edit => |edit| switch (edit.kind) { + .delete => return p.normalDelete(pane, true), + .delete_noyank => return p.normalDelete(pane, false), + .change => return p.normalChange(pane), + .yank => return p.normalYank(pane), + .replace_with_yank => return p.normalReplaceYank(pane), + .paste_after => { + pane.count = edit.count; + return p.normalPaste(pane, false); + }, + .paste_before => { + pane.count = edit.count; + return p.normalPaste(pane, true); + }, + .switch_case => return p.normalCase(pane, .toggle), + .lowercase => return p.normalCase(pane, .lower), + .uppercase => return p.normalCase(pane, .upper), + .join_lines => return p.normalJoin(pane), + .indent => return p.normalIndent(pane, edit.count, true), + .unindent => return p.normalIndent(pane, edit.count, false), + .comment_toggle => return p.normalToggleComment(pane), + .undo => return p.doUndo(pane), + .redo => return p.doRedo(pane), + }, + .lsp => |request| return p.lspRequest(p.active, switch (request) { + .definition => .definition, + .declaration => .declaration, + .type_definition => .type_definition, + .implementation => .implementation, + .references => .references, + .format => .format, + }, ""), + .adjust_number => |delta| return p.normalAdjustNumber(pane, delta), + .leader => { + p.leader_on = true; + p.leader_n = 0; + }, + .command_line => { + p.enterTagEdit(pane, -1); + if (pane.tag_edit) pane.mode = .normal; + }, + .pipe_selection => return p.startPipe(pane), + .search => return p.startSearch(pane, config.search_marker), + .search_step => |direction| { + const delta: i32 = if (direction == .forward) 1 else -1; + if (p.searchStep(p.active, delta)) return; + if (pane.isTerminal()) return p.lookStep(pane, pl, delta); + }, } } @@ -6466,7 +6683,7 @@ pub const Pardes = struct { /// mean exactly the same thing in a shell pane as in a document. fn handleInsert(p: *Pardes, pane: *Pane, key: Key) void { if (pane.nsel == 0) return p.insertKey(pane, key); - p.replaySels(pane, .insert, key); + p.replaySels(pane, .{ .insert = key }); } fn insertKey(p: *Pardes, pane: *Pane, key: Key) void { @@ -7220,7 +7437,7 @@ pub const Pardes = struct { /// the language's line-comment token put in front of it — or taken off, /// and WHICH of the two is decided once for the whole set: one uncommented /// non-blank line among them and everything gets commented. That single - /// decision is why this is a wholeKey instead of a per-cursor replay; + /// decision is why Action.scope runs this once instead of per cursor; /// replayed, a half-commented block would end up half-commented the other /// way round. /// @@ -9363,40 +9580,116 @@ pub const Pardes = struct { _ = p.panPdfPixels(pane, .horizontal, direction, scaledPdfStep(p.cell_pixels.w, cols)); } - /// Vertical keys move through the same document-pixel space as the wheel; - /// PdfFit changes page scale, not the continuity of navigation. - fn pdfNavigationKey(p: *Pardes, pane: *Pane, key: Key) bool { - if (comptime !pdf_enabled) return false; - if (pane.pdf == null or pane.pending != 0) return false; - const row_down = hit(key, config.move_down); - const row_up = hit(key, config.move_up); - const half_down = hit(key, config.half_page_down); - const half_up = hit(key, config.half_page_up); - const page_down = hit(key, config.page_down); - const page_up = hit(key, config.page_up); - const down = row_down or half_down or page_down; - const up = row_up or half_up or page_up; - if (!down and !up) return false; - const count = @max(1, pane.count); - pane.count = 0; - const direction: i8 = if (down) 1 else -1; + fn handlePdfNormal(p: *Pardes, pane: *Pane, key: Key) void { + if (comptime !pdf_enabled) return; + if (pane.pdf == null) return; + var state = paneNormalState(pane); + const parsed = normal_input.parse(&state, normalInput(key)); + 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(count); - p.stepPdfPage(pane, if (down) pages else -pages); - return true; + 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 = if (row_down or row_up) - @as(u32, p.cell_pixels.h) - else if (half_down or half_up) - if (viewport) |v| @max(@as(u32, 1), v.pixel_h / 2) else @as(u32, p.cell_pixels.h) - else if (viewport) |v| - v.pixel_h - else - @as(u32, p.cell_pixels.h); + 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); - return true; + } + + 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(.Toggleterm, 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; + }, + .search => p.startSearch(pane, config.search_marker), + .search_step => |direction| { + _ = p.searchStep(p.active, if (direction == .forward) 1 else -1); + }, + else => {}, + } } /// stack a fresh doc pane at the top of the LEFT column (acme convention: |
