summaryrefslogtreecommitdiff
path: root/lua/clever_tee/state_transitions.lua
diff options
context:
space:
mode:
authorJackson Moore <jacksonmoore@tuta.io>2026-09-04 18:44:32 +0200
committerJackson Moore <jacksonmoore@tuta.io>2026-09-04 18:44:32 +0200
commitc7b14ffb6d14969c9c41864e827f33f8e80fc24e (patch)
treec7c0ac23dc93a4a8b75449d2be4d0e05c4f05f9e /lua/clever_tee/state_transitions.lua
parent9013636a57144e1f57c9339e7888d588430aae6a (diff)
Rename plugin to clever-tee
Diffstat (limited to 'lua/clever_tee/state_transitions.lua')
-rw-r--r--lua/clever_tee/state_transitions.lua493
1 files changed, 493 insertions, 0 deletions
diff --git a/lua/clever_tee/state_transitions.lua b/lua/clever_tee/state_transitions.lua
new file mode 100644
index 0000000..377f04f
--- /dev/null
+++ b/lua/clever_tee/state_transitions.lua
@@ -0,0 +1,493 @@
+local domain = require("clever_tee.domain")
+local sequence_state = require("clever_tee.sequence_state")
+
+local M = {}
+local StateTransitions = {}
+StateTransitions.__index = StateTransitions
+M.StateTransitions = StateTransitions
+
+local function fail(message, level)
+ error(message, (level or 1) + 1)
+end
+
+local function is_integer(value)
+ return type(value) == "number"
+ and value > -math.huge
+ and value < math.huge
+ and value == math.floor(value)
+end
+
+local function require_time(value, name)
+ if type(value) ~= "number"
+ or value ~= value
+ or value <= -math.huge
+ or value >= math.huge
+ then
+ fail((name or "time") .. " must be a finite number", 2)
+ end
+ return value
+end
+
+local function require_identity(identity, name)
+ if identity == nil then
+ fail((name or "resource identity") .. " must be active", 2)
+ end
+ return identity
+end
+
+local function require_location(location, name)
+ if location == nil then
+ fail((name or "resource location") .. " must identify its host location", 2)
+ end
+ return location
+end
+
+local function require_target(target)
+ if not domain.TargetValue.is(target) then
+ fail("acquired target must be a TargetValue", 2)
+ end
+ return target
+end
+
+local function require_position(position)
+ return domain.Position.coerce(position)
+end
+
+local function moved_forward_value(direction)
+ if type(direction) == "boolean" then
+ return direction
+ end
+ if domain.Direction.is(direction) then
+ return direction == domain.Direction.FORWARD
+ end
+ if domain.Descriptor.is(direction) then
+ return direction.direction == domain.Direction.FORWARD
+ end
+ if direction == "forward" or direction == "backward" then
+ return direction == "forward"
+ end
+ fail("movement direction must be a Boolean, Direction, or Descriptor", 2)
+end
+
+local function copy_resource(resource)
+ local result = {}
+ for key, value in pairs(resource) do
+ result[key] = value
+ end
+ return result
+end
+
+local function resource_matches(resource, identity, location_field, location)
+ return resource.identity == identity
+ and (location == nil or resource[location_field] == location)
+end
+
+local function add_unique_resource(resources, resource, location_field, field_name)
+ for _, active in ipairs(resources) do
+ if resource_matches(
+ active,
+ resource.identity,
+ location_field,
+ resource[location_field]
+ ) then
+ fail(field_name .. " resource is already active at this host location", 3)
+ end
+ end
+ resources[#resources + 1] = resource
+ return copy_resource(resource)
+end
+
+local function remove_resources(data, field, predicate)
+ local removed = {}
+ local retained = {}
+ for _, resource in ipairs(data[field]) do
+ if predicate(resource) then
+ removed[#removed + 1] = copy_resource(resource)
+ else
+ retained[#retained + 1] = resource
+ end
+ end
+ data[field] = retained
+ return removed
+end
+
+local function clear_target_overlays(data, window)
+ return remove_resources(data, "target_overlays", function(resource)
+ return window == nil or resource.window == window
+ end)
+end
+
+local function clear_temporary_overlays(data, window)
+ return remove_resources(data, "temporary_overlays", function(resource)
+ return window == nil or resource.window == window
+ end)
+end
+
+local function clear_finalizers(data, buffer)
+ return remove_resources(data, "finalizers", function(resource)
+ return buffer == nil or resource.buffer == buffer
+ end)
+end
+
+local function clear_highlight_timer(data)
+ local identity = data.highlight_timer
+ data.highlight_timer = nil
+ return identity
+end
+
+local function clear_all_landings_and_direction(data)
+ data.previous_landing = {}
+ data.moved_forward = false
+end
+
+local function public_reset(data, current_window)
+ local cleanup = {
+ highlight_timer = clear_highlight_timer(data),
+ target_overlays = clear_target_overlays(data, current_window),
+ finalizers = {},
+ temporary_overlays = {},
+ }
+ data.previous_descriptor = {}
+ data.previous_landing = {}
+ data.first_move = {}
+ data.migemo_cache = {}
+ data.repeat_timestamp_ms = 0
+ return cleanup
+end
+
+function StateTransitions.new(state)
+ state = state or sequence_state.get()
+ if not sequence_state.is(state) then
+ fail("StateTransitions requires the plugin-global SequenceState", 2)
+ end
+ return setmetatable({ _state = state }, StateTransitions)
+end
+
+function M.new(state)
+ return StateTransitions.new(state)
+end
+
+setmetatable(M, {
+ __call = function(_, state)
+ return StateTransitions.new(state)
+ end,
+})
+
+function StateTransitions:state()
+ return self._state
+end
+
+function StateTransitions:_mutate(mutation)
+ return sequence_state._mutate(self._state, mutation)
+end
+
+function StateTransitions:BeginAcquisition(context, descriptor)
+ context = sequence_state._normalize_context(context)
+ descriptor = domain.Descriptor.from_string(descriptor)
+ return self:_mutate(function(state)
+ state.known_contexts[context] = true
+ state.previous_descriptor[context] = descriptor
+ state.first_move[context] = true
+ return self._state:get_context(context)
+ end)
+end
+
+function StateTransitions:CommitAcquiredTarget(context, target, time_ms)
+ context = sequence_state._normalize_context(context)
+ target = require_target(target)
+ if time_ms ~= nil then
+ time_ms = require_time(time_ms, "acquisition time")
+ end
+ return self:_mutate(function(state)
+ state.known_contexts[context] = true
+ state.previous_target[context] = target
+ state.last_input_context = context
+ if time_ms ~= nil then
+ state.repeat_timestamp_ms = time_ms
+ end
+ return self._state:get_context(context)
+ end)
+end
+
+function StateTransitions:CommitCommandSuccess(context, destination, direction)
+ context = sequence_state._normalize_context(context)
+ destination = require_position(destination)
+ local forward = moved_forward_value(direction)
+ return self:_mutate(function(state)
+ state.known_contexts[context] = true
+ state.moved_forward = forward
+ state.moved_forward_initialized = true
+ state.previous_landing[context] = destination
+ state.first_move[context] = false
+ return self._state:get_context(context)
+ end)
+end
+
+function StateTransitions:CommitVisualSuccess(context, destination)
+ context = sequence_state._normalize_context(context)
+ destination = require_position(destination)
+ return self:_mutate(function(state)
+ state.known_contexts[context] = true
+ state.previous_landing[context] = destination
+ state.first_move[context] = false
+ return self._state:get_context(context)
+ end)
+end
+
+function StateTransitions:ClearAllLandingsAndDirection()
+ return self:_mutate(function(state)
+ clear_all_landings_and_direction(state)
+ end)
+end
+
+function StateTransitions:SetRepeatTimestamp(time_ms)
+ time_ms = require_time(time_ms, "repeat timestamp")
+ return self:_mutate(function(state)
+ local previous = state.repeat_timestamp_ms
+ state.repeat_timestamp_ms = time_ms
+ return previous
+ end)
+end
+
+function StateTransitions:CacheMigemo(encoding, dictionary)
+ if type(encoding) ~= "string" or encoding == "" then
+ fail("Migemo cache encoding must be a nonempty string", 2)
+ end
+ if dictionary == nil then
+ fail("Migemo cache dictionary must be present", 2)
+ end
+ return self:_mutate(function(state)
+ local previous = state.migemo_cache[encoding]
+ state.migemo_cache[encoding] = dictionary
+ return previous
+ end)
+end
+
+function StateTransitions:RemoveMigemo(encoding)
+ if type(encoding) ~= "string" or encoding == "" then
+ fail("Migemo cache encoding must be a nonempty string", 2)
+ end
+ return self:_mutate(function(state)
+ local previous = state.migemo_cache[encoding]
+ state.migemo_cache[encoding] = nil
+ return previous
+ end)
+end
+
+function StateTransitions:ClearMigemoCache()
+ return self:_mutate(function(state)
+ local previous = state.migemo_cache
+ state.migemo_cache = {}
+ return previous
+ end)
+end
+
+function StateTransitions:SetHighlightTimer(identity)
+ return self:_mutate(function(state)
+ local previous = state.highlight_timer
+ state.highlight_timer = identity
+ return previous
+ end)
+end
+
+function StateTransitions:ClearHighlightTimer(expected_identity)
+ return self:_mutate(function(state)
+ local current = state.highlight_timer
+ if current == nil then
+ return nil, false
+ end
+ if expected_identity ~= nil and current ~= expected_identity then
+ return nil, false
+ end
+ state.highlight_timer = nil
+ return current, true
+ end)
+end
+
+function StateTransitions:AddTargetOverlay(identity, window, anchor_line)
+ if type(identity) == "table" and window == nil and identity.identity ~= nil then
+ local resource = identity
+ identity = resource.identity
+ window = resource.window
+ anchor_line = resource.anchor_line
+ end
+ require_identity(identity, "target overlay identity")
+ require_location(window, "target overlay window")
+ if anchor_line ~= nil and (not is_integer(anchor_line) or anchor_line < 1) then
+ fail("target overlay anchor_line must be a positive integer", 2)
+ end
+ local resource = {
+ identity = identity,
+ window = window,
+ group = "CleverTeeChar",
+ anchor_line = anchor_line,
+ }
+ return self:_mutate(function(state)
+ return add_unique_resource(
+ state.target_overlays,
+ resource,
+ "window",
+ "target overlay"
+ )
+ end)
+end
+
+function StateTransitions:RemoveTargetOverlay(identity, window)
+ require_identity(identity, "target overlay identity")
+ return self:_mutate(function(state)
+ return remove_resources(state, "target_overlays", function(resource)
+ return resource_matches(resource, identity, "window", window)
+ end)
+ end)
+end
+
+function StateTransitions:ClearTargetOverlays(window)
+ return self:_mutate(function(state)
+ return clear_target_overlays(state, window)
+ end)
+end
+
+function StateTransitions:AddTemporaryOverlay(identity, window, group)
+ if type(identity) == "table" and window == nil and identity.identity ~= nil then
+ local resource = identity
+ identity = resource.identity
+ window = resource.window
+ group = resource.group
+ end
+ require_identity(identity, "temporary overlay identity")
+ require_location(window, "temporary overlay window")
+ group = group or "CleverTeeCursor"
+ if group ~= "CleverTeeCursor" and group ~= "CleverTeeDirect" then
+ fail("temporary overlay group must be CleverTeeCursor or CleverTeeDirect", 2)
+ end
+ local resource = {
+ identity = identity,
+ window = window,
+ group = group,
+ }
+ return self:_mutate(function(state)
+ return add_unique_resource(
+ state.temporary_overlays,
+ resource,
+ "window",
+ "temporary overlay"
+ )
+ end)
+end
+
+function StateTransitions:RemoveTemporaryOverlay(identity, window)
+ require_identity(identity, "temporary overlay identity")
+ return self:_mutate(function(state)
+ return remove_resources(state, "temporary_overlays", function(resource)
+ return resource_matches(resource, identity, "window", window)
+ end)
+ end)
+end
+
+function StateTransitions:ClearTemporaryOverlays(window)
+ return self:_mutate(function(state)
+ return clear_temporary_overlays(state, window)
+ end)
+end
+
+function StateTransitions:AddFinalizer(identity, buffer)
+ if type(identity) == "table" and buffer == nil and identity.identity ~= nil then
+ local resource = identity
+ identity = resource.identity
+ buffer = resource.buffer
+ end
+ require_identity(identity, "finalizer identity")
+ require_location(buffer, "finalizer buffer")
+ local resource = {
+ identity = identity,
+ buffer = buffer,
+ }
+ return self:_mutate(function(state)
+ return add_unique_resource(state.finalizers, resource, "buffer", "finalizer")
+ end)
+end
+
+function StateTransitions:RemoveFinalizer(identity, buffer)
+ require_identity(identity, "finalizer identity")
+ return self:_mutate(function(state)
+ return remove_resources(state, "finalizers", function(resource)
+ return resource_matches(resource, identity, "buffer", buffer)
+ end)
+ end)
+end
+
+function StateTransitions:ClearFinalizers(buffer)
+ return self:_mutate(function(state)
+ return clear_finalizers(state, buffer)
+ end)
+end
+
+function StateTransitions:ClearTargetFeedback(current_window)
+ return self:_mutate(function(state)
+ return {
+ highlight_timer = clear_highlight_timer(state),
+ target_overlays = clear_target_overlays(state, current_window),
+ finalizers = {},
+ temporary_overlays = {},
+ }
+ end)
+end
+
+function StateTransitions:FullFinalization(current_window)
+ return self:_mutate(function(state)
+ local cleanup = {
+ highlight_timer = clear_highlight_timer(state),
+ target_overlays = clear_target_overlays(state, current_window),
+ finalizers = clear_finalizers(state),
+ temporary_overlays = {},
+ }
+ clear_all_landings_and_direction(state)
+ return cleanup
+ end)
+end
+
+function StateTransitions:PublicReset(current_window)
+ return self:_mutate(function(state)
+ return public_reset(state, current_window)
+ end)
+end
+
+function StateTransitions:DiagnosticFullReset(current_window)
+ return self:_mutate(function(state)
+ local cleanup = public_reset(state, current_window)
+ cleanup.finalizers = clear_finalizers(state)
+ state.previous_target = {}
+ state.last_input_context = nil
+ state.moved_forward = false
+ state.moved_forward_initialized = false
+ return cleanup
+ end)
+end
+
+StateTransitions.begin_acquisition = StateTransitions.BeginAcquisition
+StateTransitions.commit_acquired_target = StateTransitions.CommitAcquiredTarget
+StateTransitions.commit_command_success = StateTransitions.CommitCommandSuccess
+StateTransitions.commit_visual_success = StateTransitions.CommitVisualSuccess
+StateTransitions.clear_all_landings_and_direction =
+ StateTransitions.ClearAllLandingsAndDirection
+StateTransitions.set_repeat_timestamp = StateTransitions.SetRepeatTimestamp
+StateTransitions.cache_migemo = StateTransitions.CacheMigemo
+StateTransitions.remove_migemo = StateTransitions.RemoveMigemo
+StateTransitions.clear_migemo_cache = StateTransitions.ClearMigemoCache
+StateTransitions.set_highlight_timer = StateTransitions.SetHighlightTimer
+StateTransitions.clear_highlight_timer = StateTransitions.ClearHighlightTimer
+StateTransitions.add_target_overlay = StateTransitions.AddTargetOverlay
+StateTransitions.remove_target_overlay = StateTransitions.RemoveTargetOverlay
+StateTransitions.clear_target_overlays = StateTransitions.ClearTargetOverlays
+StateTransitions.add_temporary_overlay = StateTransitions.AddTemporaryOverlay
+StateTransitions.remove_temporary_overlay = StateTransitions.RemoveTemporaryOverlay
+StateTransitions.clear_temporary_overlays = StateTransitions.ClearTemporaryOverlays
+StateTransitions.add_finalizer = StateTransitions.AddFinalizer
+StateTransitions.remove_finalizer = StateTransitions.RemoveFinalizer
+StateTransitions.clear_finalizers = StateTransitions.ClearFinalizers
+StateTransitions.clear_target_feedback = StateTransitions.ClearTargetFeedback
+StateTransitions.full_finalization = StateTransitions.FullFinalization
+StateTransitions.public_reset = StateTransitions.PublicReset
+StateTransitions.diagnostic_full_reset = StateTransitions.DiagnosticFullReset
+
+return M