local destination_engine = require("clever_tee.destination_engine") local domain = require("clever_tee.domain") local sequence_state = require("clever_tee.sequence_state") local state_transitions = require("clever_tee.state_transitions") local text_topology = require("clever_tee.text_topology") local M = {} local MotionExecutor = {} M.MotionExecutor = MotionExecutor M.ExecutionPath = { VISUAL = "visual", COMMAND = "command", } local executor_records = setmetatable({}, { __mode = "k" }) local function fail(message, level) error(message, (level or 1) + 1) end function M.execution_path(context) context = domain.ModeContext.from_full_mode(context) if context.visual_kind ~= nil then return M.ExecutionPath.VISUAL end return M.ExecutionPath.COMMAND end function M.moved_forward(origin, destination) origin = domain.Position.coerce(origin) destination = domain.Position.coerce(destination) return domain.Position.compare(destination, origin) > 0 end function M.command_moved_forward(descriptor, origin, destination) descriptor = domain.Descriptor.from_string(descriptor) origin = domain.Position.coerce(origin) destination = domain.Position.coerce(destination) if descriptor.family == domain.Family.TILL and domain.Position.stationary(origin, destination) then return false end return M.moved_forward(origin, destination) end function M.create_dot_payload(plan) if not domain.ResolvedMotionPlan.is(plan) then fail("dot payload plan must be a ResolvedMotionPlan", 2) end return domain.DotPayload.new(plan.descriptor, plan.target_plan.target) end function M.plan_for_dot_payload(plan, payload) if not domain.ResolvedMotionPlan.is(plan) then fail("dot replay plan must be a ResolvedMotionPlan", 2) end if not domain.DotPayload.is(payload) then fail("dot replay payload must be a DotPayload", 2) end if payload.target ~= plan.target_plan.target then fail("dot replay payload target must match its resolved target plan", 2) end return domain.ResolvedMotionPlan.new({ target_plan = plan.target_plan, descriptor = payload.descriptor, search_scope = plan.search_scope, endpoint_policy = plan.endpoint_policy, }) end local function copy_options(options) local result = {} for key, value in pairs(options or {}) do result[key] = value end return result end local function normalize_options(options, dependencies) if MotionExecutor.is(options) and dependencies == nil then return options end if type(options) ~= "table" then fail("MotionExecutor options must be a table", 3) end if options.host ~= nil then if dependencies ~= nil then fail("MotionExecutor dependencies must be part of its options", 3) end return options end local result = copy_options(dependencies) result.host = options return result end local function require_host(host) if type(host) ~= "table" or type(host.read_cursor) ~= "function" or type(host.read_encoding) ~= "function" or type(host.read_pending_operator) ~= "function" or type(host.read_selection) ~= "function" or type(host.read_text) ~= "function" or type(host.apply_cursor) ~= "function" or type(host.apply_selection) ~= "function" or type(host.set_operator_inclusive) ~= "function" or type(host.register_dot_repeat) ~= "function" then fail("MotionExecutor host must provide movement state", 3) end return host end local function require_destination_engine(engine) engine = engine or destination_engine.new() if type(engine) ~= "table" or type(engine.calculate) ~= "function" then fail("MotionExecutor destination engine must provide calculate", 3) end return engine end local function require_feedback_service(service) if service ~= nil and (type(service) ~= "table" or type(service.migrate_command) ~= "function") then fail("MotionExecutor feedback service must provide migrate_command", 3) end return service end local function require_state(state) state = state or sequence_state.get() if not sequence_state.is(state) then fail("MotionExecutor state must be the plugin-global SequenceState", 3) end return state end local function require_transitions(transitions, state) transitions = transitions or state_transitions.new(state) if type(transitions) ~= "table" or type(transitions.CommitCommandSuccess) ~= "function" or type(transitions.CommitVisualSuccess) ~= "function" then fail("MotionExecutor transitions must commit motion success", 3) end return transitions end local executor_metatable = { __index = MotionExecutor, __newindex = function() fail("MotionExecutor values are immutable", 2) end, __tostring = function() return "motion-executor" end, __metatable = "clever_tee.motion_executor.MotionExecutor", } function MotionExecutor.new(options, dependencies) options = normalize_options(options, dependencies) if MotionExecutor.is(options) then return options end local executor = setmetatable({}, executor_metatable) local state = require_state(options.state) executor_records[executor] = { host = require_host(options.host), destination_engine = require_destination_engine( options.destination_engine or options.engine ), feedback_service = require_feedback_service( options.feedback_service or options.feedback ), state = state, transitions = require_transitions( options.transitions or options.state_transitions, state ), } return executor end function MotionExecutor.is(value) return type(value) == "table" and executor_records[value] ~= nil end local function execution_request( view, context, plan, count, first_move, execution_options ) if not text_topology.TextView.is(view) then fail("motion execution view must be a TextView", 3) end context = domain.ModeContext.from_full_mode(context) if not domain.ResolvedMotionPlan.is(plan) then fail("motion execution plan must be a ResolvedMotionPlan", 3) end count = domain.Count.new(count) if type(first_move) ~= "boolean" then fail("motion execution first_move must be a Boolean", 3) end execution_options = execution_options or {} if type(execution_options) ~= "table" then fail("motion execution options must be a table", 3) end local dot_payload = execution_options.dot_payload if dot_payload ~= nil and not domain.DotPayload.is(dot_payload) then fail("motion execution dot_payload must be a DotPayload", 3) end local register_dot_repeat = execution_options.register_dot_repeat if register_dot_repeat == nil then register_dot_repeat = true elseif type(register_dot_repeat) ~= "boolean" then fail("motion execution register_dot_repeat must be a Boolean", 3) end return { view = view, context = context, plan = plan, count = count, first_move = first_move, dot_payload = dot_payload, register_dot_repeat = register_dot_repeat, } end local function calculate(executor, request, origin) return executor_records[executor].destination_engine:calculate( request.view, origin, request.plan, request.count, request.first_move ) end local function command_action( host, outcome, descriptor, dot_payload, use_current_position ) return domain.ActionOutcome.new({ kind = outcome.complete and domain.ActionKind.MOVEMENT or domain.ActionKind.FAILED_SEARCH, position = use_current_position and host:read_cursor() or outcome.endpoint, search_outcome = outcome, effective_descriptor = descriptor, dot_payload = dot_payload, }) end local function register_dot_replay(executor, request, payload) local host = executor_records[executor].host host:register_dot_repeat(payload, function(replayed_payload, replay_count) return executor:execute_dot( text_topology.from_host(host), request.context, request.plan, replayed_payload, replay_count ) end) end local function migrate_command_feedback(executor, request, origin, outcome) local record = executor_records[executor] local feedback = record.feedback_service if feedback == nil then return end feedback:migrate_command({ context = request.context, origin = origin, destination = outcome.endpoint, plan = request.plan, resolved_motion_plan = request.plan, outcome = outcome, count = request.count, first_move = request.first_move, moved_forward = request.moved_forward, previous_moved_forward = record.state.moved_forward, previous_moved_forward_initialized = record.state.moved_forward_initialized, }) end function MotionExecutor:_execute_command(request) local host = executor_records[self].host local origin = host:read_cursor() local pending_operator = request.context.operator and host:read_pending_operator() or nil local outcome = calculate(self, request, origin) if outcome.successful_steps > 0 then if request.context.operator and request.plan.descriptor.direction == domain.Direction.FORWARD then host:set_operator_inclusive(true) end host:apply_cursor(outcome.endpoint, { context = request.context, descriptor = request.plan.descriptor, origin = origin, }) end if not outcome.complete then return command_action( host, outcome, request.plan.descriptor, nil, pending_operator ~= nil and pending_operator ~= "" ) end request.moved_forward = M.command_moved_forward( request.plan.descriptor, origin, outcome.endpoint ) migrate_command_feedback(self, request, origin, outcome) executor_records[self].transitions:CommitCommandSuccess( request.context, outcome.endpoint, request.moved_forward ) local dot_payload if pending_operator ~= nil and pending_operator ~= "" then dot_payload = request.dot_payload or M.create_dot_payload(request.plan) if request.register_dot_repeat then register_dot_replay(self, request, dot_payload) end end return command_action( host, outcome, request.plan.descriptor, dot_payload, pending_operator ~= nil and pending_operator ~= "" ) end function MotionExecutor:_execute_visual(request) local host = executor_records[self].host local selection = host:read_selection() if not domain.Selection.is(selection) or not selection.active or selection.kind ~= request.context.visual_kind then fail("Visual motion execution requires its active selection kind", 2) end local origin = host:read_cursor() local outcome = calculate(self, request, origin) request.selection = selection if outcome.successful_steps > 0 then host:apply_selection(selection:with_focus(outcome.endpoint)) end if not outcome.complete then return domain.ActionOutcome.from_search(outcome, request.plan.descriptor) end executor_records[self].transitions:CommitVisualSuccess( request.context, outcome.endpoint ) return domain.ActionOutcome.from_search(outcome, request.plan.descriptor) end function MotionExecutor:execute( view, context, plan, count, first_move, execution_options ) local request = execution_request( view, context, plan, count, first_move, execution_options ) if M.execution_path(request.context) == M.ExecutionPath.VISUAL then return self:_execute_visual(request) end return self:_execute_command(request) end function MotionExecutor:execute_dot(view, context, plan, payload, count) return self:execute( view, context, M.plan_for_dot_payload(plan, payload), count, false, { dot_payload = payload, register_dot_repeat = false, } ) end function M.new(options, dependencies) return MotionExecutor.new(options, dependencies) end function M.execute(host, view, context, plan, count, first_move, dependencies) return MotionExecutor.new(host, dependencies):execute( view, context, plan, count, first_move ) end M.run = M.execute setmetatable(M, { __call = function(_, options, dependencies) return MotionExecutor.new(options, dependencies) end, }) return M