Files
outline.nvim/lua/outline/writer.lua
hedy 9c70b96b36 fix(highlight-hover): Set cursor in parent if child is folded
Hopefully this commit fixes #1.

- Improved algorithm that finds the items to set hover-highlight
- Now we can set cursor on the nearest parent if the leaf node is folded
  in the outline.
- Set cursor column appropriate depending on whether lineno is enabled.

API:
- parser.preorder_iter now supports an optional argument as function,
  which determines whether to explore children. By default, folded
  parents will not explore children. This can now be overridden.

Behaviour:
- If you fold a nested node and hit <C-g>, you can go back to the
  nearest parent
- The column that cursor goes to is no longer arbitrarily chosen

It appears that simrat or whoever wrote this code thought the column was
1-indexed, however it is 0-indexed, so the old code was always putting
the cursor on the 2nd column.

Now, we put it in the first column. If lineno is enabled, we set the
cursor the be at the column of lineno padding, this makes both the
lineno and the markers visible.

Unfortunately the so-called 'improved' algorithm for
_highlight_current_item is still not the best. The most optimal would be
O(n). However, to make sure we stop refactoring now that it works OK and
can already fix an issue, I will leave this to posterity.

Tested to work (for me).
2023-11-13 21:07:30 +08:00

290 lines
9.4 KiB
Lua

