From aa2c42761a91e8eca2d551fe14f1bc1dd3fc8593 Mon Sep 17 00:00:00 2001 From: Jackson Moore Date: Fri, 4 Sep 2026 14:42:49 +0200 Subject: Fix native dot replay bridge --- lua/clever_f/host_adapter.lua | 129 +++++++++++++++++++++++++++++++++++++++++- tests/run.lua | 86 ++++++++++++++++++++++++++++ 2 files changed, 214 insertions(+), 1 deletion(-) diff --git a/lua/clever_f/host_adapter.lua b/lua/clever_f/host_adapter.lua index 17c29ef..a81a44c 100644 --- a/lua/clever_f/host_adapter.lua +++ b/lua/clever_f/host_adapter.lua @@ -48,6 +48,7 @@ function HostAdapter.new(options) cursor_presentations = {}, events = {}, dot_repeat = nil, + dot_bridge = nil, event_order = {}, augroup = nil, } @@ -124,6 +125,8 @@ function HostAdapter:set_operator_inclusive(enabled) end end +local install_dot_bridge + function HostAdapter:register_dot_repeat(payload, callback) if not domain.DotPayload.is(payload) then fail("dot-repeat payload must be a DotPayload", 2) @@ -131,10 +134,18 @@ function HostAdapter:register_dot_repeat(payload, callback) if callback ~= nil and type(callback) ~= "function" then fail("dot-repeat callback must be a function", 2) end - adapter_records[self].dot_repeat = { + local record = adapter_records[self] + record.dot_repeat = { payload = payload, callback = callback, + operator = self:read_pending_operator(), } + if install_dot_bridge ~= nil then + install_dot_bridge(self) + end + if record.dot_bridge ~= nil then + record.dot_bridge.awaiting_change = true + end return payload end @@ -479,6 +490,122 @@ function HostAdapter:remove_event_registration(identity) return true end +local DOT_MOTION_MAPPING = "(clever-f-dot-motion)" + +local function dot_bridge_supported(runtime) + return type(runtime.keymap) == "table" + and type(runtime.keymap.set) == "function" + and type(runtime.keymap.del) == "function" + and type(runtime.fn.maparg) == "function" + and type(runtime.fn.mapset) == "function" + and type(runtime.api.nvim_feedkeys) == "function" +end + +local function restore_dot_mapping(record) + local bridge = record.dot_bridge + if bridge == nil or not bridge.active then + return false + end + bridge.active = false + pcall(record.runtime.keymap.del, "n", ".") + if type(bridge.previous_mapping) == "table" + and next(bridge.previous_mapping) ~= nil + then + record.runtime.fn.mapset("n", false, bridge.previous_mapping) + end + return true +end + +local function dot_replay_keys(runtime, count, operator) + local prefix = count > 0 and tostring(count) or "" + local keys = prefix .. operator .. DOT_MOTION_MAPPING + if type(runtime.keycode) == "function" then + return runtime.keycode(keys) + end + return runtime.api.nvim_replace_termcodes(keys, true, false, true) +end + +install_dot_bridge = function(adapter) + local record = adapter_records[adapter] + local runtime = record.runtime + if not dot_bridge_supported(runtime) then + return nil + end + local bridge = record.dot_bridge + if bridge == nil then + bridge = { + active = false, + awaiting_change = false, + previous_mapping = nil, + } + record.dot_bridge = bridge + runtime.keymap.set("o", DOT_MOTION_MAPPING, function() + local registration = record.dot_repeat + if registration == nil or registration.callback == nil then + return + end + local count = runtime.v.count1 + if type(count) ~= "number" or count < 1 then + count = 1 + end + local outcome = registration.callback( + registration.payload, + domain.Count.new(count) + ) + if domain.ActionOutcome.is(outcome) then + adapter:translate_action_outcome(outcome) + end + end, { + silent = true, + remap = false, + desc = "clever-f dot motion", + }) + local has_cmd_atom = type(runtime.fn.exists) == "function" + and runtime.fn.exists("##CmdAtom") == 1 + local ownership_events = has_cmd_atom + and "CmdAtom" + or { "TextChanged", "TextChangedI", "TextChangedP" } + runtime.api.nvim_create_autocmd(ownership_events, { + group = event_augroup(record), + desc = "clever-f dot ownership", + callback = function(event) + if has_cmd_atom and not (event.data and event.data.changed) then + return + end + if bridge.awaiting_change then + bridge.awaiting_change = false + return + end + restore_dot_mapping(record) + end, + }) + end + if not bridge.active then + bridge.previous_mapping = runtime.fn.maparg(".", "n", false, true) + runtime.keymap.set("n", ".", function() + local registration = record.dot_repeat + if registration == nil or registration.operator == "" then + restore_dot_mapping(record) + runtime.api.nvim_feedkeys(".", "n", false) + return + end + bridge.awaiting_change = true + local count = runtime.v.count or 0 + runtime.api.nvim_feedkeys( + dot_replay_keys(runtime, count, registration.operator), + "n", + false + ) + end, { + silent = true, + remap = false, + desc = "clever-f dot repeat", + }) + bridge.active = true + end + return bridge +end + function HostAdapter:deliver_event(name, payload) if type(name) ~= "string" or name == "" then fail("event name must be a nonempty string", 2) diff --git a/tests/run.lua b/tests/run.lua index d4535ef..fb84dd3 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -417,6 +417,92 @@ test("Host adapter connects motion endpoints and dot payloads", function() same(3, replayed_count.value) end) +test("Host adapter bridges native dot to resolved payload replay", function() + local mappings = {} + local deleted = {} + local restored + local ownership_autocmd + local fed + local runtime = { + api = { + nvim_create_augroup = function() + return 9 + end, + nvim_create_autocmd = function(_, options) + ownership_autocmd = options.callback + return 12 + end, + nvim_feedkeys = function(keys, mode, escape_csi) + fed = { keys = keys, mode = mode, escape_csi = escape_csi } + end, + }, + fn = { + exists = function(name) + same("##CmdAtom", name) + return 1 + end, + maparg = function(lhs, mode, abbreviation, dictionary) + same(".", lhs) + same("n", mode) + falsy(abbreviation) + truthy(dictionary) + return { lhs = ".", rhs = "prior-dot" } + end, + mapset = function(mode, abbreviation, mapping) + restored = { mode = mode, abbreviation = abbreviation, mapping = mapping } + end, + }, + keymap = { + set = function(mode, lhs, callback, options) + mappings[mode .. "\0" .. lhs] = { + callback = callback, + options = options, + } + end, + del = function(mode, lhs) + deleted[#deleted + 1] = mode .. "\0" .. lhs + mappings[mode .. "\0" .. lhs] = nil + end, + }, + keycode = function(keys) + return keys + end, + v = { operator = "d", count = 2, count1 = 2 }, + } + local adapter = host_adapter.new({ runtime = runtime }) + local payload = domain.DotPayload.new( + "f", + domain.TargetValue.character("e", string.byte("e")) + ) + local replayed + adapter:register_dot_repeat(payload, function(value, count) + replayed = { payload = value, count = count } + return domain.ActionOutcome.neutral(domain.Position.new(1, 1)) + end) + + local dot = mappings["n\0."] + local motion = mappings["o\0(clever-f-dot-motion)"] + truthy(dot ~= nil) + truthy(motion ~= nil) + dot.callback() + same("2d(clever-f-dot-motion)", fed.keys) + same("n", fed.mode) + falsy(fed.escape_csi) + + motion.callback() + same(payload, replayed.payload) + same(2, replayed.count.value) + + ownership_autocmd({ data = { changed = true } }) + truthy(mappings["n\0."] ~= nil) + ownership_autocmd({ data = { changed = true } }) + same(nil, mappings["n\0."]) + list_same({ "n\0." }, deleted) + same("n", restored.mode) + falsy(restored.abbreviation) + same("prior-dot", restored.mapping.rhs) +end) + test("Host adapter reads encoding and uses Nvim case conversion", function() local lowered = {} local runtime = { -- cgit v1.2.3