summaryrefslogtreecommitdiff
path: root/lua/clever_tee/feedback_service.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/feedback_service.lua
parent9013636a57144e1f57c9339e7888d588430aae6a (diff)
Rename plugin to clever-tee
Diffstat (limited to 'lua/clever_tee/feedback_service.lua')
-rw-r--r--lua/clever_tee/feedback_service.lua1003
1 files changed, 1003 insertions, 0 deletions
diff --git a/lua/clever_tee/feedback_service.lua b/lua/clever_tee/feedback_service.lua
new file mode 100644
index 0000000..b99fe04
--- /dev/null
+++ b/lua/clever_tee/feedback_service.lua
@@ -0,0 +1,1003 @@
+local domain = require("clever_tee.domain")
+local policy = require("clever_tee.policy")
+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 FeedbackService = {}
+FeedbackService.__index = FeedbackService
+M.FeedbackService = FeedbackService
+local CursorPresentationLease = {}
+M.CursorPresentationLease = CursorPresentationLease
+
+M.DEFAULT_LABEL_GROUP = "CleverTeeDefaultLabel"
+M.Priority = {
+ HIGH = "high",
+ ORDINARY = "ordinary",
+}
+M.FINALIZER_EVENTS = {
+ "CursorMoved",
+ "InsertEnter",
+ "TextChanged",
+}
+M.EAGER_EVENTS = {
+ "WinEnter",
+ "WinLeave",
+ "CmdwinLeave",
+}
+M.FinalizerAction = {
+ PRESERVE = "preserve",
+ FINALIZE = "finalize",
+}
+M.MigrationReason = {
+ LINE_CHANGE = "line_change",
+ TILL_DIRECTION_CHANGE = "till_direction_change",
+}
+
+local OVERLAY_PRIORITIES = {
+ CleverTeeCursor = M.Priority.HIGH,
+ CleverTeeChar = M.Priority.HIGH,
+ CleverTeeDirect = M.Priority.ORDINARY,
+}
+
+local service_records = setmetatable({}, { __mode = "k" })
+local cursor_lease_records = setmetatable({}, { __mode = "k" })
+local temporary_release_records = setmetatable({}, { __mode = "k" })
+
+local FEATURE_GROUPS = {
+ "CleverTeeCursor",
+ "CleverTeeChar",
+ "CleverTeeDirect",
+}
+
+local LEGACY_NORMAL_EX_CONTEXTS = {
+ cv = true,
+ cvr = true,
+}
+
+local DIRECT_FINALIZER_EVENTS = {
+ InsertEnter = true,
+ TextChanged = true,
+}
+
+local EAGER_EVENT_SET = {}
+for _, event_name in ipairs(M.EAGER_EVENTS) do
+ EAGER_EVENT_SET[event_name] = true
+end
+
+local DEFAULT_LABEL_DEFINITION = {
+ guifg = "red",
+ guibg = "NONE",
+ gui = {
+ bold = true,
+ underline = true,
+ },
+ ctermfg = "red",
+ ctermbg = "NONE",
+ cterm = {
+ bold = true,
+ underline = true,
+ },
+}
+
+local function copy(value)
+ if type(value) ~= "table" then
+ return value
+ end
+ local result = {}
+ for key, item in pairs(value) do
+ result[key] = copy(item)
+ end
+ return result
+end
+
+function M.default_label_definition()
+ return copy(DEFAULT_LABEL_DEFINITION)
+end
+
+function M.overlay_priority(group)
+ local priority = OVERLAY_PRIORITIES[group]
+ if priority == nil then
+ error("unknown feedback overlay group '" .. tostring(group) .. "'", 2)
+ end
+ return priority
+end
+
+local function fail(message, level)
+ error(message, (level or 1) + 1)
+end
+
+local function normalize_options(options)
+ if type(options) ~= "table" then
+ fail("FeedbackService options must be a table", 3)
+ end
+ if options.host == nil then
+ return { host = options }
+ end
+ return options
+end
+
+local function require_host(host)
+ if type(host) ~= "table"
+ or type(host.read_highlight_group) ~= "function"
+ or type(host.define_highlight_group) ~= "function"
+ or type(host.create_highlight) ~= "function"
+ or type(host.remove_highlight) ~= "function"
+ or type(host.read_buffer) ~= "function"
+ or type(host.read_cursor) ~= "function"
+ or type(host.read_window) ~= "function"
+ or type(host.register_events) ~= "function"
+ or type(host.remove_event_registration) ~= "function"
+ or type(host.start_timer) ~= "function"
+ or type(host.stop_timer) ~= "function"
+ or type(host.supports_timers) ~= "function"
+ or type(host.supports_cursor_presentation) ~= "function"
+ or type(host.suppress_cursor_presentation) ~= "function"
+ or type(host.restore_cursor_presentation) ~= "function"
+ then
+ fail("FeedbackService host must provide highlight groups", 3)
+ end
+ return host
+end
+
+local function require_transitions(transitions, state)
+ transitions = transitions or state_transitions.new(state)
+ if type(transitions) ~= "table"
+ or type(transitions.AddTemporaryOverlay) ~= "function"
+ or type(transitions.RemoveTemporaryOverlay) ~= "function"
+ or type(transitions.AddTargetOverlay) ~= "function"
+ or type(transitions.ClearTargetOverlays) ~= "function"
+ or type(transitions.AddFinalizer) ~= "function"
+ or type(transitions.RemoveFinalizer) ~= "function"
+ or type(transitions.FullFinalization) ~= "function"
+ or type(transitions.ClearTargetFeedback) ~= "function"
+ or type(transitions.SetHighlightTimer) ~= "function"
+ or type(transitions.ClearHighlightTimer) ~= "function"
+ then
+ fail("FeedbackService transitions must manage overlay resources", 3)
+ end
+ return transitions
+end
+
+local function require_policy(service, host)
+ service = service or policy.new(host)
+ if type(service) ~= "table"
+ or type(service.evaluate_highlight_links) ~= "function"
+ or type(service.sample_acquisition) ~= "function"
+ or type(service.sample_markers) ~= "function"
+ or type(service.sample_timeouts) ~= "function"
+ or type(service.capture_activation) ~= "function"
+ then
+ fail("FeedbackService policy must evaluate highlight links", 3)
+ end
+ return service
+end
+
+function FeedbackService.new(options)
+ if FeedbackService.is(options) then
+ return options
+ end
+ options = normalize_options(options)
+ local service = setmetatable({}, FeedbackService)
+ local host = require_host(options.host)
+ local state = options.state or sequence_state.get()
+ if not sequence_state.is(state) then
+ fail("FeedbackService state must be the plugin-global SequenceState", 2)
+ end
+ service_records[service] = {
+ host = host,
+ policy = require_policy(options.policy or options.policy_service, host),
+ state = state,
+ transitions = require_transitions(
+ options.transitions or options.state_transitions,
+ state
+ ),
+ persistent_requests = {},
+ owned_finalizer = nil,
+ activation = nil,
+ eager_registration = nil,
+ last_eager_decision = nil,
+ }
+ return service
+end
+
+function FeedbackService.is(value)
+ return type(value) == "table" and service_records[value] ~= nil
+end
+
+local cursor_lease_metatable = {
+ __index = function(lease, key)
+ local method = CursorPresentationLease[key]
+ if method ~= nil then
+ return method
+ end
+ local record = cursor_lease_records[lease]
+ if key == "identity" then
+ return record.identity
+ end
+ if key == "active" then
+ return record.active
+ end
+ return nil
+ end,
+ __newindex = function()
+ fail("cursor presentation leases are read-only", 2)
+ end,
+ __metatable = "clever_tee.feedback_service.CursorPresentationLease",
+}
+
+local function new_cursor_presentation_lease(host, suppress)
+ local lease = setmetatable({}, cursor_lease_metatable)
+ local identity = suppress and host:suppress_cursor_presentation() or nil
+ cursor_lease_records[lease] = {
+ host = host,
+ identity = identity,
+ active = identity ~= nil,
+ }
+ return lease
+end
+
+function CursorPresentationLease.is(value)
+ return type(value) == "table" and cursor_lease_records[value] ~= nil
+end
+
+function CursorPresentationLease:release()
+ local record = cursor_lease_records[self]
+ if record == nil then
+ fail("cursor presentation lease is invalid", 2)
+ end
+ if not record.active then
+ return false
+ end
+ record.active = false
+ record.host:restore_cursor_presentation(record.identity)
+ return true
+end
+
+function FeedbackService:create_cursor_presentation_lease(enabled)
+ local record = service_records[self]
+ if enabled == nil then
+ enabled = record.policy:sample_acquisition().hide_cursor_on_cmdline
+ elseif type(enabled) ~= "boolean" then
+ fail("cursor presentation policy must be a Boolean", 2)
+ end
+ local supported = enabled and record.host:supports_cursor_presentation()
+ return new_cursor_presentation_lease(record.host, supported == true)
+end
+
+local function position_list(positions)
+ if type(positions) ~= "table" then
+ fail("direct marker positions must be a list", 3)
+ end
+ local result = {}
+ local item_count = 0
+ for key, position in pairs(positions) do
+ if type(key) ~= "number"
+ or key ~= math.floor(key)
+ or key < 1
+ or key > #positions
+ then
+ fail("direct marker positions must be a list", 3)
+ end
+ result[key] = domain.Position.coerce(position)
+ item_count = item_count + 1
+ end
+ if item_count ~= #positions then
+ fail("direct marker positions must be a list", 3)
+ end
+ return result
+end
+
+function FeedbackService:create_direct_markers(positions, window)
+ positions = position_list(positions)
+ if #positions == 0 then
+ return nil
+ end
+ if window == nil then
+ fail("direct marker window must identify its host window", 2)
+ end
+
+ local record = service_records[self]
+ local identity = record.host:create_highlight({
+ group = "CleverTeeDirect",
+ window = window,
+ positions = positions,
+ priority = M.overlay_priority("CleverTeeDirect"),
+ })
+ record.transitions:AddTemporaryOverlay(identity, window, "CleverTeeDirect")
+ local resource = {
+ identity = identity,
+ window = window,
+ group = "CleverTeeDirect",
+ positions = positions,
+ }
+ temporary_release_records[resource] = false
+ return resource
+end
+
+function FeedbackService:create_cursor_marker(position, window)
+ position = domain.Position.coerce(position)
+ if window == nil then
+ fail("cursor marker window must identify its host window", 2)
+ end
+
+ local record = service_records[self]
+ local identity = record.host:create_highlight({
+ group = "CleverTeeCursor",
+ window = window,
+ position = position,
+ priority = M.overlay_priority("CleverTeeCursor"),
+ })
+ record.transitions:AddTemporaryOverlay(identity, window, "CleverTeeCursor")
+ local resource = {
+ identity = identity,
+ window = window,
+ group = "CleverTeeCursor",
+ position = position,
+ }
+ temporary_release_records[resource] = false
+ return resource
+end
+
+function FeedbackService:remove_temporary_overlay(resource)
+ if type(resource) ~= "table" or resource.identity == nil then
+ fail("temporary overlay resource must identify its highlight", 2)
+ end
+ if temporary_release_records[resource] == true then
+ return false
+ end
+ temporary_release_records[resource] = true
+ local record = service_records[self]
+ local ok, removed = pcall(
+ record.host.remove_highlight,
+ record.host,
+ resource.identity
+ )
+ record.transitions:RemoveTemporaryOverlay(resource.identity, resource.window)
+ if not ok then
+ error(removed, 0)
+ end
+ return removed
+end
+
+function M.persistent_context_eligible(context)
+ context = domain.ModeContext.from_full_mode(context)
+ return context.key == "n"
+ or context.visual_kind ~= nil
+ or context.select_kind ~= nil
+ or LEGACY_NORMAL_EX_CONTEXTS[context.key] == true
+end
+
+function M.persistent_destination(
+ view,
+ target_position,
+ descriptor,
+ endpoint_policy
+)
+ if not text_topology.TextView.is(view) then
+ fail("persistent feedback requires a TextView", 2)
+ end
+ target_position = domain.Position.coerce(target_position)
+ descriptor = domain.Descriptor.from_string(descriptor)
+ endpoint_policy = domain.EndpointPolicy.from_string(endpoint_policy)
+
+ if endpoint_policy == domain.EndpointPolicy.VISUAL_EXCLUSIVE
+ and descriptor.direction == domain.Direction.FORWARD
+ then
+ if descriptor.family == domain.Family.FIND then
+ return view:successor(target_position)
+ end
+ return target_position
+ end
+ if descriptor.family == domain.Family.FIND then
+ return target_position
+ end
+ if descriptor.direction == domain.Direction.FORWARD then
+ return view:predecessor(target_position)
+ end
+ return view:successor(target_position)
+end
+
+function M.persistent_match_positions(
+ view,
+ match_start_line,
+ target_plan,
+ descriptor,
+ endpoint_policy
+)
+ if not text_topology.TextView.is(view) then
+ fail("persistent feedback requires a TextView", 2)
+ end
+ if not domain.TargetPlan.is(target_plan) then
+ fail("persistent feedback requires a TargetPlan", 2)
+ end
+ local positions = {}
+ local seen = {}
+ local candidates = view:iter_line_forward(match_start_line)
+ while true do
+ local position, character = candidates()
+ if position == nil then
+ break
+ end
+ if target_plan:matches(character, position, view) then
+ local destination = M.persistent_destination(
+ view,
+ position,
+ descriptor,
+ endpoint_policy
+ )
+ if destination ~= nil then
+ local key = tostring(destination.line)
+ .. ":"
+ .. tostring(destination.byte_column)
+ if not seen[key] then
+ seen[key] = true
+ positions[#positions + 1] = destination
+ end
+ end
+ end
+ end
+ return positions
+end
+
+function FeedbackService:build_persistent(specification)
+ if type(specification) ~= "table" then
+ fail("persistent feedback request must be a table", 2)
+ end
+ local context = domain.ModeContext.from_full_mode(specification.context)
+ if not M.persistent_context_eligible(context) then
+ fail("persistent feedback request requires an eligible context", 2)
+ end
+ if not domain.TargetPlan.is(specification.target_plan) then
+ fail("persistent feedback request requires a TargetPlan", 2)
+ end
+ if not domain.ResolvedMotionPlan.is(specification.motion_plan) then
+ fail("persistent feedback request requires a ResolvedMotionPlan", 2)
+ end
+ if specification.motion_plan.target_plan ~= specification.target_plan then
+ fail("persistent feedback must reuse the movement TargetPlan", 2)
+ end
+ local descriptor = domain.Descriptor.from_string(
+ specification.descriptor or specification.motion_plan.descriptor
+ )
+ local endpoint_policy = domain.EndpointPolicy.from_string(
+ specification.endpoint_policy or specification.motion_plan.endpoint_policy
+ )
+ local anchor = domain.Position.coerce(specification.anchor)
+ local view = specification.text_view
+ or text_topology.from_host(service_records[self].host)
+ return {
+ context = context,
+ anchor = anchor,
+ target_plan = specification.target_plan,
+ motion_plan = specification.motion_plan,
+ descriptor = descriptor,
+ endpoint_policy = endpoint_policy,
+ anchor_line = anchor.line,
+ match_start_line = anchor.line,
+ positions = M.persistent_match_positions(
+ view,
+ anchor.line,
+ specification.target_plan,
+ descriptor,
+ endpoint_policy
+ ),
+ text_view = view,
+ window = specification.window,
+ }
+end
+
+local function release_target_overlays(record, resources)
+ for _, resource in ipairs(resources) do
+ record.host:remove_highlight(resource.identity)
+ end
+end
+
+local function remove_target_overlays(record, window)
+ local resources = record.transitions:ClearTargetOverlays(window)
+ release_target_overlays(record, resources)
+ return resources
+end
+
+function FeedbackService:remove_character_overlays(window)
+ if window == nil then
+ fail("character overlay window must identify its host window", 2)
+ end
+ return remove_target_overlays(service_records[self], window)
+end
+
+function FeedbackService:cursor_moved_decision()
+ local record = service_records[self]
+ local context = record.state.last_input_context
+ local expected = context and record.state:get_previous_landing(context) or nil
+ local actual = record.host:read_cursor()
+ return {
+ context = context,
+ expected = expected,
+ actual = actual,
+ equal = expected ~= nil and domain.Position.equal(actual, expected),
+ }
+end
+
+local function release_finalizers(record, resources)
+ for _, resource in ipairs(resources) do
+ record.host:remove_event_registration(resource.identity)
+ end
+end
+
+local function release_highlight_timer(record, identity)
+ if identity == nil then
+ return false
+ end
+ return record.host:stop_timer(identity)
+end
+
+function FeedbackService:release_transition_cleanup(cleanup)
+ if type(cleanup) ~= "table" then
+ fail("feedback transition cleanup must be a table", 2)
+ end
+ local record = service_records[self]
+ release_finalizers(record, cleanup.finalizers or {})
+ release_highlight_timer(record, cleanup.highlight_timer)
+ release_target_overlays(record, cleanup.target_overlays or {})
+ if #(cleanup.finalizers or {}) > 0 then
+ record.owned_finalizer = nil
+ end
+ return cleanup
+end
+
+function FeedbackService:full_finalize(window)
+ local record = service_records[self]
+ window = window or record.host:read_window()
+ local cleanup = record.transitions:FullFinalization(window)
+ self:release_transition_cleanup(cleanup)
+ record.owned_finalizer = nil
+ return cleanup
+end
+
+function FeedbackService:handle_finalizer_event(name, payload)
+ if name == "CursorMoved" then
+ local decision = self:cursor_moved_decision()
+ if decision.equal then
+ decision.action = M.FinalizerAction.PRESERVE
+ else
+ decision.action = M.FinalizerAction.FINALIZE
+ decision.cleanup = self:full_finalize(payload and payload.window)
+ end
+ return decision
+ end
+ if DIRECT_FINALIZER_EVENTS[name] then
+ return {
+ action = M.FinalizerAction.FINALIZE,
+ cleanup = self:full_finalize(payload and payload.window),
+ }
+ end
+ return false
+end
+
+local function register_finalizers(service, record)
+ local buffer = record.host:read_buffer()
+ local owned = record.owned_finalizer
+ if owned ~= nil and owned.buffer == buffer then
+ return owned
+ end
+ if owned ~= nil then
+ record.host:remove_event_registration(owned.identity)
+ record.transitions:RemoveFinalizer(owned.identity, owned.buffer)
+ end
+
+ local identity = record.host:register_events(
+ M.FINALIZER_EVENTS,
+ function(name, payload)
+ service:handle_finalizer_event(name, payload)
+ end,
+ { buffer = buffer }
+ )
+ record.transitions:AddFinalizer(identity, buffer)
+ owned = {
+ identity = identity,
+ buffer = buffer,
+ }
+ record.owned_finalizer = owned
+ return owned
+end
+
+local function materialize_persistent(service, request)
+ if request.window == nil then
+ fail("persistent feedback window must identify its host window", 3)
+ end
+ local record = service_records[service]
+ remove_target_overlays(record, request.window)
+ request.identity = record.host:create_highlight({
+ group = "CleverTeeChar",
+ window = request.window,
+ positions = request.positions,
+ priority = M.overlay_priority("CleverTeeChar"),
+ target_plan = request.target_plan,
+ descriptor = request.descriptor,
+ endpoint_policy = request.endpoint_policy,
+ anchor_line = request.anchor_line,
+ match_start_line = request.match_start_line,
+ })
+ request.group = "CleverTeeChar"
+ request.priority = M.overlay_priority("CleverTeeChar")
+ local owned, ownership_error = pcall(function()
+ record.transitions:AddTargetOverlay(
+ request.identity,
+ request.window,
+ request.anchor.line
+ )
+ request.finalizers = register_finalizers(service, record)
+ end)
+ if not owned then
+ local resources = record.transitions:RemoveTargetOverlay(
+ request.identity,
+ request.window
+ )
+ if #resources == 0 then
+ resources[1] = {
+ identity = request.identity,
+ window = request.window,
+ }
+ end
+ pcall(release_target_overlays, record, resources)
+ error(ownership_error, 0)
+ end
+ local requests = record.persistent_requests
+ requests[#requests + 1] = request
+ return request
+end
+
+function FeedbackService:request_persistent(specification)
+ return materialize_persistent(self, self:build_persistent(specification))
+end
+
+function FeedbackService:restore_primary(specification)
+ local restoration = self:build_primary_restoration(specification)
+ if restoration == nil then
+ return nil
+ end
+ return materialize_persistent(self, restoration)
+end
+
+function FeedbackService:persistent_requests()
+ local result = {}
+ for index, request in ipairs(service_records[self].persistent_requests) do
+ result[index] = request
+ end
+ return result
+end
+
+function M.repeated_till_migration_candidate(request)
+ if type(request) ~= "table" then
+ fail("command feedback migration request must be a table", 2)
+ end
+ local plan = request.resolved_motion_plan or request.plan
+ return domain.ResolvedMotionPlan.is(plan)
+ and plan.descriptor.family == domain.Family.TILL
+ and request.first_move == false
+end
+
+function M.till_direction_changed(request)
+ if not M.repeated_till_migration_candidate(request) then
+ return false
+ end
+ if type(request.moved_forward) ~= "boolean"
+ or type(request.previous_moved_forward) ~= "boolean"
+ then
+ fail("TILL feedback migration requires movement directions", 2)
+ end
+ return request.moved_forward ~= request.previous_moved_forward
+end
+
+function M.command_migration_reason(request)
+ if type(request) ~= "table" then
+ fail("command feedback migration request must be a table", 2)
+ end
+ local origin = domain.Position.coerce(request.origin)
+ local destination = domain.Position.coerce(request.destination)
+ if request.outcome ~= nil and request.outcome.complete ~= true then
+ return nil
+ end
+ if origin.line ~= destination.line then
+ return M.MigrationReason.LINE_CHANGE
+ end
+ if M.till_direction_changed(request) then
+ return M.MigrationReason.TILL_DIRECTION_CHANGE
+ end
+ return nil
+end
+
+local function has_target_overlay(record, window)
+ for _, resource in ipairs(record.state.target_overlays) do
+ if resource.window == window then
+ return true
+ end
+ end
+ return false
+end
+
+function FeedbackService:primary_restoration_active(context, window)
+ local record = service_records[self]
+ context = domain.ModeContext.from_full_mode(context)
+ local mark_char = record.policy:sample_markers().mark_char
+ if not mark_char or not M.persistent_context_eligible(context) then
+ return false
+ end
+ window = window or record.host:read_window()
+ return not has_target_overlay(record, window)
+end
+
+function FeedbackService:build_primary_restoration(specification)
+ if type(specification) ~= "table" then
+ fail("primary feedback restoration must be a table", 2)
+ end
+ local window = specification.window
+ or service_records[self].host:read_window()
+ if not self:primary_restoration_active(specification.context, window) then
+ return nil
+ end
+ if not domain.TargetPlan.is(specification.target_plan) then
+ fail("primary feedback restoration requires an action TargetPlan", 2)
+ end
+ local action_motion_plan = specification.motion_plan
+ local search_scope = specification.search_scope
+ or (domain.ResolvedMotionPlan.is(action_motion_plan)
+ and action_motion_plan.search_scope)
+ or domain.SearchScope.BUFFER
+ local motion_plan = domain.ResolvedMotionPlan.new({
+ target_plan = specification.target_plan,
+ descriptor = specification.stored_descriptor,
+ search_scope = search_scope,
+ endpoint_policy = specification.endpoint_policy,
+ })
+ return self:build_persistent({
+ context = specification.context,
+ anchor = specification.anchor,
+ target_plan = specification.target_plan,
+ motion_plan = motion_plan,
+ descriptor = specification.stored_descriptor,
+ endpoint_policy = specification.endpoint_policy,
+ text_view = specification.text_view,
+ window = window,
+ })
+end
+
+function FeedbackService:migrate_command(request)
+ local reason = M.command_migration_reason(request)
+ local record = service_records[self]
+ local window = request.window or record.host:read_window()
+ if reason == nil or not has_target_overlay(record, window) then
+ return {
+ migrated = false,
+ reason = reason,
+ }
+ end
+
+ local plan = request.resolved_motion_plan or request.plan
+ if not domain.ResolvedMotionPlan.is(plan) then
+ fail("command feedback migration requires a ResolvedMotionPlan", 2)
+ end
+ local overlay = self:request_persistent({
+ context = request.context,
+ anchor = request.destination,
+ target_plan = plan.target_plan,
+ motion_plan = plan,
+ descriptor = plan.descriptor,
+ endpoint_policy = plan.endpoint_policy,
+ window = window,
+ })
+ return {
+ migrated = true,
+ reason = reason,
+ overlay = overlay,
+ }
+end
+
+function FeedbackService:highlight_timer_delay()
+ local record = service_records[self]
+ if not record.policy:sample_markers().mark_char then
+ return nil
+ end
+ local delay = record.policy:sample_timeouts().highlight_timeout_ms
+ if delay == 0 or record.host:supports_timers() ~= true then
+ return nil
+ end
+ return delay
+end
+
+function FeedbackService:cancel_highlight_timer()
+ local record = service_records[self]
+ local identity = record.transitions:ClearHighlightTimer()
+ release_highlight_timer(record, identity)
+ return identity
+end
+
+function FeedbackService:handle_highlight_timer(callback_identity, window)
+ if callback_identity == nil then
+ fail("highlight timer callback identity must be present", 2)
+ end
+ local record = service_records[self]
+ if callback_identity ~= record.state.highlight_timer then
+ return false
+ end
+ local _, current = record.transitions:ClearHighlightTimer(callback_identity)
+ if not current then
+ return false
+ end
+ self:remove_character_overlays(window or record.host:read_window())
+ return true
+end
+
+function FeedbackService:start_highlight_timer(window)
+ local record = service_records[self]
+ local delay = self:highlight_timer_delay()
+ if delay == nil then
+ return nil
+ end
+ window = window or record.host:read_window()
+ self:cancel_highlight_timer()
+ local identity
+ identity = record.host:start_timer(delay, function(callback_identity)
+ self:handle_highlight_timer(callback_identity or identity, window)
+ end)
+ record.transitions:SetHighlightTimer(identity)
+ return identity
+end
+
+function FeedbackService:refresh_primary(resolved_target, window)
+ if resolved_target == nil then
+ return nil
+ end
+ if not domain.TargetValue.is(resolved_target) then
+ fail("primary timer refresh requires a resolved TargetValue", 2)
+ end
+ return self:start_highlight_timer(window)
+end
+
+function FeedbackService:handle_eager_event(name, payload)
+ if not EAGER_EVENT_SET[name] then
+ return false
+ end
+ local record = service_records[self]
+ local decision = {
+ event = name,
+ payload = payload,
+ mark_char = record.policy:sample_markers().mark_char,
+ cleaned = false,
+ }
+ if decision.mark_char then
+ local window = payload and payload.window or record.host:read_window()
+ local cleanup = record.transitions:ClearTargetFeedback(window)
+ release_highlight_timer(record, cleanup.highlight_timer)
+ release_target_overlays(record, cleanup.target_overlays)
+ decision.cleaned = true
+ decision.cleanup = cleanup
+ end
+ record.last_eager_decision = decision
+ return decision
+end
+
+function FeedbackService:last_eager_decision()
+ local decision = service_records[self].last_eager_decision
+ return decision and copy(decision) or nil
+end
+
+function FeedbackService:activate()
+ local record = service_records[self]
+ if record.activation ~= nil then
+ return copy(record.activation)
+ end
+ local sampled = record.policy:capture_activation()
+ local activation = {
+ clean_labels_eagerly = sampled.clean_labels_eagerly,
+ eager_registration = nil,
+ }
+ if sampled.clean_labels_eagerly then
+ local identity = record.host:register_events(
+ M.EAGER_EVENTS,
+ function(name, payload)
+ self:handle_eager_event(name, payload)
+ end,
+ { owner = "clever_tee", lifecycle = "eager" }
+ )
+ record.eager_registration = identity
+ activation.eager_registration = identity
+ end
+ record.activation = activation
+ return copy(activation)
+end
+
+function FeedbackService:evaluate_feature_links()
+ local record = service_records[self]
+ local rules = record.policy:evaluate_highlight_links()
+ local results = {}
+ for _, group in ipairs(FEATURE_GROUPS) do
+ local rule = rules[group]
+ if rule.enabled then
+ if rule.configured_target ~= nil then
+ record.host:define_highlight_group(
+ group,
+ { link = rule.configured_target },
+ { force = true }
+ )
+ results[group] = {
+ group = group,
+ target = rule.configured_target,
+ source = "configured",
+ applied = true,
+ }
+ else
+ local existing = record.host:read_highlight_group(group)
+ if existing ~= nil then
+ results[group] = {
+ group = group,
+ definition = existing,
+ source = "colorscheme",
+ applied = false,
+ }
+ else
+ record.host:define_highlight_group(
+ group,
+ { link = rule.target },
+ { default = true }
+ )
+ results[group] = {
+ group = group,
+ target = rule.target,
+ source = "fallback",
+ applied = true,
+ }
+ end
+ end
+ end
+ end
+ return results
+end
+
+function FeedbackService:ensure_default_label()
+ local existing = service_records[self].host:read_highlight_group(
+ M.DEFAULT_LABEL_GROUP
+ )
+ if existing ~= nil then
+ return {
+ group = M.DEFAULT_LABEL_GROUP,
+ definition = existing,
+ source = "colorscheme",
+ applied = false,
+ }
+ end
+
+ local definition = M.default_label_definition()
+ service_records[self].host:define_highlight_group(
+ M.DEFAULT_LABEL_GROUP,
+ definition,
+ { default = true }
+ )
+ return {
+ group = M.DEFAULT_LABEL_GROUP,
+ definition = definition,
+ source = "fallback",
+ applied = true,
+ }
+end
+
+function FeedbackService:evaluate_highlights()
+ return {
+ default_label = self:ensure_default_label(),
+ feature_links = self:evaluate_feature_links(),
+ }
+end
+
+function M.new(options)
+ return FeedbackService.new(options)
+end
+
+setmetatable(M, {
+ __call = function(_, options)
+ return FeedbackService.new(options)
+ end,
+})
+
+return M