diff options
| author | Jackson Moore <jacksonmoore@tuta.io> | 2026-09-04 18:44:32 +0200 |
|---|---|---|
| committer | Jackson Moore <jacksonmoore@tuta.io> | 2026-09-04 18:44:32 +0200 |
| commit | c7b14ffb6d14969c9c41864e827f33f8e80fc24e (patch) | |
| tree | c7c0ac23dc93a4a8b75449d2be4d0e05c4f05f9e /lua/clever_tee/repeat_resolver.lua | |
| parent | 9013636a57144e1f57c9339e7888d588430aae6a (diff) | |
Rename plugin to clever-tee
Diffstat (limited to 'lua/clever_tee/repeat_resolver.lua')
| -rw-r--r-- | lua/clever_tee/repeat_resolver.lua | 333 |
1 files changed, 333 insertions, 0 deletions
diff --git a/lua/clever_tee/repeat_resolver.lua b/lua/clever_tee/repeat_resolver.lua new file mode 100644 index 0000000..67d6617 --- /dev/null +++ b/lua/clever_tee/repeat_resolver.lua @@ -0,0 +1,333 @@ +local domain = require("clever_tee.domain") +local sequence_state = require("clever_tee.sequence_state") +local state_transitions = require("clever_tee.state_transitions") + +local M = {} +local RepeatResolver = {} +M.RepeatResolver = RepeatResolver + +M.Decision = { + ACQUIRE = "acquire", + REPEAT = "repeat", +} +M.ACQUIRE = M.Decision.ACQUIRE +M.REPEAT = M.Decision.REPEAT + +local resolver_records = setmetatable({}, { __mode = "k" }) + +local function fail(message, level) + error(message, (level or 1) + 1) +end + +local function is_nonnegative_integer(value) + return type(value) == "number" + and value >= 0 + and value < math.huge + and value == math.floor(value) +end + +local function require_policy(service) + if service ~= nil and (type(service) ~= "table" + or type(service.sample_timeouts) ~= "function" + or type(service.sample_direction) ~= "function") + then + fail("RepeatResolver policy must sample timeouts and direction", 3) + end + return service +end + +local function require_clock(clock) + if clock ~= nil and (type(clock) ~= "table" + or type(clock.read_time_ms) ~= "function") + then + fail("RepeatResolver clock must provide read_time_ms", 3) + end + return clock +end + +local function require_transitions(transitions, state) + transitions = transitions or state_transitions.new(state) + if type(transitions) ~= "table" + or type(transitions.SetRepeatTimestamp) ~= "function" + or type(transitions.PublicReset) ~= "function" + then + fail("RepeatResolver transitions must set repeat time and apply Public Reset", 3) + end + return transitions +end + +local function finite_time(value) + return type(value) == "number" + and value == value + and value > -math.huge + and value < math.huge +end + +function M.truncate_elapsed_ms(elapsed_ms) + if not finite_time(elapsed_ms) then + fail("elapsed milliseconds must be finite", 2) + end + local integer_part = math.modf(elapsed_ms) + return integer_part +end + +M.truncate_milliseconds = M.truncate_elapsed_ms + +local resolver_metatable = { + __index = RepeatResolver, + __newindex = function() + fail("RepeatResolver values are immutable", 2) + end, + __tostring = function() + return "repeat-resolver" + end, + __metatable = "clever_tee.repeat_resolver.RepeatResolver", +} + +function RepeatResolver.new(options) + if RepeatResolver.is(options) then + return options + end + if options == nil then + options = {} + elseif sequence_state.is(options) then + options = { state = options } + elseif type(options) ~= "table" then + fail("RepeatResolver options must be a table", 2) + end + + local state = options.state or sequence_state.get() + if not sequence_state.is(state) then + fail("RepeatResolver state must be the plugin-global SequenceState", 2) + end + + local resolver = setmetatable({}, resolver_metatable) + resolver_records[resolver] = { + clock = require_clock(options.clock or options.time_provider or options.host), + policy = require_policy(options.policy or options.policy_service), + state = state, + transitions = require_transitions( + options.transitions or options.state_transitions, + state + ), + } + return resolver +end + +function RepeatResolver.is(value) + return type(value) == "table" and resolver_records[value] ~= nil +end + +function RepeatResolver:previous_landing(context) + context = domain.ModeContext.from_full_mode(context) + return resolver_records[self].state:get_previous_landing(context) +end + +function RepeatResolver:decide(context, current_position, macro_state) + current_position = domain.Position.coerce(current_position) + local landing = self:previous_landing(context) + if landing == nil or not domain.Position.equal(landing, current_position) then + return M.Decision.ACQUIRE + end + if domain.MacroState.new(macro_state).executing then + return M.Decision.ACQUIRE + end + return M.Decision.REPEAT +end + +RepeatResolver.eligibility = RepeatResolver.decide +RepeatResolver.resolve_eligibility = RepeatResolver.decide + +function RepeatResolver:sample_repeat_timeout_ms() + local service = resolver_records[self].policy + if service == nil then + fail("RepeatResolver requires a policy to sample repeat timeout", 2) + end + local sampled = service:sample_timeouts() + local timeout = type(sampled) == "table" and sampled.repeat_timeout_ms or nil + if not is_nonnegative_integer(timeout) then + fail("repeat_timeout_ms sample must be a nonnegative integer", 2) + end + return timeout +end + +RepeatResolver.sample_repeat_timeout = RepeatResolver.sample_repeat_timeout_ms + +function RepeatResolver:evaluate_timeout(current_window) + local timeout = self:sample_repeat_timeout_ms() + if timeout == 0 then + return M.Decision.REPEAT, nil + end + + local clock = resolver_records[self].clock + if clock == nil then + fail("RepeatResolver requires a clock for positive repeat timeout", 2) + end + local current_time = clock:read_time_ms() + if not finite_time(current_time) then + fail("repeat clock must return finite milliseconds", 2) + end + local record = resolver_records[self] + local elapsed_ms = M.truncate_elapsed_ms( + current_time - record.state.repeat_timestamp_ms + ) + record.transitions:SetRepeatTimestamp(current_time) + if elapsed_ms <= timeout then + return M.Decision.REPEAT, elapsed_ms + end + local cleanup = record.transitions:PublicReset(current_window) + return M.Decision.ACQUIRE, elapsed_ms, cleanup +end + +RepeatResolver.check_timeout = RepeatResolver.evaluate_timeout +RepeatResolver.resolve_timeout = RepeatResolver.evaluate_timeout + +local function sampled_fixed_direction(resolver) + local service = resolver_records[resolver].policy + if service == nil then + fail("RepeatResolver requires a policy to resolve primary direction", 3) + end + local sampled = service:sample_direction() + local fixed + if type(sampled) == "table" then + fixed = sampled.fix_key_direction + end + if type(fixed) ~= "boolean" then + fail("fix_key_direction sample must be a Boolean", 3) + end + return fixed +end + +function M.reverse_request(stored_descriptor, pressed_key, fix_key_direction) + local stored = domain.Descriptor.from_string(stored_descriptor) + local pressed = domain.Descriptor.from_string(pressed_key) + if type(fix_key_direction) ~= "boolean" then + fail("fix_key_direction must be a Boolean", 2) + end + + local reverse = domain.Descriptor.is_uppercase(pressed) + if fix_key_direction and domain.Descriptor.is_uppercase(stored) then + reverse = not reverse + end + return reverse +end + +function M.primary_direction(stored_descriptor, pressed_key, fix_key_direction) + local stored = domain.Descriptor.from_string(stored_descriptor) + if M.reverse_request(stored, pressed_key, fix_key_direction) then + return domain.Descriptor.swap(stored) + end + return stored +end + +M.resolve_primary_direction = M.primary_direction +M.effective_primary_descriptor = M.primary_direction + +function RepeatResolver:resolve_primary_direction(stored_descriptor, pressed_key) + return M.primary_direction( + stored_descriptor, + pressed_key, + sampled_fixed_direction(self) + ) +end + +RepeatResolver.primary_direction = RepeatResolver.resolve_primary_direction +RepeatResolver.effective_primary_descriptor = + RepeatResolver.resolve_primary_direction + +function M.explicit_target(stored_target) + if stored_target == nil then + return domain.TargetValue.code_fallback(0) + end + return stored_target +end + +local function build_explicit_request(descriptor, stored_target) + if descriptor == nil then + return domain.ExplicitRepeatRequest.neutral() + end + local target = M.explicit_target(stored_target) + if target.first_code == 0x80 then + return domain.ExplicitRepeatRequest.neutral() + end + return domain.ExplicitRepeatRequest.new(descriptor, target) +end + +function M.build_same_direction_request(stored_descriptor, stored_target) + return build_explicit_request(stored_descriptor, stored_target) +end + +M.explicit_same_direction = M.build_same_direction_request +M.same_direction_request = M.build_same_direction_request + +function RepeatResolver:same_direction_request(context) + context = domain.ModeContext.from_full_mode(context) + local state = resolver_records[self].state + return M.build_same_direction_request( + state:get_previous_descriptor(context), + state:get_previous_target(context) + ) +end + +RepeatResolver.resolve_explicit_same = RepeatResolver.same_direction_request +RepeatResolver.explicit_same = RepeatResolver.same_direction_request + +function M.build_opposite_direction_request(stored_descriptor, stored_target) + if stored_descriptor == nil then + return domain.ExplicitRepeatRequest.neutral() + end + return build_explicit_request( + domain.Descriptor.swap(stored_descriptor), + stored_target + ) +end + +M.explicit_opposite_direction = M.build_opposite_direction_request +M.opposite_direction_request = M.build_opposite_direction_request + +function RepeatResolver:opposite_direction_request(context) + context = domain.ModeContext.from_full_mode(context) + local state = resolver_records[self].state + return M.build_opposite_direction_request( + state:get_previous_descriptor(context), + state:get_previous_target(context) + ) +end + +RepeatResolver.resolve_explicit_opposite = + RepeatResolver.opposite_direction_request +RepeatResolver.explicit_opposite = RepeatResolver.opposite_direction_request + +function M.new(options) + return RepeatResolver.new(options) +end + +M.landing = function(context, options) + return RepeatResolver.new(options):previous_landing(context) +end + +function M.decide(context, current_position, macro_state, options) + return RepeatResolver.new(options):decide( + context, + current_position, + macro_state + ) +end + +M.eligibility = M.decide + +function M.sample_repeat_timeout_ms(options) + return RepeatResolver.new(options):sample_repeat_timeout_ms() +end + +function M.evaluate_timeout(options, current_window) + return RepeatResolver.new(options):evaluate_timeout(current_window) +end + +setmetatable(M, { + __call = function(_, options) + return RepeatResolver.new(options) + end, +}) + +return M |
