local domain = require("clever_f.domain") local policy = require("clever_f.policy") local sequence_state = require("clever_f.sequence_state") local state_transitions = require("clever_f.state_transitions") local text_topology = require("clever_f.text_topology") local M = {} local FeedbackService = {} FeedbackService.__index = FeedbackService M.FeedbackService = FeedbackService local CursorPresentationLease = {} M.CursorPresentationLease = CursorPresentationLease M.DEFAULT_LABEL_GROUP = "CleverFDefaultLabel" M.Priority = { HIGH = "high", ORDINARY = "ordinary", } M.FINALIZER_EVENTS = { "CursorMoved", "InsertEnter", "TextChanged", } M.FinalizerAction = { PRESERVE = "preserve", FINALIZE = "finalize", } M.MigrationReason = { LINE_CHANGE = "line_change", TILL_DIRECTION_CHANGE = "till_direction_change", } local OVERLAY_PRIORITIES = { CleverFCursor = M.Priority.HIGH, CleverFChar = M.Priority.HIGH, CleverFDirect = 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 = { "CleverFCursor", "CleverFChar", "CleverFDirect", } local LEGACY_NORMAL_EX_CONTEXTS = { cv = true, cvr = true, } local DIRECT_FINALIZER_EVENTS = { InsertEnter = true, TextChanged = true, } 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.stop_timer) ~= "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" 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" 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, } 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_f.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 = "CleverFDirect", window = window, positions = positions, priority = M.overlay_priority("CleverFDirect"), }) record.transitions:AddTemporaryOverlay(identity, window, "CleverFDirect") local resource = { identity = identity, window = window, group = "CleverFDirect", 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 = "CleverFCursor", window = window, position = position, priority = M.overlay_priority("CleverFCursor"), }) record.transitions:AddTemporaryOverlay(identity, window, "CleverFCursor") local resource = { identity = identity, window = window, group = "CleverFCursor", 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, 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:full_finalize(window) local record = service_records[self] window = window or record.host:read_window() local cleanup = record.transitions:FullFinalization(window) release_finalizers(record, cleanup.finalizers) release_highlight_timer(record, cleanup.highlight_timer) release_target_overlays(record, cleanup.target_overlays) 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 function FeedbackService:request_persistent(specification) local request = self:build_persistent(specification) if request.window == nil then fail("persistent feedback window must identify its host window", 2) end local record = service_records[self] remove_target_overlays(record, request.window) request.identity = record.host:create_highlight({ group = "CleverFChar", window = request.window, positions = request.positions, priority = M.overlay_priority("CleverFChar"), target_plan = request.target_plan, descriptor = request.descriptor, endpoint_policy = request.endpoint_policy, match_start_line = request.match_start_line, }) request.group = "CleverFChar" request.priority = M.overlay_priority("CleverFChar") record.transitions:AddTargetOverlay( request.identity, request.window, request.anchor.line ) request.finalizers = register_finalizers(self, record) local requests = record.persistent_requests requests[#requests + 1] = request return request 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: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: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