Created
June 7, 2025 17:03
-
-
Save wroyca/839ccd574090d9b8f3f379b446cc1142 to your computer and use it in GitHub Desktop.
Clangd inactive regions with Neovim
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
---@class InactiveRegionsModule | |
---@field config InactiveRegionsConfig | |
---@field ns integer Namespace ID for highlights | |
---@field _state InactiveRegionsState Internal state | |
local InactiveRegions = {} | |
---@class InactiveRegionsConfig | |
---@field opacity number Opacity level for inactive regions (0.0-1.0) | |
---@field namespace string? Custom namespace name | |
---@field debounce_ms integer Debounce delay for updates in milliseconds | |
---@field max_chunk_size integer Maximum nodes to process per coroutine yield | |
---@field precompute_theme boolean Whether to precompute theme color blends | |
---@field performance_monitoring boolean Enable performance metrics collection | |
---@field debug boolean Enable debug logging and commands | |
---@field live_typing boolean Enable live typing blending for inactive regions | |
---@field typing_debounce_ms integer Debounce delay for live typing updates | |
---@field word_boundary_update boolean Enable word boundary detection and treesitter analysis | |
---@field immediate_char_highlight boolean Highlight characters as they're typed | |
---@field boundary_chars string Characters that are considered word boundaries | |
local default_config = { | |
opacity = 0.5, | |
namespace = nil, | |
debounce_ms = 16, | |
max_chunk_size = 200, | |
precompute_theme = true, | |
performance_monitoring = false, | |
debug = false, | |
live_typing = false, -- poc, not relablie, bugs expected. | |
typing_debounce_ms = 0, | |
word_boundary_update = true, | |
immediate_char_highlight = true, | |
boundary_chars = " \t\n", | |
} | |
---@class InactiveRegionsState | |
---@field color_cache table<string, string> RGB/Hex color conversion cache | |
---@field blend_cache table<string, string> Precomputed color blends | |
---@field highlight_cache table<string, integer> Highlight group cache | |
---@field region_cache table<string, InactiveRegion[]> Previous regions per buffer | |
---@field pending_updates table<string, boolean> Buffers with pending updates | |
---@field debounce_timers table<string, integer> Active debounce timers | |
---@field active_coroutines table<string, thread> Running processing coroutines | |
---@field performance_metrics InactiveRegionsMetrics Performance data | |
---@field theme_signature string Current theme identifier for cache invalidation | |
---@field live_typing_state InactiveRegionsLiveTypingState Live typing state | |
local InactiveRegionsState = {} | |
---@class InactiveRegionsMetrics | |
---@field total_regions_processed integer | |
---@field total_highlights_applied integer | |
---@field cache_hits integer | |
---@field cache_misses integer | |
---@field avg_processing_time_ms number | |
---@field last_update_time_ms number | |
local InactiveRegionsMetrics = {} | |
---@class InactiveRegion | |
---@field start {line: integer, character: integer} | |
---@field end {line: integer, character: integer} | |
---@class TypingEvent | |
---@field bufnr integer Buffer number | |
---@field lnum integer Line number | |
---@field col integer Column number | |
---@field timestamp integer Event timestamp | |
-- Private Implementation | |
-- | |
local H = {} | |
-- Module Lifecycle Management | |
-- | |
---@param config InactiveRegionsConfig? Optional configuration overrides | |
function InactiveRegions.setup(config) | |
config = H.validate_and_merge_config(config or {}) | |
InactiveRegions.config = config | |
InactiveRegions.ns = vim.api.nvim_create_namespace(config.namespace or "inactive_regions_enterprise") | |
InactiveRegions._state = H.create_initial_state() | |
H.setup_lsp_handlers() | |
H.setup_autocommands() | |
H.create_default_highlights() | |
if config.debug then | |
H.setup_debug_commands() | |
end | |
if config.precompute_theme then | |
H.precompute_theme_blends() | |
end | |
_G.InactiveRegions = InactiveRegions | |
H.log("Inactive regions module initialized successfully") | |
end | |
---Clean shutdown of the module | |
function InactiveRegions.teardown() | |
local state = InactiveRegions._state | |
if not state then return end | |
-- Stops all active debounce timers to prevents pending updates from executing | |
-- after teardown. | |
for _, timer in pairs(state.debounce_timers) do | |
if timer then | |
vim.fn.timer_stop(timer) | |
end | |
end | |
-- Clears the table of active coroutines. Note that While this does not stop | |
-- running coroutines immediately, it prevents them from being rescheduled. | |
state.active_coroutines = {} | |
-- Iterates through the region cache, which tracks buffers managed by the | |
-- module and removes all highlights applied from all known buffers. | |
for filename in pairs(state.region_cache) do | |
if vim.fn.bufexists(filename) ~= 0 then | |
local bufnr = vim.fn.bufnr(filename) | |
vim.api.nvim_buf_clear_namespace(bufnr, InactiveRegions.ns, 0, -1) | |
end | |
end | |
H.log("Inactive regions module shut down") | |
end | |
-- Public API | |
-- | |
---Enable inactive regions for a specific buffer | |
---@param bufnr integer? Buffer number (defaults to current buffer) | |
function InactiveRegions.enable(bufnr) | |
bufnr = bufnr or vim.api.nvim_get_current_buf() | |
vim.api.nvim_buf_set_var(bufnr, 'inactive_regions_enabled', true) | |
H.log("Enabled inactive regions for buffer %d", bufnr) | |
end | |
---Disable inactive regions for a specific buffer | |
---@param bufnr integer? Buffer number (defaults to current buffer) | |
function InactiveRegions.disable(bufnr) | |
bufnr = bufnr or vim.api.nvim_get_current_buf() | |
vim.api.nvim_buf_clear_namespace(bufnr, InactiveRegions.ns, 0, -1) | |
vim.api.nvim_buf_set_var(bufnr, 'inactive_regions_enabled', false) | |
H.log("Disabled inactive regions for buffer %d", bufnr) | |
end | |
---Toggle debug mode | |
function InactiveRegions.toggle_debug() | |
InactiveRegions.config.debug = not InactiveRegions.config.debug | |
if InactiveRegions.config.debug then H.setup_debug_commands() end | |
H.log("Debug mode %s", InactiveRegions.config.debug and "enabled" or "disabled") | |
end | |
---Clear all caches and force regeneration | |
function InactiveRegions.invalidate_caches() | |
local state = InactiveRegions._state | |
if not state then return end | |
state.color_cache = {} | |
state.blend_cache = {} | |
state.highlight_cache = {} | |
state.theme_signature = "" | |
-- If theme precomputation is enabled, re-runs the precomputation process so | |
-- that caches are repopulated according to the current theme. | |
if InactiveRegions.config.precompute_theme then | |
H.precompute_theme_blends() | |
end | |
H.log("All caches invalidated and regenerated") | |
end | |
---Get performance metrics | |
---@return InactiveRegionsMetrics | |
function InactiveRegions.get_metrics() | |
return vim.deepcopy(InactiveRegions._state.performance_metrics) | |
end | |
-- Configuration and Validation | |
-- | |
---Validate and merge user configuration with defaults | |
---@param user_config table User-provided configuration | |
---@return InactiveRegionsConfig | |
function H.validate_and_merge_config(user_config) | |
local config = vim.tbl_deep_extend("force", default_config, user_config) | |
if config.opacity < 0 or config.opacity > 1 then | |
vim.notify( | |
"InactiveRegions: opacity must be between 0.0 and 1.0, got " .. config.opacity, | |
vim.log.levels.WARN | |
) | |
config.opacity = 0.5 | |
end | |
if config.debounce_ms < 0 or config.debounce_ms > 1000 then | |
vim.notify( | |
"InactiveRegions: debounce_ms must be between 0 and 1000, got " .. config.debounce_ms, | |
vim.log.levels.WARN | |
) | |
config.debounce_ms = 16 | |
end | |
if config.max_chunk_size < 50 or config.max_chunk_size > 1000 then | |
vim.notify( | |
"InactiveRegions: max_chunk_size must be between 50 and 1000, got " .. config.max_chunk_size, | |
vim.log.levels.WARN | |
) | |
config.max_chunk_size = 200 | |
end | |
return config | |
end | |
---Create initial state structure | |
---@return InactiveRegionsState | |
function H.create_initial_state() | |
return { | |
color_cache = {}, | |
blend_cache = {}, | |
highlight_cache = {}, | |
region_cache = {}, | |
pending_updates = {}, | |
debounce_timers = {}, | |
active_coroutines = {}, | |
performance_metrics = { | |
total_regions_processed = 0, | |
total_highlights_applied = 0, | |
cache_hits = 0, | |
cache_misses = 0, | |
avg_processing_time_ms = 0, | |
last_update_time_ms = 0, | |
}, | |
theme_signature = "", | |
live_typing_state = { | |
typing_timers = {}, | |
cursor_in_inactive = {}, | |
last_typed_pos = {}, | |
temp_highlights = {}, | |
}, | |
} | |
end | |
-- LSP Integration | |
-- | |
---Set up LSP handlers for inactive regions | |
function H.setup_lsp_handlers() | |
vim.lsp.handlers["textDocument/inactiveRegions"] = H.handle_inactive_regions_request | |
end | |
---Handle inactive regions LSP notification with debouncing and async processing | |
---@param _ any LSP client (unused) | |
---@param message table LSP message containing regions data | |
---@param _ any Context (unused) | |
---@param _ any Config (unused) | |
function H.handle_inactive_regions_request(_, message, _, _) | |
local start_time = vim.loop.hrtime() | |
local uri = message.textDocument.uri | |
local filename = vim.uri_to_fname(uri) | |
local regions = message.regions or {} | |
H.log("Received inactive regions request for %s with %d regions", filename, #regions) | |
if not H.is_buffer_valid_and_enabled(filename) then | |
H.log("Buffer %s is not valid or inactive regions are disabled", filename) | |
return | |
end | |
if not H.regions_changed(filename, regions) then | |
H.log("Regions unchanged for %s, skipping update", filename) | |
return | |
end | |
H.cancel_pending_update(filename) | |
InactiveRegions._state.region_cache[filename] = vim.deepcopy(regions) | |
H.schedule_debounced_update(filename, regions, start_time) | |
end | |
---Check if buffer is valid and has inactive regions enabled | |
---@param filename string Buffer filename | |
---@return boolean | |
function H.is_buffer_valid_and_enabled(filename) | |
if vim.fn.bufexists(filename) == 0 then | |
return false | |
end | |
local bufnr = vim.fn.bufnr(filename) | |
local ok, enabled = pcall(vim.api.nvim_buf_get_var, bufnr, 'inactive_regions_enabled') | |
return not ok or enabled ~= false | |
end | |
---Check if regions have changed compared to cached version | |
---@param filename string Buffer filename | |
---@param new_regions InactiveRegion[] New regions to compare | |
---@return boolean | |
function H.regions_changed(filename, new_regions) | |
local cached = InactiveRegions._state.region_cache[filename] | |
-- If there are no cached regions or the number of regions differs, considers | |
-- the regions as changed. | |
if not cached or #cached ~= #new_regions then | |
return true | |
end | |
-- Iterates through each new region and compares it with the corresponding | |
-- cached region. Change is detected if any region's start or end | |
-- line/character differs. | |
for i, region in ipairs(new_regions) do | |
local cached_region = cached[i] | |
if not cached_region or | |
region.start.line ~= cached_region.start.line or | |
region.start.character ~= cached_region.start.character or | |
region["end"].line ~= cached_region["end"].line or | |
region["end"].character ~= cached_region["end"].character then | |
return true | |
end | |
end | |
return false | |
end | |
---Cancel any pending update for the given buffer | |
---@param filename string Buffer filename | |
function H.cancel_pending_update(filename) | |
local state = InactiveRegions._state | |
local timer = state.debounce_timers[filename] | |
if timer then | |
vim.fn.timer_stop(timer) | |
state.debounce_timers[filename] = nil | |
end | |
state.pending_updates[filename] = nil | |
-- Cancels any active processing coroutine for this buffer. Note that setting | |
-- the coroutine reference to nil signals it to terminate if it's designed to | |
-- check this. | |
state.active_coroutines[filename] = nil | |
end | |
---Schedule a debounced update for the buffer | |
---@param filename string Buffer filename | |
---@param regions InactiveRegion[] Regions to process | |
---@param start_time integer Request start time for metrics | |
function H.schedule_debounced_update(filename, regions, start_time) | |
local state = InactiveRegions._state | |
state.pending_updates[filename] = true | |
-- Starts a timer that, upon expiring, will trigger asynchronous processing of | |
-- regions. The delay is configured by `InactiveRegions.config.debounce_ms`. | |
local timer = vim.fn.timer_start(InactiveRegions.config.debounce_ms, function() | |
state.debounce_timers[filename] = nil | |
state.pending_updates[filename] = nil | |
H.process_inactive_regions_async(filename, regions, start_time) | |
end) | |
--Stores the timer ID for potential cancellation. | |
state.debounce_timers[filename] = timer | |
end | |
-- Async Processing Engine | |
-- | |
---Process inactive regions asynchronously with proper coroutine management | |
---@param filename string Buffer filename | |
---@param regions InactiveRegion[] Regions to process | |
---@param start_time integer Request start time for metrics | |
function H.process_inactive_regions_async(filename, regions, start_time) | |
local state = InactiveRegions._state | |
-- Cancel any existing coroutine for this buffer ibefore starting a new one to | |
-- prevents multiple processing tasks from running concurrently for the same | |
-- buffer. | |
state.active_coroutines[filename] = nil | |
local bufnr = vim.fn.bufnr(filename) | |
if bufnr == -1 then | |
H.log("Buffer no longer exists for %s", filename) | |
return | |
end | |
vim.api.nvim_buf_clear_namespace(bufnr, InactiveRegions.ns, 0, -1) | |
if #regions == 0 then | |
H.log("No regions to process for %s", filename) | |
return | |
end | |
local co = coroutine.create(function() | |
return H.process_regions_coroutine(bufnr, filename, regions, start_time) | |
end) | |
-- Stores the reference to the new coroutine and resumes it safely. | |
state.active_coroutines[filename] = co | |
H.resume_coroutine_safely(filename, co) | |
end | |
---Safely resume a coroutine with error handling | |
---@param filename string Buffer filename for context | |
---@param co thread Coroutine to resume | |
function H.resume_coroutine_safely(filename, co) | |
local success, should_continue = coroutine.resume(co) | |
if not success then | |
H.log("Error in processing coroutine for %s: %s", filename, should_continue) | |
InactiveRegions._state.active_coroutines[filename] = nil | |
return | |
end | |
if coroutine.status(co) == "dead" then | |
InactiveRegions._state.active_coroutines[filename] = nil | |
H.log("Completed processing for %s", filename) | |
elseif should_continue then | |
vim.schedule(function() | |
-- Before resuming, checks if this coroutine is still the active one for | |
-- the buffer. That is, we must handles cases where a new update might | |
-- have superseded this processing task. | |
if InactiveRegions._state.active_coroutines[filename] == co then | |
H.resume_coroutine_safely(filename, co) | |
end | |
end) | |
end | |
end | |
---Main coroutine function for processing regions | |
---@param bufnr integer Buffer number | |
---@param filename string Buffer filename | |
---@param regions InactiveRegion[] Regions to process | |
---@param start_time integer Request start time | |
---@return boolean Should continue processing | |
function H.process_regions_coroutine(bufnr, filename, regions, start_time) | |
local state = InactiveRegions._state | |
local metrics = state.performance_metrics | |
local parser = vim.treesitter.get_parser(bufnr) | |
if not parser then | |
H.log("No treesitter parser available for buffer %d", bufnr) | |
return false | |
end | |
local trees = parser:parse() | |
if not trees or #trees == 0 then | |
H.log("No parse trees available for buffer %d", bufnr) | |
return false | |
end | |
local query = vim.treesitter.query.get(parser:lang(), "highlights") | |
if not query then | |
H.log("No highlight query available for language %s", parser:lang()) | |
return false | |
end | |
H.ensure_theme_blends_cached() | |
local total_highlights = 0 | |
local nodes_processed = 0 | |
-- Iterates over each inactive region provided by the LSP. | |
for region_idx, region in ipairs(regions) do | |
H.log("Processing region %d/%d", region_idx, #regions) | |
local highlights_batch = {} | |
-- Iterates over Treesitter captures within the current region's line range. | |
-- `trees[1]:root()` gets the root node of the primary syntax tree. The | |
-- iteration is constrained to lines spanning the inactive region. | |
for id, node in query:iter_captures( | |
trees[1]:root(), | |
bufnr, | |
region.start.line, -- Start line for iteration (0-indexed). | |
region["end"].line + 1 -- End line for iteration (exclusive, 0-indexed). | |
) do | |
local start_row, start_col, end_row, end_col = node:range() | |
-- Filters nodes to include only those strictly within the inactive | |
-- region's boundaries. | |
if H.is_node_in_region(start_row, end_row, region) then | |
-- Gets the name of the capture (e.g., "comment", "keyword"). | |
local capture_name = query.captures[id] | |
if capture_name then | |
-- Retrieves or creates a highlight group specific to this capture | |
-- name, styled for inactive regions (e.g., with reduced opacity). | |
local highlight_group = H.get_or_create_inactive_highlight(capture_name) | |
table.insert(highlights_batch, { | |
group = highlight_group, | |
row = start_row, | |
col_start = start_col, | |
col_end = end_col, | |
}) | |
total_highlights = total_highlights + 1 | |
end | |
end | |
nodes_processed = nodes_processed + 1 | |
-- Periodically yields control to Neovim main loop to prevents blocking | |
-- the UI during intensive processing. | |
if nodes_processed % InactiveRegions.config.max_chunk_size == 0 then | |
H.apply_highlight_batch(bufnr, highlights_batch) | |
highlights_batch = {} | |
H.log("Yielding after processing %d nodes", nodes_processed) | |
coroutine.yield(true) | |
end | |
end | |
H.apply_highlight_batch(bufnr, highlights_batch) | |
coroutine.yield(true) | |
end | |
metrics.total_regions_processed = metrics.total_regions_processed + #regions | |
metrics.total_highlights_applied = metrics.total_highlights_applied + total_highlights | |
local end_time = vim.loop.hrtime() | |
local processing_time_ms = (end_time - start_time) / 1000000 | |
if metrics.avg_processing_time_ms == 0 then | |
metrics.avg_processing_time_ms = processing_time_ms | |
else | |
metrics.avg_processing_time_ms = (metrics.avg_processing_time_ms + processing_time_ms) / 2 | |
end | |
metrics.last_update_time_ms = processing_time_ms | |
H.log("Completed processing %d regions with %d highlights in %.2fms", | |
#regions, total_highlights, processing_time_ms) | |
return false | |
end | |
---Check if a node is within the bounds of an inactive region | |
---@param start_row integer Node start row | |
---@param end_row integer Node end row | |
---@param region InactiveRegion Region to check against | |
---@return boolean | |
function H.is_node_in_region(start_row, end_row, region) | |
-- A node is considered within the region if its start row is not before the | |
-- region's start and its end row is not after the region's end. Note that we | |
-- assumes that regions are defined by line numbers. | |
return start_row >= region.start.line and end_row <= region["end"].line | |
end | |
---Apply a batch of highlights efficiently | |
---@param bufnr integer Buffer number | |
---@param highlights table[] Batch of highlight data | |
function H.apply_highlight_batch(bufnr, highlights) | |
for _, hl in ipairs(highlights) do | |
vim.api.nvim_buf_add_highlight( | |
bufnr, | |
InactiveRegions.ns, | |
hl.group, -- the name of the highlight group to apply | |
hl.row, -- the 0-indexed line number | |
hl.col_start, -- the 0-indexed start column (byte offset) | |
hl.col_end -- the 0-indexed end column (byte offset, exclusive or -1 for end of line) | |
) | |
end | |
end | |
-- Color Management and Caching | |
-- | |
---Precompute all theme color blends for performance | |
function H.precompute_theme_blends() | |
local state = InactiveRegions._state | |
local theme_sig = H.get_theme_signature() | |
if state.theme_signature == theme_sig then | |
H.log("Theme blends already cached for current theme") | |
return | |
end | |
H.log("Precomputing theme color blends...") | |
state.blend_cache = {} | |
state.highlight_cache = {} | |
local background = H.get_background_color() | |
local opacity = InactiveRegions.config.opacity | |
local all_highlights = vim.api.nvim_get_hl(0, {}) | |
local blends_computed = 0 | |
for name, hl in pairs(all_highlights) do | |
if hl.fg then | |
local fg_color = type(hl.fg) == "string" and hl.fg or string.format("#%06x", hl.fg) | |
local blend_key = fg_color .. "_" .. background .. "_" .. opacity | |
if not state.blend_cache[blend_key] then | |
state.blend_cache[blend_key] = H.blend_colors_fast(fg_color, background, opacity) | |
blends_computed = blends_computed + 1 | |
end | |
end | |
end | |
state.theme_signature = theme_sig | |
H.log("Precomputed %d color blends for theme", blends_computed) | |
end | |
---Ensure theme blends are cached for current theme | |
function H.ensure_theme_blends_cached() | |
if not InactiveRegions.config.precompute_theme then | |
return | |
end | |
local current_sig = H.get_theme_signature() | |
if InactiveRegions._state.theme_signature ~= current_sig then | |
H.precompute_theme_blends() | |
end | |
end | |
---Get a signature for the current theme for cache invalidation | |
---@return string | |
function H.get_theme_signature() | |
local normal_hl = vim.api.nvim_get_hl(0, { name = "Normal" }) | |
local bg = normal_hl.bg or normal_hl.background or 0 | |
local fg = normal_hl.fg or normal_hl.foreground or 0 | |
return string.format("%s_%s_%s", vim.g.colors_name or "default", bg, fg) | |
end | |
---Get or create an inactive highlight group for a capture | |
---@param capture_name string Treesitter capture name | |
---@return string Highlight group name | |
function H.get_or_create_inactive_highlight(capture_name) | |
local state = InactiveRegions._state | |
local group_name = "InactiveRegions_" .. capture_name | |
if state.highlight_cache[group_name] then | |
state.performance_metrics.cache_hits = state.performance_metrics.cache_hits + 1 | |
return group_name | |
end | |
state.performance_metrics.cache_misses = state.performance_metrics.cache_misses + 1 | |
local ts_group = "@" .. capture_name | |
local fg_color = H.get_highlight_color(ts_group, "fg") | |
local bg_color = H.get_background_color() | |
local blended_color = H.get_blended_color(fg_color, bg_color, InactiveRegions.config.opacity) | |
vim.api.nvim_set_hl(0, group_name, { | |
fg = blended_color, | |
default = true | |
}) | |
-- Caches the newly created group name. Note that '1' acts as a presence | |
-- marker. | |
state.highlight_cache[group_name] = 1 | |
return group_name | |
end | |
---Get blended color, using cache when available | |
---@param fg string Foreground color | |
---@param bg string Background color | |
---@param opacity number Opacity level | |
---@return string Blended color | |
function H.get_blended_color(fg, bg, opacity) | |
local state = InactiveRegions._state | |
local cache_key = fg .. "_" .. bg .. "_" .. opacity | |
local cached = state.blend_cache[cache_key] | |
if cached then | |
state.performance_metrics.cache_hits = state.performance_metrics.cache_hits + 1 | |
return cached | |
end | |
state.performance_metrics.cache_misses = state.performance_metrics.cache_misses + 1 | |
local blended = H.blend_colors_fast(fg, bg, opacity) | |
state.blend_cache[cache_key] = blended | |
return blended | |
end | |
---Fast color blending algorithm | |
---@param fg string Foreground color (hex) | |
---@param bg string Background color (hex) | |
---@param opacity number Opacity (0.0-1.0) | |
---@return string Blended color (hex) | |
function H.blend_colors_fast(fg, bg, opacity) | |
local fr, fg_g, fb = H.hex_to_rgb(fg) | |
local br, bg_g, bb = H.hex_to_rgb(bg) | |
local inv_opacity = 1 - opacity | |
local r = math.floor(fr * opacity + br * inv_opacity + 0.5) | |
local g = math.floor(fg_g * opacity + bg_g * inv_opacity + 0.5) | |
local b = math.floor(fb * opacity + bb * inv_opacity + 0.5) | |
return string.format("#%02x%02x%02x", | |
math.min(255, math.max(0, r)), | |
math.min(255, math.max(0, g)), | |
math.min(255, math.max(0, b)) | |
) | |
end | |
---Convert hex color to RGB components | |
---@param hex string Hex color string | |
---@return integer, integer, integer RGB components | |
function H.hex_to_rgb(hex) | |
hex = hex:gsub("#", "") | |
if #hex == 3 then | |
-- Expands shorthand hex format (e.g., #RGB to #RRGGBB). | |
hex = hex:gsub("(.)", "%1%1") | |
end | |
return tonumber(hex:sub(1, 2), 16) or 0, | |
tonumber(hex:sub(3, 4), 16) or 0, | |
tonumber(hex:sub(5, 6), 16) or 0 | |
end | |
---Get color from highlight group with caching and fallback resolution | |
---@param group_name string Highlight group name | |
---@param attr string Attribute ("fg" or "bg") | |
---@return string Color in hex format | |
function H.get_highlight_color(group_name, attr) | |
local state = InactiveRegions._state | |
local cache_key = group_name .. "_" .. attr | |
if state.color_cache[cache_key] then | |
return state.color_cache[cache_key] | |
end | |
local color = H.resolve_highlight_color(group_name, attr) | |
state.color_cache[cache_key] = color | |
return color | |
end | |
---Resolve highlight color with fallback chain | |
---@param group_name string Highlight group name | |
---@param attr string Attribute to get | |
---@return string Color in hex format | |
function H.resolve_highlight_color(group_name, attr) | |
local visited = {} | |
local function resolve(name) | |
if visited[name] then return nil end | |
visited[name] = true | |
local hl = vim.api.nvim_get_hl(0, { name = name }) | |
-- Check direct attribute | |
local val = hl[attr] or hl[attr == "fg" and "foreground" or "background"] | |
if val then | |
return type(val) == "string" and val:match("^#") and val | |
or string.format("#%06x", val) | |
end | |
-- If the group is linked to another group (e.g., Comment links to Normal), | |
-- recursively resolves the linked group. | |
if hl.link then | |
return resolve(hl.link) | |
end | |
return nil | |
end | |
local color = resolve(group_name) | |
-- If the color could not be resolved for the given group (and it's not | |
-- 'Normal' itself), attempts to fall back to the 'Normal' group's color for | |
-- that attribute. | |
if not color and group_name ~= "Normal" then | |
color = H.resolve_highlight_color("Normal", attr) | |
end | |
if not color then | |
if attr == "fg" then | |
color = vim.o.background == "dark" and "#ffffff" or "#000000" | |
else | |
color = vim.o.background == "dark" and "#000000" or "#ffffff" | |
end | |
end | |
return color | |
end | |
---Get the current background color | |
---@return string Background color in hex format | |
function H.get_background_color() | |
return H.get_highlight_color("Normal", "bg") | |
end | |
-- Event Handling and Autocommands | |
-- | |
---Set up autocommands for theme changes and cache invalidation | |
function H.setup_autocommands() | |
local augroup = vim.api.nvim_create_augroup("InactiveRegions", { clear = true }) | |
vim.api.nvim_create_autocmd("ColorScheme", { | |
group = augroup, | |
callback = function() | |
H.log("Colorscheme changed, invalidating caches") | |
InactiveRegions.invalidate_caches() | |
end | |
}) | |
vim.api.nvim_create_autocmd("BufDelete", { | |
group = augroup, | |
callback = function(args) | |
local filename = vim.api.nvim_buf_get_name(args.buf) | |
if filename and filename ~= "" then | |
H.cancel_pending_update(filename) | |
InactiveRegions._state.region_cache[filename] = nil | |
H.log("Cleaned up state for deleted buffer: %s", filename) | |
end | |
end | |
}) | |
if InactiveRegions.config.live_typing then | |
H.setup_live_typing_autocmds(augroup) | |
end | |
end | |
---Create default highlight groups | |
function H.create_default_highlights() | |
local bg = H.get_background_color() | |
local fg = H.get_highlight_color("Normal", "fg") | |
-- Defines a base 'InactiveRegions' highlight group. Its foreground color is | |
-- the 'Normal' foreground blended with the 'Normal' background using the | |
-- configured opacity. The idea is for it to serves as a fallback or generic | |
-- inactive style. | |
vim.api.nvim_set_hl(0, "InactiveRegions", { | |
fg = H.blend_colors_fast(fg, bg, InactiveRegions.config.opacity), | |
default = true | |
}) | |
end | |
-- Live Typing Support | |
-- | |
-- Live Typing Word Boundary Detection | |
-- | |
---Check if a word boundary character was just typed | |
---@param bufnr integer Buffer number | |
---@param row integer Current row (0-based) | |
---@param col integer Current column (0-based) | |
---@return boolean True if a boundary character was typed | |
function H.check_word_boundary_typed(bufnr, row, col) | |
-- Word boundary cannot be typed at the very beginning of a line (column 0). | |
if col == 0 then return false end | |
local line = vim.api.nvim_buf_get_lines(bufnr, row, row + 1, false)[1] | |
if not line or col > #line then return false end | |
local char = line:sub(col, col) | |
local boundary_chars = InactiveRegions.config.boundary_chars | |
-- Checks if the typed character is one of the configured boundary characters. | |
-- If not, it's not a word boundary event. | |
if not boundary_chars:find(char, 1, true) then | |
return false | |
end | |
-- Verifies that there is a non-boundary character (part of a word) | |
-- immediately preceding the typed boundary character so that the | |
-- boundary character is actually terminating a word. | |
local word_start = col - 1 | |
while word_start >= 1 do | |
local prev_char = line:sub(word_start, word_start) | |
-- If another boundary character is found before any word character, then | |
-- the typed boundary character did not terminate a word (e.g., typing two | |
-- spaces). | |
if boundary_chars:find(prev_char, 1, true) then | |
break | |
end | |
-- If a word character (`%w`) is found, it confirms a word was just | |
-- completed. | |
if prev_char:match("%w") then | |
return true | |
end | |
word_start = word_start - 1 | |
end | |
return false | |
end | |
---Analyze and update highlight for the completed word before cursor | |
---@param bufnr integer Buffer number | |
---@param row integer Current row (0-based) | |
---@param col integer Current column (0-based) | |
function H.analyze_and_update_completed_word(bufnr, row, col) | |
-- Extracts the word that was just completed, along with its start and end | |
-- columns. Note that for extraction, we considers the character at `col` as | |
-- the boundary character. | |
local word, start_col, end_col = H.extract_word_before_cursor(bufnr, row, col) | |
if not word or word == "" then | |
H.log("No word found at boundary at (%d,%d)", row, col) | |
return | |
end | |
H.log("Word boundary detected: analyzing word '%s' at (%d,%d-%d)", word, row, start_col, end_col) | |
-- Uses Treesitter to determine the correct semantic highlighting for the | |
-- extracted word and applies the corresponding inactive-style highlight. | |
H.update_word_highlight_with_treesitter(bufnr, row, start_col, end_col, word) | |
end | |
---Extract the word that was just completed before the cursor | |
---@param bufnr integer Buffer number | |
---@param row integer Current row (0-based) | |
---@param col integer Current column (0-based) | |
---@return string|nil, integer|nil, integer|nil Word text, start column, end column | |
function H.extract_word_before_cursor(bufnr, row, col) | |
-- Cannot extract a word if the line is empty or at the very start of the | |
-- line. | |
local line = vim.api.nvim_buf_get_lines(bufnr, row, row + 1, false)[1] | |
if not line or col == 0 then return nil end | |
local boundary_chars = InactiveRegions.config.boundary_chars | |
-- Determines if the character at the current cursor position (col) is a | |
-- boundary character to adjust the search range for the word. | |
local just_typed_boundary = false | |
if col <= #line then | |
local char_at_cursor = line:sub(col, col) | |
just_typed_boundary = boundary_chars:find(char_at_cursor, 1, true) ~= nil | |
end | |
-- Sets the starting point for backward search. If a boundary was just typed, | |
-- the word ends at `col - 1`. Otherwise, the word might include `col`. | |
local search_start = just_typed_boundary and (col - 1) or col | |
local start_col = search_start | |
-- Moves backward from `search_start` to find the beginning of the word. The | |
-- word starts after the first boundary character encountered, or at the line | |
-- start. | |
while start_col >= 1 do | |
local char = line:sub(start_col, start_col) | |
if boundary_chars:find(char, 1, true) then | |
start_col = start_col + 1 -- Word starts after this boundary. | |
break | |
end | |
start_col = start_col - 1 | |
end | |
start_col = math.max(start_col, 1) -- start_col must be at least 1 (1-based) | |
-- Sets the end point for the word. If a boundary was just typed, the word | |
-- ends at `col - 1`. Otherwise, it ends at `col`. Note that it primarily | |
-- targets the word *before* a typed boundary. | |
local end_col = just_typed_boundary and (col - 1) or col | |
-- This loop seems redundant or potentially incorrect if `just_typed_boundary` | |
-- is true, as `end_col` would be `col-1`. But if `just_typed_boundary` is false, | |
-- it tries to extend `end_col` to the end of the current word segment. For | |
-- "word boundary" detection, the word is expected to end *before* the cursor. | |
while end_col <= #line do | |
local char = line:sub(end_col + 1, end_col + 1) | |
if not char or char == "" or boundary_chars:find(char, 1, true) then | |
break -- Found end of word or line. | |
end | |
end_col = end_col + 1 | |
end | |
-- If the calculated start is after the end, no valid word was found. | |
if start_col > end_col then return nil end | |
local word = line:sub(start_col, end_col) | |
-- Returns the extracted word, and its 0-based start and end columns. | |
return word, start_col - 1, end_col | |
end | |
---Update word highlight using treesitter analysis | |
---@param bufnr integer Buffer number | |
---@param row integer Row position (0-based) | |
---@param start_col integer Start column (0-based) | |
---@param end_col integer End column (0-based, inclusive) | |
---@param word string The word text for logging | |
function H.update_word_highlight_with_treesitter(bufnr, row, start_col, end_col, word) | |
-- Cancels any pending timer for immediate character highlighting. That is, | |
-- word boundary provides a more definitive highlight, which | |
-- supersed temporary ones. | |
local state = InactiveRegions._state.live_typing_state | |
local existing_timer = state.typing_timers[bufnr] | |
if existing_timer then | |
vim.fn.timer_stop(existing_timer) | |
state.typing_timers[bufnr] = nil | |
H.log("Cancelled pending immediate character highlighting timer for word boundary") | |
end | |
-- Retrieves all existing extmarks (highlights) on the current line within the | |
-- module's namespace. It must be done to clear previous temporary highlights | |
-- or outdated word highlights that might overlap with the word being | |
-- analyzed. | |
local all_extmarks = vim.api.nvim_buf_get_extmarks( | |
bufnr, | |
InactiveRegions.ns, | |
{row, 0}, | |
{row, -1}, | |
{ details = true } | |
) | |
local cleared_count = 0 | |
for _, mark in ipairs(all_extmarks) do | |
local mark_id, mark_row, mark_col, mark_details = mark[1], mark[2], mark[3], mark[4] | |
-- Determine if this extmark overlaps with our word range | |
local should_clear = false | |
if mark_details and mark_details.hl_group then | |
-- Always clear temporary live typing highlights | |
if mark_details.hl_group == "InactiveRegions_LiveTyping" then | |
should_clear = true | |
else | |
-- For other (presumably more permanent) highlights, clears only if they | |
-- overlap with the current word's range (`start_col` to `end_col + 1`). | |
local mark_start = mark_col | |
local mark_end = mark_details.end_col or mark_col + 1 | |
if not (mark_end <= start_col or mark_start >= end_col + 1) then | |
should_clear = true | |
end | |
end | |
end | |
if should_clear then | |
vim.api.nvim_buf_del_extmark(bufnr, InactiveRegions.ns, mark_id) | |
cleared_count = cleared_count + 1 | |
end | |
end | |
H.log("Cleared %d overlapping extmarks from line %d, preserving others", cleared_count, row) | |
local captures = vim.treesitter.get_captures_at_pos(bufnr, row, start_col) | |
local highlight_group = "InactiveRegions_LiveTyping" -- default if no specific capture found. | |
local capture_name = nil | |
-- Iterates through captures to find the most relevant one (often the first or | |
-- most specific). | |
for _, capture in ipairs(captures) do | |
if capture.capture then | |
capture_name = capture.capture | |
highlight_group = H.get_or_create_inactive_highlight(capture_name) | |
break | |
end | |
end | |
if capture_name then | |
H.log("Word '%s' identified as @%s, applying highlight %s", word, capture_name, highlight_group) | |
else | |
-- If no Treesitter capture is found, applies a generic inactive highlight | |
-- by blending the 'Normal' foreground with the background at the configured | |
-- opacity. | |
local bg_color = H.get_background_color() | |
local fg_color = H.get_highlight_color("Normal", "fg") | |
local blended_color = H.get_blended_color(fg_color, bg_color, InactiveRegions.config.opacity) | |
highlight_group = "InactiveRegions_LiveTyping" -- re-affirm or define if not set | |
vim.api.nvim_set_hl(0, highlight_group, { | |
fg = blended_color, | |
default = false -- `default = false` so that it overrides, not links. | |
}) | |
H.log("Word '%s' has no treesitter capture, using default highlight", word) | |
end | |
H.log("Applying highlight '%s' for word '%s' at (%d,%d-%d)", highlight_group, word, row, start_col, end_col) | |
-- Applies the determined highlight group to the word's range. `end_col + 1` | |
-- makes the end column exclusive, as expected by `nvim_buf_add_highlight`. | |
vim.api.nvim_buf_add_highlight( | |
bufnr, | |
InactiveRegions.ns, | |
highlight_group, | |
row, | |
start_col, | |
end_col + 1 -- `end_col` was inclusive, API needs exclusive or -1. | |
) | |
-- Resets the temporary highlights state for the buffer, as a definitive | |
-- highlight has been applied. | |
if state.temp_highlights[bufnr] then | |
state.temp_highlights[bufnr] = {} | |
end | |
end | |
-- Live Typing Basic Functionality | |
-- | |
---Set up autocommands for live typing blending in inactive regions | |
---@param augroup integer Autocommand group ID | |
function H.setup_live_typing_autocmds(augroup) | |
vim.api.nvim_create_autocmd("InsertEnter", { | |
group = augroup, | |
callback = function(args) | |
local bufnr = args.buf | |
H.update_cursor_inactive_status(bufnr) | |
end | |
}) | |
vim.api.nvim_create_autocmd("TextChangedI", { | |
group = augroup, | |
callback = function(args) | |
local bufnr = args.buf | |
if H.is_cursor_in_inactive_region(bufnr) then | |
H.handle_live_typing(bufnr) | |
end | |
end | |
}) | |
vim.api.nvim_create_autocmd("CursorMovedI", { | |
group = augroup, | |
callback = function(args) | |
local bufnr = args.buf | |
H.update_cursor_inactive_status(bufnr) | |
end | |
}) | |
vim.api.nvim_create_autocmd("InsertLeave", { | |
group = augroup, | |
callback = function(args) | |
local bufnr = args.buf | |
H.clear_temp_highlights(bufnr) | |
end | |
}) | |
-- Listens for a custom "TSReparse" User event, often triggered after | |
-- Treesitter finishes re-parsing the buffer (e.g., after a significant change | |
-- or file save). If the cursor is in an inactive region, attempts to reapply | |
-- more accurate highlights to the typed content, now that Treesitter | |
-- information is up-to-date. | |
vim.api.nvim_create_autocmd("User", { | |
group = augroup, | |
pattern = "TSReparse", | |
callback = function(args) | |
local bufnr = args.buf or vim.api.nvim_get_current_buf() | |
if H.is_cursor_in_inactive_region(bufnr) then | |
H.reapply_typed_highlights(bufnr) | |
end | |
end | |
}) | |
end | |
---Update cursor inactive region status for a buffer | |
---@param bufnr integer Buffer number | |
function H.update_cursor_inactive_status(bufnr) | |
local filename = vim.api.nvim_buf_get_name(bufnr) | |
if not filename or filename == "" then return end | |
local regions = InactiveRegions._state.region_cache[filename] | |
if not regions then return end | |
local cursor = vim.api.nvim_win_get_cursor(0) | |
local row, col = cursor[1] - 1, cursor[2] | |
local in_inactive = false | |
for _, region in ipairs(regions) do | |
if H.is_position_in_region(row, col, region) then | |
in_inactive = true | |
break | |
end | |
end | |
InactiveRegions._state.live_typing_state.cursor_in_inactive[bufnr] = in_inactive | |
H.log("Cursor in inactive region for buffer %d: %s", bufnr, tostring(in_inactive)) | |
end | |
---Check if cursor is currently in an inactive region | |
---@param bufnr integer Buffer number | |
---@return boolean | |
function H.is_cursor_in_inactive_region(bufnr) | |
return InactiveRegions._state.live_typing_state.cursor_in_inactive[bufnr] or false | |
end | |
---Check if a position is within an inactive region | |
---@param row integer Row position (0-based) | |
---@param col integer Column position (0-based) | |
---@param region InactiveRegion Region to check against | |
---@return boolean | |
function H.is_position_in_region(row, col, region) | |
if row < region.start.line or row > region["end"].line then | |
return false | |
end | |
if row == region.start.line and col < region.start.character then | |
return false | |
end | |
if row == region["end"].line and col > region["end"].character then | |
return false | |
end | |
return true | |
end | |
---Handle live typing in inactive regions | |
---@param bufnr integer Buffer number | |
function H.handle_live_typing(bufnr) | |
local state = InactiveRegions._state.live_typing_state | |
-- Cancels any existing debounced timer for live typing highlights so that | |
-- rapid typing doesn't queue up multiple highlight applications. | |
local existing_timer = state.typing_timers[bufnr] | |
if existing_timer then | |
vim.fn.timer_stop(existing_timer) | |
state.typing_timers[bufnr] = nil | |
end | |
if InactiveRegions.config.word_boundary_update then | |
local cursor = vim.api.nvim_win_get_cursor(0) | |
local row, col = cursor[1] - 1, cursor[2] -- Convert to 0-based | |
if H.check_word_boundary_typed(bufnr, row, col) then | |
H.clear_temp_highlights(bufnr) | |
H.analyze_and_update_completed_word(bufnr, row, col) | |
return -- Skips the character-by-character highlighting | |
end | |
end | |
if InactiveRegions.config.immediate_char_highlight then | |
local timer = vim.fn.timer_start(InactiveRegions.config.typing_debounce_ms, function() | |
state.typing_timers[bufnr] = nil | |
H.apply_live_typing_highlights(bufnr) | |
end) | |
state.typing_timers[bufnr] = timer | |
end | |
end | |
---Apply live typing highlights to newly typed content | |
---@param bufnr integer Buffer number | |
function H.apply_live_typing_highlights(bufnr) | |
if not vim.api.nvim_buf_is_valid(bufnr) then return end | |
local cursor = vim.api.nvim_win_get_cursor(0) | |
local row, col = cursor[1] - 1, cursor[2] | |
local state = InactiveRegions._state.live_typing_state | |
-- Check if we're at a word boundary - if so, don't apply temporary highlights | |
-- because word boundary detection should handle it | |
if InactiveRegions.config.word_boundary_update and | |
H.check_word_boundary_typed(bufnr, row, col) then | |
H.log("Skipping live typing highlights - word boundary detected") | |
return | |
end | |
local line = vim.api.nvim_buf_get_lines(bufnr, row, row + 1, false)[1] | |
if not line then return end | |
local boundary_chars = InactiveRegions.config.boundary_chars | |
-- Finds the start of the current word by searching backwards from the cursor | |
-- column until a boundary character or the beginning of the line is | |
-- encountered. | |
local word_start = col | |
while word_start > 0 do | |
local char = line:sub(word_start, word_start) | |
if boundary_chars:find(char, 1, true) then | |
word_start = word_start + 1 -- word starts after the boundary. | |
break | |
end | |
word_start = word_start - 1 | |
end | |
word_start = math.max(word_start, 1) -- 1-based index. | |
-- Finds the end of the current word by searching forwards from the cursor | |
-- column until a boundary character or the end of the line is encountered. | |
local word_end = col | |
while word_end <= #line do | |
local char = line:sub(word_end + 1, word_end + 1) | |
if not char or char == "" or boundary_chars:find(char, 1, true) then | |
break -- end of word or line. | |
end | |
word_end = word_end + 1 | |
end | |
-- Converts word start/end to 0-based column indices for highlighting. | |
local start_col = word_start - 1 | |
local end_col = word_end -- `word_end` is inclusive end of word, API needs exclusive. | |
-- Updates the last known typed position. | |
state.last_typed_pos[bufnr] = { row = row, col = col } | |
-- If immediate character highlighting is enabled and a valid word range is | |
-- found, applies temporary highlights to this range. | |
if InactiveRegions.config.immediate_char_highlight and start_col < end_col then | |
H.apply_temp_highlights_to_range(bufnr, row, start_col, row, end_col) | |
local word = line:sub(word_start, word_end) | |
H.log("Applied live typing highlights to entire word '%s' at buffer %d, range (%d,%d-%d)", | |
word, bufnr, row, start_col, end_col) | |
end | |
end | |
---Apply temporary highlights to a range, blending with inactive region style | |
---@param bufnr integer Buffer number | |
---@param start_row integer Start row (0-based) | |
---@param start_col integer Start column (0-based) | |
---@param end_row integer End row (0-based) | |
---@param end_col integer End column (0-based) | |
function H.apply_temp_highlights_to_range(bufnr, start_row, start_col, end_row, end_col) | |
local state = InactiveRegions._state.live_typing_state | |
-- Clears any pre-existing temporary highlights for this buffer to prevents | |
-- accumulation or overlapping of temporary highlights. | |
H.clear_temp_highlights(bufnr) | |
-- Determines the color for temporary highlights by blending the 'Normal' | |
-- foreground color with the background, using the configured opacity. | |
local bg_color = H.get_background_color() | |
local fg_color = H.get_highlight_color("Normal", "fg") | |
local blended_color = H.get_blended_color(fg_color, bg_color, InactiveRegions.config.opacity) | |
-- Defines or redefines the 'InactiveRegions_LiveTyping' highlight group. Note | |
-- that we use it specifically for these temporary highlights. | |
local temp_group = "InactiveRegions_LiveTyping" | |
vim.api.nvim_set_hl(0, temp_group, { | |
fg = blended_color, | |
default = false -- overrides any underlying text properties. | |
}) | |
local highlight_data = {} | |
-- Iterates over the line range to apply highlights. Note that it's structured | |
-- to handle multi-line ranges, though typically live typing affects a single | |
-- line. | |
for line = start_row, end_row do | |
local line_start_col = line == start_row and start_col or 0 | |
local line_end_col = line == end_row and end_col or -1 | |
-- Before applying a new temporary highlight, clears any existing extmarks | |
-- (from this module or others) in the exact target range. This is a more | |
-- precise cleanup than the general `clear_temp_highlights` if there's a | |
-- need to avoid affecting adjacent, non-temporary highlights. Note that, | |
-- `clear_temp_highlights` may have already ran, so this might be redundant | |
-- or target different types of overlaps, but it's okay to ignore. | |
local existing_marks = vim.api.nvim_buf_get_extmarks( | |
bufnr, | |
InactiveRegions.ns, | |
{line, line_start_col}, | |
{line, line_end_col == -1 and -1 or line_end_col}, | |
{ details = false } | |
) | |
for _, mark in ipairs(existing_marks) do | |
local mark_id = mark[1] | |
vim.api.nvim_buf_del_extmark(bufnr, InactiveRegions.ns, mark_id) | |
end | |
local hl_id = vim.api.nvim_buf_add_highlight( | |
bufnr, | |
InactiveRegions.ns, | |
temp_group, | |
line, | |
line_start_col, | |
line_end_col | |
) | |
table.insert(highlight_data, { | |
id = hl_id, | |
row = line, | |
start_col = line_start_col, | |
end_col = line_end_col == -1 and math.huge or line_end_col, | |
group = temp_group | |
}) | |
H.log("Applied temp highlight to line %d, cols %d-%s", | |
line, line_start_col, line_end_col == -1 and "end" or tostring(line_end_col)) | |
end | |
state.temp_highlights[bufnr] = highlight_data | |
end | |
---Clear temporary highlights for a buffer | |
---@param bufnr integer Buffer number | |
function H.clear_temp_highlights(bufnr) | |
local state = InactiveRegions._state.live_typing_state | |
local highlight_data = state.temp_highlights[bufnr] | |
if highlight_data then | |
-- Attempts to remove each tracked temporary highlight by its stored ID. | |
-- `pcall` is used because an extmark might have been removed by other | |
-- means. | |
for _, hl in ipairs(highlight_data) do | |
if hl.id then | |
pcall(vim.api.nvim_buf_del_extmark, bufnr, InactiveRegions.ns, hl.id) | |
end | |
end | |
state.temp_highlights[bufnr] = {} | |
H.log("Cleared %d temp highlights for buffer %d", #highlight_data, bufnr) | |
end | |
-- As a fallback or broader cleanup, iterates through all extmarks in | |
-- the module's namespace and removes any that use the | |
-- 'InactiveRegions_LiveTyping' group. This catches any highlights | |
-- that might not have been tracked by ID. | |
local all_marks = vim.api.nvim_buf_get_extmarks( | |
bufnr, | |
InactiveRegions.ns, | |
0, | |
-1, | |
{ details = true } | |
) | |
local cleared_count = 0 | |
for _, mark in ipairs(all_marks) do | |
local mark_id, _, _, mark_details = mark[1], mark[2], mark[3], mark[4] | |
if mark_details and mark_details.hl_group == "InactiveRegions_LiveTyping" then | |
vim.api.nvim_buf_del_extmark(bufnr, InactiveRegions.ns, mark_id) | |
cleared_count = cleared_count + 1 | |
end | |
end | |
if cleared_count > 0 then | |
H.log("Cleared %d additional InactiveRegions_LiveTyping highlights", cleared_count) | |
end | |
state.last_typed_pos[bufnr] = nil | |
end | |
---Request a highlight refresh for a buffer (for when treesitter reparses) | |
---@param bufnr integer Buffer number | |
function H.reapply_typed_highlights(bufnr) | |
local filename = vim.api.nvim_buf_get_name(bufnr) | |
if not filename or filename == "" then return end | |
if not H.is_cursor_in_inactive_region(bufnr) then return end | |
local cursor = vim.api.nvim_win_get_cursor(0) | |
local row, col = cursor[1] - 1, cursor[2] | |
-- Attempts to get Treesitter captures at the current cursor position. After a | |
-- reparse, Treesitter should have more accurate information. | |
local captures = vim.treesitter.get_captures_at_pos(bufnr, row, col) | |
if #captures > 0 then | |
-- If captures are found, it implies Treesitter has identified syntax | |
-- elements here. | |
for _, capture in ipairs(captures) do | |
if capture.capture then | |
-- Gets or creates the appropriate inactive-style highlight group for | |
-- the capture. | |
local highlight_group = H.get_or_create_inactive_highlight(capture.capture) | |
-- Applies this highlight to a small range around the cursor. This is | |
-- intended to "correct" the temporary highlight with a Treesitter-aware | |
-- one. The range `col-1` to `col+1` might need adjustment depending on | |
-- typical node sizes. | |
vim.api.nvim_buf_add_highlight( | |
bufnr, | |
InactiveRegions.ns, | |
highlight_group, | |
row, | |
math.max(0, col - 1), -- Start column, ensuring non-negative. | |
col + 1 -- End column (exclusive). | |
) | |
end | |
end | |
H.log("Reapplied proper highlights for typed content in buffer %d", bufnr) | |
end | |
end | |
---Request a highlight refresh for a buffer | |
---@param bufnr integer Buffer number | |
function H.request_highlight_refresh(bufnr) | |
local filename = vim.api.nvim_buf_get_name(bufnr) | |
if not filename or filename == "" then return end | |
-- Trigger a refresh by simulating a small change in regions. The intent | |
-- is to trigger a re-evaluation of highlights, possibly by forcing a | |
-- Treesitter reparse of the affected area. | |
vim.schedule(function() | |
if vim.api.nvim_buf_is_valid(bufnr) then | |
local parser = vim.treesitter.get_parser(bufnr) | |
if parser then | |
-- Forces the parser to re-parse the buffer. `true` might indicate an | |
-- incremental parse or a full reparse depending on context. Ideally, it | |
-- should lead to new highlight information if Treesitter emits events | |
-- or if subsequent logic queries Treesitter. | |
-- | |
-- Note: Simply reparsing might not be enough. The system relies on LSP | |
-- sending new `inactiveRegions` or an event like `TSReparse` (if | |
-- configured) to trigger the main highlighting logic. This function | |
-- alone might not directly cause `H.process_inactive_regions_async` to | |
-- run without further integration. | |
parser:parse(true) | |
end | |
end | |
end) | |
end | |
-- Debug and Development Tools | |
-- | |
---Set up debug commands for development and troubleshooting | |
function H.setup_debug_commands() | |
vim.api.nvim_create_user_command("InactiveRegionsMetrics", function() | |
local metrics = InactiveRegions.get_metrics() | |
print("=== Inactive Regions Performance Metrics ===") | |
print(string.format("Total regions processed: %d", metrics.total_regions_processed)) | |
print(string.format("Total highlights applied: %d", metrics.total_highlights_applied)) | |
print(string.format("Cache hits: %d", metrics.cache_hits)) | |
print(string.format("Cache misses: %d", metrics.cache_misses)) | |
print(string.format("Cache hit ratio: %.2f%%", | |
metrics.cache_hits / math.max(1, metrics.cache_hits + metrics.cache_misses) * 100)) | |
print(string.format("Average processing time: %.2fms", metrics.avg_processing_time_ms)) | |
print(string.format("Last update time: %.2fms", metrics.last_update_time_ms)) | |
end, {}) | |
vim.api.nvim_create_user_command("InactiveRegionsCacheInfo", function() | |
local state = InactiveRegions._state | |
print("=== Inactive Regions Cache Information ===") | |
print(string.format("Color cache entries: %d", vim.tbl_count(state.color_cache))) | |
print(string.format("Blend cache entries: %d", vim.tbl_count(state.blend_cache))) | |
print(string.format("Highlight cache entries: %d", vim.tbl_count(state.highlight_cache))) | |
print(string.format("Cached buffers: %d", vim.tbl_count(state.region_cache))) | |
print(string.format("Active coroutines: %d", vim.tbl_count(state.active_coroutines))) | |
print(string.format("Theme signature: %s", state.theme_signature)) | |
end, {}) | |
vim.api.nvim_create_user_command("InactiveRegionsRecompute", function() | |
InactiveRegions.invalidate_caches() | |
print("Forced cache invalidation and recomputation") | |
end, {}) | |
vim.api.nvim_create_user_command("InactiveRegionsDebugNode", function() | |
local bufnr = vim.api.nvim_get_current_buf() | |
local cursor = vim.api.nvim_win_get_cursor(0) | |
local row, col = cursor[1] - 1, cursor[2] | |
local captures = vim.treesitter.get_captures_at_pos(bufnr, row, col) | |
if #captures == 0 then | |
print("No treesitter captures at cursor position") | |
return | |
end | |
print("=== Node Debug Information ===") | |
for _, capture in ipairs(captures) do | |
if capture.capture then | |
local ts_group = "@" .. capture.capture | |
local fg_color = H.get_highlight_color(ts_group, "fg") | |
local bg_color = H.get_background_color() | |
local blended = H.get_blended_color(fg_color, bg_color, InactiveRegions.config.opacity) | |
print(string.format("Capture: %s", capture.capture)) | |
print(string.format(" TS Group: %s", ts_group)) | |
print(string.format(" FG Color: %s", fg_color)) | |
print(string.format(" BG Color: %s", bg_color)) | |
print(string.format(" Blended: %s", blended)) | |
end | |
end | |
end, {}) | |
vim.api.nvim_create_user_command("InactiveRegionsDebugWord", function() | |
local bufnr = vim.api.nvim_get_current_buf() | |
local cursor = vim.api.nvim_win_get_cursor(0) | |
local row, col = cursor[1] - 1, cursor[2] | |
print("=== Word Boundary Debug Information ===") | |
print(string.format("Cursor position: (%d, %d)", row, col)) | |
if col > 0 then | |
local line = vim.api.nvim_buf_get_lines(bufnr, row, row + 1, false)[1] | |
if line then | |
local char = line:sub(col, col) | |
local is_boundary = InactiveRegions.config.boundary_chars:find(char, 1, true) ~= nil | |
print(string.format("Character at cursor: '%s'", char)) | |
print(string.format("Is word boundary: %s", tostring(is_boundary))) | |
local word, start_col, end_col = H.extract_word_before_cursor(bufnr, row, col) | |
if word then | |
print(string.format("Word before cursor: '%s' at (%d-%d)", word, start_col, end_col)) | |
local captures = vim.treesitter.get_captures_at_pos(bufnr, row, start_col) | |
if #captures > 0 then | |
for _, capture in ipairs(captures) do | |
if capture.capture then | |
print(string.format("Word treesitter capture: @%s", capture.capture)) | |
break | |
end | |
end | |
else | |
print("No treesitter captures for word") | |
end | |
else | |
print("No word found before cursor") | |
end | |
end | |
else | |
print("At beginning of line") | |
end | |
print(string.format("Word boundary update enabled: %s", tostring(InactiveRegions.config.word_boundary_update))) | |
print(string.format("Immediate char highlight enabled: %s", tostring(InactiveRegions.config.immediate_char_highlight))) | |
print(string.format("Boundary chars: '%s'", InactiveRegions.config.boundary_chars)) | |
end, {}) | |
end | |
---Debug logging function | |
---@param format string Format string | |
---@param ... any Arguments for format string | |
function H.log(format, ...) | |
if InactiveRegions.config.debug then | |
local timestamp = os.date("%H:%M:%S") | |
print(string.format("[%s] InactiveRegions: " .. format, timestamp, ...)) | |
end | |
end | |
-- Module Export | |
-- | |
InactiveRegions.config = default_config | |
return InactiveRegions |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment