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/feedback_service.lua | |
| parent | 9013636a57144e1f57c9339e7888d588430aae6a (diff) | |
Rename plugin to clever-tee
Diffstat (limited to 'lua/clever_tee/feedback_service.lua')
| -rw-r--r-- | lua/clever_tee/feedback_service.lua | 1003 |
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 |
