summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/modal.zig44
-rw-r--r--src/normal_input.zig623
-rw-r--r--src/pardes.zig1231
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: