//! 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: u21, 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; return resultAction(.{ .replace_char = 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 "replace accepts a Unicode literal" { var state: State = .{}; try std.testing.expectEqual(Result.pending, parse(&state, input('r', &.{.prefix_replace}))); try std.testing.expectEqualDeep(Result{ .action = .{ .replace_char = '界' } }, parse(&state, input('界', &.{}))); 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()); }