local symbols = require 'outline.symbols'
local parser = require 'outline.parser'
local cfg = require 'outline.config'
local ui = require 'outline.ui'
local t_utils = require 'outline.utils.table'
local folding = require 'outline.folding'
local strlen = vim.fn.strlen
local M = {}
local hlns = vim.api.nvim_create_namespace 'outline-icon-highlight'
local ns = vim.api.nvim_create_namespace 'outline-virt-text'
---@param bufnr integer
---@return boolean
function M.is_buffer_outline(bufnr)
if not vim.api.nvim_buf_is_valid(bufnr) then
return false
end
local name = vim.api.nvim_buf_get_name(bufnr)
local ft = vim.api.nvim_buf_get_option(bufnr, 'filetype')
return string.match(name, 'OUTLINE') ~= nil and ft == 'Outline'
end
---Apply highlights and hover highlights to bufnr
---@param bufnr integer
---@param nodes outline.FlatSymbolNode[] flattened nodes
function M.add_highlights(bufnr, hl_info, nodes)
for _, line_hl in ipairs(hl_info) do
local line, hl_start, hl_end, hl_type = unpack(line_hl)
vim.api.nvim_buf_add_highlight(
bufnr,
hlns,
hl_type,
line - 1,
hl_start,
hl_end
)
end
M.add_hover_highlights(bufnr, nodes)
end
---@param bufnr integer
local function clear_virt_text(bufnr)
vim.api.nvim_buf_clear_namespace(bufnr, -1, 0, -1)
end
---@param bufnr integer
---@param nodes outline.FlatSymbolNode[] flattened nodes
function M.add_hover_highlights (bufnr, nodes)
if not cfg.o.outline_items.highlight_hovered_item then
return
end
-- clear old highlight
ui.clear_hover_highlight(bufnr)
for _, node in ipairs(nodes) do
if not node.hovered then
goto continue
end
if node.prefix_length then
ui.add_hover_highlight(
bufnr,
node.line_in_outline - 1,
node.prefix_length
)
end
::continue::
end
end
---@class outline.FlatSymbolNode
---@field name string
---@field depth integer
---@field parent outline.SymbolNode
---@field deprecated boolean
---@field kind integer|string
---@field icon string
---@field detail string
---@field line integer
---@field character integer
---@field range_start integer
---@field range_end integer
---@field isLast boolean
---@field hierarchy boolean
---@field children? outline.SymbolNode[]
---@field traversal_child integer
---@field line_in_outline integer
---@field prefix_length integer
---@field hovered boolean
---@field folded boolean
---The quintessential function of this entire plugin. Clears virtual text,
-- parses each node and replaces old lines with new lines to be written for the
-- outline buffer.
-- Handles highlights, virtual text, and of course lines of outline to write
---@param bufnr integer Nothing is done if is_buffer_outline(bufnr) is not true
---@param items outline.SymbolNode[] Tree of symbols after being parsed by parser.parse_result
---@return outline.FlatSymbolNode[] flattened_items Empty table returned if bufnr is invalid
---@param codewin integer code window
function M.make_outline(bufnr, items, codewin)
if not M.is_buffer_outline(bufnr) then
return {}
end
local codebuf = vim.api.nvim_win_get_buf(codewin)
clear_virt_text(bufnr)
---@type string[]
local lines = {}
---@type string[]
local details = {}
---@type string[]
local linenos = {}
---@type outline.FlatSymbolNode[]
local flattened = {}
local hl = {}
-- Find the prefix for each line needed for the lineno space
local lineno_offset = 0
local lineno_prefix = ""
local lineno_max_width = #tostring(vim.api.nvim_buf_line_count(codebuf) - 1)
if cfg.o.outline_items.show_symbol_lineno then
-- Use max width-1 plus 1 space padding.
-- -1 because if max_width is a power of ten, don't shift the entire lineno
-- column by the right just because the last line number requires an extra
-- digit. If max_width is 1000, the lineno column will take up 3 columns to
-- fill the digits, and 1 padding on the right. The 1000 can fit perfectly
-- there.
lineno_offset = math.max(2, lineno_max_width) + 1
lineno_prefix = string.rep(' ', lineno_offset)
end
-- Closures for convenience
local function add_guide_hl(from, to)
table.insert(hl, {
#flattened,
from,
to,
"OutlineGuides"
})
end
local function add_fold_hl(from, to)
table.insert(hl, {
#flattened,
from,
to,
"OutlineFoldMarker"
})
end
local guide_markers = cfg.o.guides.markers
local fold_markers = cfg.o.symbol_folding.markers
for node in parser.preorder_iter(items) do
table.insert(flattened, node)
node.line_in_outline = #flattened
table.insert(details, node.detail or '')
local lineno = tostring(node.range_start+1)
local leftpad = string.rep(' ', lineno_max_width-#lineno)
table.insert(linenos, leftpad..lineno)
-- Make the guides for the line prefix
local pref = t_utils.str_to_table(string.rep(' ', node.depth))
local fold_marker_width = 0
if folding.is_foldable(node) then
-- Add fold marker
local marker = fold_markers[2]
if folding.is_folded(node) then
marker = fold_markers[1]
end
pref[#pref] = marker
fold_marker_width = strlen(marker)
else
-- Rightmost guide for the immediate parent, only added if fold marker is
-- not added
if node.depth > 1 then
local marker = guide_markers.middle
if node.isLast then
marker = guide_markers.bottom
end
pref[#pref] = marker
end
end
-- Add vertical guides to the left, for all parents that isn't the last
-- sibling. Iter from first grandparent until second last ancestor (last
-- ancestor is the entire outline itself, it should not have a vertical
-- guide).
local iternode = node
for i = node.depth-1, 2, -1 do
iternode = iternode.parent_node
if not iternode.isLast then
pref[i] = guide_markers.vertical
end
end
-- Finished with guide prefix
-- Join all prefix chars by a space
local pref_str = table.concat(pref, ' ')
local total_pref_len = lineno_offset + #pref_str
-- Guide hl goes from start of prefix till before the fold marker, if any.
-- Fold hl goes from start of fold marker until before the icon.
add_guide_hl(lineno_offset, total_pref_len - fold_marker_width)
if fold_marker_width > 0 then
add_fold_hl(total_pref_len - fold_marker_width, total_pref_len + 1)
end
local line = lineno_prefix..pref_str..' '..node.icon..' '..node.name
-- Highlight for the icon ✨
local hl_start = #pref_str + #lineno_prefix + 1 -- Start from icon col
local hl_end = hl_start + #node.icon -- until after icon
local hl_type = cfg.o.symbols.icons[symbols.kinds[node.kind]].hl
table.insert(hl, { #flattened, hl_start, hl_end, hl_type })
-- Prefix length is from start until the beginning of the node.name, used
-- for hover highlights.
node.prefix_length = hl_end + 1
-- lines passed to nvim_buf_set_lines cannot contain newlines in each line
line = line:gsub("\n", " ")
table.insert(lines, line)
end
-- Write the lines 🎉
vim.api.nvim_buf_set_option(bufnr, 'modifiable', true)
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines)
vim.api.nvim_buf_set_option(bufnr, 'modifiable', false)
-- Unfortunately highlights and extmarks cannot be added to lines that do not
-- yet exist. Hence this requires another O(n) of iteration.
M.add_highlights(bufnr, hl, flattened)
-- Add details and lineno virtual text.
if cfg.o.outline_items.show_symbol_details then
for index, value in ipairs(details) do
vim.api.nvim_buf_set_extmark(bufnr, ns, index - 1, -1, {
virt_text = { { value, 'OutlineDetails' } },
virt_text_pos = 'eol',
hl_mode = 'combine',
})
end
end
if cfg.o.outline_items.show_symbol_lineno then
-- Line numbers are left padded, right aligned, positioned at the leftmost
-- column
-- TODO: Fix lineno not appearing if text in line is truncated on the right
-- due to narrow window, after nvim fixes virt_text_hide.
for index, value in ipairs(linenos) do
vim.api.nvim_buf_set_extmark(bufnr, ns, index - 1, -1, {
virt_text = { { value, 'OutlineLineno' } },
virt_text_pos = 'overlay',
virt_text_win_col = 0,
-- When hide_cursor + cursorline enabled, we want the lineno to also
-- take on the cursorline background so wherever the cursor is, it
-- appears blended. We want 'replace' even for `hide_cursor=false
-- cursorline=true` because vim's native line numbers do not get
-- highlighted by cursorline.
hl_mode = (cfg.o.outline_window.hide_cursor and 'combine') or 'replace',
})
end
end
return flattened
end
-- XXX: Is the performance tradeoff of calling `nvim_buf_set_lines` on each
-- iteration worth it in order to put setting of highlights, details, and
-- linenos together with each line?
-- That is,
-- 1. { call nvim_buf_set_lines once for all lines }
-- + { O(n) for each of highlights, details, and linenos }
--OR
-- 2. { call nvim_buf_set_lines for each line }
-- + { O(1) for each of highlight/detail/lineno the same iteration }
-- It appears that for highlight/detail/lineno, the number of calls to nvim API
-- is the same, only 3 extra tables in memory for (1). Where as for (2) you
-- have to call nvim_buf_set_lines n times (each line) rather than add lines
-- all at once, saving only the need of 1 extra table (lines table) in memory.
return M