diff options
| author | Gabriel Schneider <[email protected]> | 2026-08-03 01:07:22 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-10 09:17:07 -0300 |
| commit | b50005359c9fa33beef9a2ea7496268a246b296e (patch) | |
| tree | fb5b5df938f130dc108bd35f57e510447b029fd8 /src/normal_input.zig | |
| parent | 6a43880a7ef6899f0d66c1b0a63a7e7b5610236a (diff) | |
| download | pardes-b50005359c9fa33beef9a2ea7496268a246b296e.tar.gz pardes-b50005359c9fa33beef9a2ea7496268a246b296e.zip | |
separate modal parsing and reuse actions for PDF navigation
Diffstat (limited to 'src/normal_input.zig')
| -rw-r--r-- | src/normal_input.zig | 623 |
1 files changed, 623 insertions, 0 deletions
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()); +} |
