diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/modal.zig | 44 | ||||
| -rw-r--r-- | src/normal_input.zig | 623 | ||||
| -rw-r--r-- | src/pardes.zig | 1231 |
3 files changed, 1396 insertions, 502 deletions
diff --git a/src/modal.zig b/src/modal.zig index cdc49142..8e36d1ae 100644 --- a/src/modal.zig +++ b/src/modal.zig @@ -1279,13 +1279,10 @@ test "hx increment" { try std.testing.expectEqual(@as(?[]u8, null), try hxIncrement(a, "", 1)); } -// ---- file-pane modal step ---- -// One keypress on an editable FILE pane. This is the single source of truth for -// file-modal behavior: main.zig delegates file panes here, and the tutor-driven -// unit tests (below) exercise it directly. Pure (no vaxis/ghostty/term.zig) — -// only std + the modal primitives above. Returns the new content (caller frees -// the old), or null if the key only moved the cursor / was a no-op (caller keeps -// the old content, no realloc). +// ---- retired standalone file-pane modal prototype ---- +// This old pure-std model has no production or harness callers. It remains +// only as context for the primitive algorithms around it; normal_input.zig is +// the sole BODY-NORMAL recognizer and Pardes owns Action execution. pub const Key = union(enum) { ch: u21, // a printable codepoint (insert inserts its UTF-8 bytes) @@ -1535,9 +1532,11 @@ fn stepInsertFile(alloc: std.mem.Allocator, content: []const u8, st: *FileState, } } -// The full NORMAL-mode file step (motions + insert entry + select/edit). See -// handleNormal in main.zig for the terminal counterpart (which differs in its -// edit ops: terminals can't delete shell output, only pasted runs). +// RETIRED LEGACY REFERENCE INTERPRETER. Nothing in production or the test +// harness calls this mini-parser; BODY-NORMAL recognition lives exclusively +// in normal_input.zig and Pardes executes its semantic Actions for text/PDF. +// Keep this frozen only as old modal-algorithm context; do not add bindings or +// infer current behavior from it. pub fn stepFile(alloc: std.mem.Allocator, content: []const u8, st: *FileState, view: View, key: Key, yank: *?[]u8) !?[]u8 { const lines = try splitLines(alloc, content); defer alloc.free(lines); @@ -1550,11 +1549,7 @@ pub fn stepFile(alloc: std.mem.Allocator, content: []const u8, st: *FileState, v const p = st.pending; st.pending = 0; if (p == 'g') { - if (chEq(key, 'g')) cur = gotoFirst() - else if (chEq(key, 'e')) cur = gotoLast(lines) - else if (chEq(key, 'h')) cur = lineStart(cur) - else if (chEq(key, 'l')) cur = lineEnd(lines, cur) - else return null; // unknown continuation: consume, no-op + if (chEq(key, 'g')) cur = gotoFirst() else if (chEq(key, 'e')) cur = gotoLast(lines) else if (chEq(key, 'h')) cur = lineStart(cur) else if (chEq(key, 'l')) cur = lineEnd(lines, cur) else return null; // unknown continuation: consume, no-op st.cur = cur; st.msel_active = false; return null; @@ -1582,24 +1577,7 @@ pub fn stepFile(alloc: std.mem.Allocator, content: []const u8, st: *FileState, v // motions -> move cursor, collapse selection, keep visible var moved: ?Cursor = null; - if (chEq(key, 'h') or tagOf(key) == .left) moved = charLeft(cur) - else if (chEq(key, 'l') or tagOf(key) == .right) moved = charRight(lines, cur) - else if (chEq(key, 'j') or tagOf(key) == .down) moved = lineDown(lines, cur) - else if (chEq(key, 'k') or tagOf(key) == .up) moved = lineUp(lines, cur) - else if (chEq(key, 'w')) moved = nextWordStart(lines, cur, false) - else if (chEq(key, 'W')) moved = nextWordStart(lines, cur, true) - else if (chEq(key, 'b')) moved = prevWordStart(lines, cur, false) - else if (chEq(key, 'B')) moved = prevWordStart(lines, cur, true) - else if (chEq(key, 'e')) moved = nextWordEnd(lines, cur, false) - else if (chEq(key, 'E')) moved = nextWordEnd(lines, cur, true) - else if (chEq(key, '0') or tagOf(key) == .home) moved = lineStart(cur) - else if (chEq(key, '$') or tagOf(key) == .end) moved = lineEnd(lines, cur) - else if (chEq(key, '^')) moved = firstNonWsOf(lines, cur) - else if (chEq(key, 'G')) moved = gotoLast(lines) - else if (tagOf(key) == .half_down) moved = clampToChar(lines, pageDown(cur, @max(1, view.rows / 2))) - else if (tagOf(key) == .half_up) moved = clampToChar(lines, pageUp(cur, @max(1, view.rows / 2))) - else if (tagOf(key) == .page_down) moved = clampToChar(lines, pageDown(cur, view.rows)) - else if (tagOf(key) == .page_up) moved = clampToChar(lines, pageUp(cur, view.rows)); + if (chEq(key, 'h') or tagOf(key) == .left) moved = charLeft(cur) else if (chEq(key, 'l') or tagOf(key) == .right) moved = charRight(lines, cur) else if (chEq(key, 'j') or tagOf(key) == .down) moved = lineDown(lines, cur) else if (chEq(key, 'k') or tagOf(key) == .up) moved = lineUp(lines, cur) else if (chEq(key, 'w')) moved = nextWordStart(lines, cur, false) else if (chEq(key, 'W')) moved = nextWordStart(lines, cur, true) else if (chEq(key, 'b')) moved = prevWordStart(lines, cur, false) else if (chEq(key, 'B')) moved = prevWordStart(lines, cur, true) else if (chEq(key, 'e')) moved = nextWordEnd(lines, cur, false) else if (chEq(key, 'E')) moved = nextWordEnd(lines, cur, true) else if (chEq(key, '0') or tagOf(key) == .home) moved = lineStart(cur) else if (chEq(key, '$') or tagOf(key) == .end) moved = lineEnd(lines, cur) else if (chEq(key, '^')) moved = firstNonWsOf(lines, cur) else if (chEq(key, 'G')) moved = gotoLast(lines) else if (tagOf(key) == .half_down) moved = clampToChar(lines, pageDown(cur, @max(1, view.rows / 2))) else if (tagOf(key) == .half_up) moved = clampToChar(lines, pageUp(cur, @max(1, view.rows / 2))) else if (tagOf(key) == .page_down) moved = clampToChar(lines, pageDown(cur, view.rows)) else if (tagOf(key) == .page_up) moved = clampToChar(lines, pageUp(cur, view.rows)); if (moved) |m| { st.cur = m; st.msel_active = false; diff --git a/src/normal_input.zig b/src/normal_input.zig new file mode 100644 index 00000000..c6b2574f --- /dev/null +++ b/src/normal_input.zig @@ -0,0 +1,623 @@ +//! Pure BODY-NORMAL input recognition. +//! +//! The platform/core boundary first normalizes a physical key into every +//! configured `Role` it matches. This module then owns the state machine: +//! counts, prefixes and literal character arguments. It deliberately knows +//! nothing about panes or text, so the text and PDF adapters consume exactly +//! the same semantic `Action` values. +const std = @import("std"); + +pub const Role = enum { + escape, + + prefix_goto, + prefix_view, + prefix_match, + prefix_find_fwd, + prefix_find_back, + prefix_till_fwd, + prefix_till_back, + prefix_replace, + prefix_next, + prefix_prev, + + goto_file_start, + goto_last_line, + goto_line_start, + goto_line_end, + goto_first_nonws, + goto_line_down, + goto_line_up, + goto_column, + goto_view_top, + goto_view_center, + goto_view_bottom, + goto_definition, + goto_declaration, + goto_type_definition, + goto_implementation, + goto_references, + + view_top, + view_center, + view_bottom, + view_scroll_down, + view_scroll_up, + + match_inside, + match_around, + surround_add, + surround_replace, + surround_delete, + + goto_paragraph, + add_newline, + goto_diagnostic, + goto_diagnostic_end, + + move_left, + move_right, + move_down, + move_up, + next_word_start, + prev_word_start, + next_word_end, + next_long_word_start, + prev_long_word_start, + next_long_word_end, + repeat_find, + line_start, + line_end, + line_first_nonws, + goto_line, + half_page_down, + half_page_up, + page_down, + page_up, + + insert, + append, + insert_line_start, + insert_line_end, + open_below, + open_above, + + select_mode, + select_line, + select_line_bounds, + shrink_to_line_bounds, + collapse_selection, + flip_selection, + select_all, + copy_sel_below, + copy_sel_above, + keep_primary_sel, + remove_primary_sel, + rotate_sel_fwd, + rotate_sel_back, + split_sel_newline, + merge_sels, + merge_consecutive_sels, + trim_sels, + select_regex, + split_regex, + + delete, + delete_noyank, + change, + yank, + replace_with_yank, + paste_after, + paste_before, + switch_case, + to_lowercase, + to_uppercase, + join_lines, + indent, + unindent, + format, + increment, + decrement, + comment_toggle, + undo, + redo, + + leader, + command_line, + pipe_selection, + search, + search_next, + search_prev, +}; + +pub const Input = struct { + roles: std.EnumSet(Role) = .initEmpty(), + cp: u21, + ctrl: bool = false, + alt: bool = false, + + pub fn has(value: Input, role: Role) bool { + return value.roles.contains(role); + } + + fn literal(value: Input) ?u21 { + if (value.ctrl or value.alt or value.cp >= 0xF0000) return null; + return value.cp; + } +}; + +pub const Prefix = enum(u8) { + none, + goto, + view, + match, + find_fwd, + find_back, + till_fwd, + till_back, + replace, + next, + prev, +}; + +pub const MatchSub = enum(u8) { + none, + inside, + around, + surround_add, + surround_replace, + surround_delete, +}; + +pub const State = struct { + count: u32 = 0, + prefix: Prefix = .none, + match_sub: MatchSub = .none, + held_char: u21 = 0, + + pub fn clear(state: *State) void { + state.* = .{}; + } +}; + +pub const Scope = enum { once, per_selection }; +pub const Direction = enum { backward, forward }; +pub const Motion = enum { + left, + right, + down, + up, + next_word_start, + prev_word_start, + next_word_end, + next_long_word_start, + prev_long_word_start, + next_long_word_end, +}; +pub const Goto = enum { + file_start, + last_line, + line_start, + line_end, + first_nonws, + line_down, + line_up, + column, + view_top, + view_center, + view_bottom, +}; +pub const View = enum { top, center, bottom, scroll_down, scroll_up }; +pub const Find = enum { forward, backward, till_forward, till_backward }; +pub const Line = enum { start, end, first_nonws }; +pub const Page = enum { half_down, half_up, down, up }; +pub const Insert = enum { at, append, line_start, line_end, open_below, open_above }; +pub const Select = enum { + mode, + line, + line_bounds, + shrink_to_line_bounds, + collapse, + flip, + all, +}; +pub const Multi = enum { + copy_below, + copy_above, + keep_primary, + remove_primary, + rotate_forward, + rotate_backward, + split_newline, + merge, + merge_consecutive, + trim, +}; +pub const Edit = enum { + delete, + delete_noyank, + change, + yank, + replace_with_yank, + paste_after, + paste_before, + switch_case, + lowercase, + uppercase, + join_lines, + indent, + unindent, + comment_toggle, + undo, + redo, +}; +pub const Lsp = enum { definition, declaration, type_definition, implementation, references, format }; + +pub const Counted = struct { + count: u32, + explicit: bool, +}; + +pub const Action = union(enum) { + escape, + goto: struct { target: Goto, count: u32, explicit_count: bool }, + view: View, + find: struct { kind: Find, char: u21, count: u32 }, + replace_char: u8, + match_bracket, + textobject: struct { char: u21, around: bool }, + surround_add: u21, + surround_delete: u21, + surround_replace: struct { from: u21, to: u21 }, + paragraph: struct { direction: Direction, count: u32 }, + add_newline: struct { direction: Direction, count: u32 }, + diagnostic: struct { direction: Direction, endpoint: bool }, + move: struct { motion: Motion, count: u32 }, + repeat_find: u32, + line: Line, + goto_line: Counted, + page: struct { kind: Page, count: u32 }, + insert: struct { kind: Insert, count: u32 }, + select: struct { kind: Select, count: u32 }, + multi: struct { kind: Multi, count: u32 }, + select_regex: bool, // false = select, true = split + edit: struct { kind: Edit, count: u32 }, + lsp: Lsp, + adjust_number: i64, + leader, + command_line, + pipe_selection, + search, + search_step: Direction, + + pub fn scope(value: Action) Scope { + return switch (value) { + .escape, + .multi, + .select_regex, + .leader, + .command_line, + .pipe_selection, + .search, + .search_step, + => .once, + .edit => |edit| switch (edit.kind) { + .comment_toggle, .undo, .redo => .once, + else => .per_selection, + }, + else => .per_selection, + }; + } +}; + +pub const Result = union(enum) { + pending, + ignored, + unbound, + action: Action, +}; + +fn resultAction(value: Action) Result { + return .{ .action = value }; +} + +fn consumeCount(state: *State) Counted { + const count = state.count; + state.count = 0; + return .{ .count = @max(1, count), .explicit = count != 0 }; +} + +fn armPrefix(state: *State, prefix: Prefix, saved_count: u32) Result { + state.prefix = prefix; + state.count = saved_count; + if (prefix == .match) { + state.match_sub = .none; + state.held_char = 0; + } + return .pending; +} + +/// Consume one normalized physical key. Invalid continuations are +/// distinguished from genuinely unbound top-level keys, and always clear the +/// prefix that owned them. +pub fn parse(state: *State, key: Input) Result { + if (key.has(.escape)) { + state.clear(); + return resultAction(.escape); + } + + // A digit is a count only before a command/prefix. A leading zero keeps + // its configured line-start role; after another digit it extends count. + if (state.prefix == .none and !key.ctrl and !key.alt and + key.cp >= '0' and key.cp <= '9' and + !(key.cp == '0' and state.count == 0)) + { + if (state.count < 0xffff) + state.count = state.count * 10 + key.cp - '0'; + return .pending; + } + + const counted = consumeCount(state); + const count = counted.count; + + switch (state.prefix) { + .goto => { + state.prefix = .none; + if (key.has(.goto_file_start)) return resultAction(.{ .goto = .{ .target = .file_start, .count = count, .explicit_count = counted.explicit } }); + if (key.has(.goto_last_line)) return resultAction(.{ .goto = .{ .target = .last_line, .count = count, .explicit_count = counted.explicit } }); + if (key.has(.goto_line_start)) return resultAction(.{ .goto = .{ .target = .line_start, .count = count, .explicit_count = counted.explicit } }); + if (key.has(.goto_line_end)) return resultAction(.{ .goto = .{ .target = .line_end, .count = count, .explicit_count = counted.explicit } }); + if (key.has(.goto_first_nonws)) return resultAction(.{ .goto = .{ .target = .first_nonws, .count = count, .explicit_count = counted.explicit } }); + if (key.has(.goto_line_down)) return resultAction(.{ .goto = .{ .target = .line_down, .count = count, .explicit_count = counted.explicit } }); + if (key.has(.goto_line_up)) return resultAction(.{ .goto = .{ .target = .line_up, .count = count, .explicit_count = counted.explicit } }); + if (key.has(.goto_column)) return resultAction(.{ .goto = .{ .target = .column, .count = count, .explicit_count = counted.explicit } }); + if (key.has(.goto_view_top)) return resultAction(.{ .goto = .{ .target = .view_top, .count = count, .explicit_count = counted.explicit } }); + if (key.has(.goto_view_center)) return resultAction(.{ .goto = .{ .target = .view_center, .count = count, .explicit_count = counted.explicit } }); + if (key.has(.goto_view_bottom)) return resultAction(.{ .goto = .{ .target = .view_bottom, .count = count, .explicit_count = counted.explicit } }); + if (key.has(.goto_definition)) return resultAction(.{ .lsp = .definition }); + if (key.has(.goto_declaration)) return resultAction(.{ .lsp = .declaration }); + if (key.has(.goto_type_definition)) return resultAction(.{ .lsp = .type_definition }); + if (key.has(.goto_implementation)) return resultAction(.{ .lsp = .implementation }); + if (key.has(.goto_references)) return resultAction(.{ .lsp = .references }); + return .ignored; + }, + .view => { + state.prefix = .none; + if (key.has(.view_top)) return resultAction(.{ .view = .top }); + if (key.has(.view_center)) return resultAction(.{ .view = .center }); + if (key.has(.view_bottom)) return resultAction(.{ .view = .bottom }); + if (key.has(.view_scroll_down)) return resultAction(.{ .view = .scroll_down }); + if (key.has(.view_scroll_up)) return resultAction(.{ .view = .scroll_up }); + if (key.has(.half_page_down)) return resultAction(.{ .page = .{ .kind = .half_down, .count = count } }); + if (key.has(.half_page_up)) return resultAction(.{ .page = .{ .kind = .half_up, .count = count } }); + if (key.has(.page_down)) return resultAction(.{ .page = .{ .kind = .down, .count = count } }); + if (key.has(.page_up)) return resultAction(.{ .page = .{ .kind = .up, .count = count } }); + return .ignored; + }, + .find_fwd, .find_back, .till_fwd, .till_back => |prefix| { + state.prefix = .none; + const char = key.literal() orelse return .ignored; + const kind: Find = switch (prefix) { + .find_fwd => .forward, + .find_back => .backward, + .till_fwd => .till_forward, + .till_back => .till_backward, + else => unreachable, + }; + return resultAction(.{ .find = .{ .kind = kind, .char = char, .count = count } }); + }, + .replace => { + state.prefix = .none; + const char = key.literal() orelse return .ignored; + if (char > 0x7f) return .ignored; + return resultAction(.{ .replace_char = @intCast(char) }); + }, + .match => { + if (state.match_sub == .none) { + if (key.has(.prefix_match)) { + state.prefix = .none; + return resultAction(.match_bracket); + } + const sub: MatchSub = if (key.has(.match_inside)) + .inside + else if (key.has(.match_around)) + .around + else if (key.has(.surround_add)) + .surround_add + else if (key.has(.surround_replace)) + .surround_replace + else if (key.has(.surround_delete)) + .surround_delete + else { + state.prefix = .none; + return .ignored; + }; + state.match_sub = sub; + return .pending; + } + const char = key.literal() orelse { + state.clear(); + return .ignored; + }; + if (state.match_sub == .surround_replace and state.held_char == 0) { + state.held_char = char; + return .pending; + } + const sub = state.match_sub; + const from = state.held_char; + state.clear(); + return switch (sub) { + .inside => resultAction(.{ .textobject = .{ .char = char, .around = false } }), + .around => resultAction(.{ .textobject = .{ .char = char, .around = true } }), + .surround_add => resultAction(.{ .surround_add = char }), + .surround_delete => resultAction(.{ .surround_delete = char }), + .surround_replace => resultAction(.{ .surround_replace = .{ .from = from, .to = char } }), + .none => unreachable, + }; + }, + .next, .prev => |prefix| { + state.prefix = .none; + const direction: Direction = if (prefix == .next) .forward else .backward; + if (key.has(.goto_paragraph)) return resultAction(.{ .paragraph = .{ .direction = direction, .count = count } }); + if (key.has(.add_newline)) return resultAction(.{ .add_newline = .{ .direction = direction, .count = count } }); + if (key.has(.goto_diagnostic)) return resultAction(.{ .diagnostic = .{ .direction = direction, .endpoint = false } }); + if (key.has(.goto_diagnostic_end)) return resultAction(.{ .diagnostic = .{ .direction = direction, .endpoint = true } }); + return .ignored; + }, + .none => {}, + } + + // Prefix setters retain the count for their continuation. + if (key.has(.prefix_goto)) return armPrefix(state, .goto, if (counted.explicit) count else 0); + if (key.has(.prefix_view)) return armPrefix(state, .view, if (counted.explicit) count else 0); + if (key.has(.prefix_find_fwd)) return armPrefix(state, .find_fwd, if (counted.explicit) count else 0); + if (key.has(.prefix_find_back)) return armPrefix(state, .find_back, if (counted.explicit) count else 0); + if (key.has(.prefix_till_fwd)) return armPrefix(state, .till_fwd, if (counted.explicit) count else 0); + if (key.has(.prefix_till_back)) return armPrefix(state, .till_back, if (counted.explicit) count else 0); + if (key.has(.prefix_replace)) return armPrefix(state, .replace, if (counted.explicit) count else 0); + if (key.has(.prefix_next)) return armPrefix(state, .next, if (counted.explicit) count else 0); + if (key.has(.prefix_prev)) return armPrefix(state, .prev, if (counted.explicit) count else 0); + if (key.has(.prefix_match)) return armPrefix(state, .match, 0); + + if (key.has(.move_left)) return resultAction(.{ .move = .{ .motion = .left, .count = count } }); + if (key.has(.move_right)) return resultAction(.{ .move = .{ .motion = .right, .count = count } }); + if (key.has(.move_down)) return resultAction(.{ .move = .{ .motion = .down, .count = count } }); + if (key.has(.move_up)) return resultAction(.{ .move = .{ .motion = .up, .count = count } }); + if (key.has(.next_word_start)) return resultAction(.{ .move = .{ .motion = .next_word_start, .count = count } }); + if (key.has(.prev_word_start)) return resultAction(.{ .move = .{ .motion = .prev_word_start, .count = count } }); + if (key.has(.next_word_end)) return resultAction(.{ .move = .{ .motion = .next_word_end, .count = count } }); + if (key.has(.next_long_word_start)) return resultAction(.{ .move = .{ .motion = .next_long_word_start, .count = count } }); + if (key.has(.prev_long_word_start)) return resultAction(.{ .move = .{ .motion = .prev_long_word_start, .count = count } }); + if (key.has(.next_long_word_end)) return resultAction(.{ .move = .{ .motion = .next_long_word_end, .count = count } }); + if (key.has(.repeat_find)) return resultAction(.{ .repeat_find = count }); + if (key.has(.line_start)) return resultAction(.{ .line = .start }); + if (key.has(.line_end)) return resultAction(.{ .line = .end }); + if (key.has(.line_first_nonws)) return resultAction(.{ .line = .first_nonws }); + if (key.has(.goto_line)) return resultAction(.{ .goto_line = counted }); + if (key.has(.half_page_down)) return resultAction(.{ .page = .{ .kind = .half_down, .count = count } }); + if (key.has(.half_page_up)) return resultAction(.{ .page = .{ .kind = .half_up, .count = count } }); + if (key.has(.page_down)) return resultAction(.{ .page = .{ .kind = .down, .count = count } }); + if (key.has(.page_up)) return resultAction(.{ .page = .{ .kind = .up, .count = count } }); + + if (key.has(.insert)) return resultAction(.{ .insert = .{ .kind = .at, .count = count } }); + if (key.has(.append)) return resultAction(.{ .insert = .{ .kind = .append, .count = count } }); + if (key.has(.insert_line_start)) return resultAction(.{ .insert = .{ .kind = .line_start, .count = count } }); + if (key.has(.insert_line_end)) return resultAction(.{ .insert = .{ .kind = .line_end, .count = count } }); + if (key.has(.open_below)) return resultAction(.{ .insert = .{ .kind = .open_below, .count = count } }); + if (key.has(.open_above)) return resultAction(.{ .insert = .{ .kind = .open_above, .count = count } }); + + if (key.has(.select_mode)) return resultAction(.{ .select = .{ .kind = .mode, .count = count } }); + if (key.has(.select_line)) return resultAction(.{ .select = .{ .kind = .line, .count = count } }); + if (key.has(.select_line_bounds)) return resultAction(.{ .select = .{ .kind = .line_bounds, .count = count } }); + if (key.has(.shrink_to_line_bounds)) return resultAction(.{ .select = .{ .kind = .shrink_to_line_bounds, .count = count } }); + if (key.has(.collapse_selection)) return resultAction(.{ .select = .{ .kind = .collapse, .count = count } }); + if (key.has(.flip_selection)) return resultAction(.{ .select = .{ .kind = .flip, .count = count } }); + if (key.has(.select_all)) return resultAction(.{ .select = .{ .kind = .all, .count = count } }); + + if (key.has(.copy_sel_below)) return resultAction(.{ .multi = .{ .kind = .copy_below, .count = count } }); + if (key.has(.copy_sel_above)) return resultAction(.{ .multi = .{ .kind = .copy_above, .count = count } }); + if (key.has(.keep_primary_sel)) return resultAction(.{ .multi = .{ .kind = .keep_primary, .count = count } }); + if (key.has(.remove_primary_sel)) return resultAction(.{ .multi = .{ .kind = .remove_primary, .count = count } }); + if (key.has(.rotate_sel_fwd)) return resultAction(.{ .multi = .{ .kind = .rotate_forward, .count = count } }); + if (key.has(.rotate_sel_back)) return resultAction(.{ .multi = .{ .kind = .rotate_backward, .count = count } }); + if (key.has(.split_sel_newline)) return resultAction(.{ .multi = .{ .kind = .split_newline, .count = count } }); + if (key.has(.merge_sels)) return resultAction(.{ .multi = .{ .kind = .merge, .count = count } }); + if (key.has(.merge_consecutive_sels)) return resultAction(.{ .multi = .{ .kind = .merge_consecutive, .count = count } }); + if (key.has(.trim_sels)) return resultAction(.{ .multi = .{ .kind = .trim, .count = count } }); + if (key.has(.select_regex)) return resultAction(.{ .select_regex = false }); + if (key.has(.split_regex)) return resultAction(.{ .select_regex = true }); + + if (key.has(.delete)) return resultAction(.{ .edit = .{ .kind = .delete, .count = count } }); + if (key.has(.delete_noyank)) return resultAction(.{ .edit = .{ .kind = .delete_noyank, .count = count } }); + if (key.has(.change)) return resultAction(.{ .edit = .{ .kind = .change, .count = count } }); + if (key.has(.yank)) return resultAction(.{ .edit = .{ .kind = .yank, .count = count } }); + if (key.has(.replace_with_yank)) return resultAction(.{ .edit = .{ .kind = .replace_with_yank, .count = count } }); + if (key.has(.paste_after)) return resultAction(.{ .edit = .{ .kind = .paste_after, .count = count } }); + if (key.has(.paste_before)) return resultAction(.{ .edit = .{ .kind = .paste_before, .count = count } }); + if (key.has(.switch_case)) return resultAction(.{ .edit = .{ .kind = .switch_case, .count = count } }); + if (key.has(.to_lowercase)) return resultAction(.{ .edit = .{ .kind = .lowercase, .count = count } }); + if (key.has(.to_uppercase)) return resultAction(.{ .edit = .{ .kind = .uppercase, .count = count } }); + if (key.has(.join_lines)) return resultAction(.{ .edit = .{ .kind = .join_lines, .count = count } }); + if (key.has(.indent)) return resultAction(.{ .edit = .{ .kind = .indent, .count = count } }); + if (key.has(.unindent)) return resultAction(.{ .edit = .{ .kind = .unindent, .count = count } }); + if (key.has(.format)) return resultAction(.{ .lsp = .format }); + if (key.has(.increment)) return resultAction(.{ .adjust_number = @intCast(count) }); + if (key.has(.decrement)) return resultAction(.{ .adjust_number = -@as(i64, @intCast(count)) }); + if (key.has(.comment_toggle)) return resultAction(.{ .edit = .{ .kind = .comment_toggle, .count = count } }); + if (key.has(.undo)) return resultAction(.{ .edit = .{ .kind = .undo, .count = count } }); + if (key.has(.redo)) return resultAction(.{ .edit = .{ .kind = .redo, .count = count } }); + + if (key.has(.leader)) return resultAction(.leader); + if (key.has(.command_line)) return resultAction(.command_line); + if (key.has(.pipe_selection)) return resultAction(.pipe_selection); + if (key.has(.search)) return resultAction(.search); + if (key.has(.search_next)) return resultAction(.{ .search_step = .forward }); + if (key.has(.search_prev)) return resultAction(.{ .search_step = .backward }); + return .unbound; +} + +fn input(cp: u21, roles: []const Role) Input { + return .{ .cp = cp, .roles = .initMany(roles) }; +} + +test "counts survive prefixes and identical parser actions can feed both adapters" { + var text: State = .{}; + var pdf: State = .{}; + const sequence = [_]Input{ + input('1', &.{}), + input('2', &.{}), + input('g', &.{ .prefix_goto, .goto_file_start }), + input('j', &.{ .move_down, .goto_line_down, .view_scroll_down }), + }; + for (sequence[0 .. sequence.len - 1]) |key| { + try std.testing.expectEqualDeep(parse(&text, key), parse(&pdf, key)); + } + const ta = parse(&text, sequence[sequence.len - 1]); + const pa = parse(&pdf, sequence[sequence.len - 1]); + try std.testing.expectEqualDeep(ta, pa); + try std.testing.expectEqualDeep(Result{ .action = .{ .goto = .{ + .target = .line_down, + .count = 12, + .explicit_count = true, + } } }, ta); + try std.testing.expectEqual(State{}, text); + try std.testing.expectEqual(State{}, pdf); +} + +test "invalid continuations are ignored and clear prefix plus count" { + var state: State = .{}; + try std.testing.expectEqual(Result.pending, parse(&state, input('4', &.{}))); + try std.testing.expectEqual(Result.pending, parse(&state, input('g', &.{.prefix_goto}))); + try std.testing.expectEqual(Result.ignored, parse(&state, input('?', &.{}))); + try std.testing.expectEqual(State{}, state); + try std.testing.expectEqualDeep(Result{ .action = .{ .move = .{ .motion = .down, .count = 1 } } }, parse(&state, input('j', &.{.move_down}))); +} + +test "literal arguments retain conflicting command characters" { + var state: State = .{}; + try std.testing.expectEqual(Result.pending, parse(&state, input('f', &.{.prefix_find_fwd}))); + try std.testing.expectEqualDeep(Result{ .action = .{ .find = .{ .kind = .forward, .char = 'p', .count = 1 } } }, parse(&state, input('p', &.{.paste_after}))); + + try std.testing.expectEqual(Result.pending, parse(&state, input('m', &.{.prefix_match}))); + try std.testing.expectEqual(Result.pending, parse(&state, input('r', &.{.surround_replace}))); + try std.testing.expectEqual(Result.pending, parse(&state, input('[', &.{.prefix_prev}))); + try std.testing.expectEqualDeep(Result{ .action = .{ .surround_replace = .{ .from = '[', .to = ']' } } }, parse(&state, input(']', &.{.prefix_next}))); + try std.testing.expectEqual(State{}, state); +} + +test "modified and special keys cannot satisfy literal continuations" { + var state: State = .{}; + _ = parse(&state, input('r', &.{.prefix_replace})); + try std.testing.expectEqual(Result.ignored, parse(&state, .{ .cp = 'x', .ctrl = true })); + try std.testing.expectEqual(State{}, state); + _ = parse(&state, input('f', &.{.prefix_find_fwd})); + try std.testing.expectEqual(Result.ignored, parse(&state, .{ .cp = 0xF0001 })); + try std.testing.expectEqual(State{}, state); +} + +test "once versus per-selection is semantic action metadata" { + try std.testing.expectEqual(Scope.once, (@as(Action, .search)).scope()); + try std.testing.expectEqual(Scope.once, (Action{ .edit = .{ .kind = .undo, .count = 1 } }).scope()); + try std.testing.expectEqual(Scope.per_selection, (Action{ .edit = .{ .kind = .delete, .count = 1 } }).scope()); + try std.testing.expectEqual(Scope.per_selection, (Action{ .move = .{ .motion = .down, .count = 3 } }).scope()); +} 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: |
