430 lines
15 KiB
Lua
430 lines
15 KiB
Lua
-- mod-version:3
|
|
local core = require "core"
|
|
local DocView = require "core.docview"
|
|
local style = require "core.style"
|
|
local config = require "core.config"
|
|
local common = require "core.common"
|
|
local command = require "core.command"
|
|
|
|
local SS = {}
|
|
|
|
-- Ignore lines with only the opening bracket
|
|
function SS.get_level_ignore_open_bracket(doc, line)
|
|
if doc.lines[line]:match("^%s*{%s*$") then
|
|
return -1
|
|
end
|
|
return SS.get_level_default(doc, line)
|
|
end
|
|
|
|
local filetype_overrides = {
|
|
["Markdown"] = function(doc, line)
|
|
-- Use the markdown heading level only
|
|
local indent = string.match(doc.lines[line], "^#+() .+")
|
|
return indent or math.huge
|
|
end,
|
|
["C"] = SS.get_level_ignore_open_bracket,
|
|
["C++"] = SS.get_level_ignore_open_bracket,
|
|
["Plain Text"] = false
|
|
}
|
|
|
|
config.plugins.sticky_scroll = common.merge({
|
|
enabled = true,
|
|
max_sticky_lines = 5,
|
|
-- Override the function to get the level of a line.
|
|
--
|
|
-- The key is the syntax name, the value is a function that receives the doc
|
|
-- and the line, and returns the level [-1; math.huge].
|
|
--
|
|
-- The default function is `SS.get_level_default`, which is indent based,
|
|
-- and ignores comment-only lines.
|
|
-- Use `false` to disable the plugin for that filetype.
|
|
filetype_overrides = filetype_overrides,
|
|
config_spec = {
|
|
name = "Sticky Scroll",
|
|
{
|
|
label = "Enabled",
|
|
description = "Enable or disable drawing the Sticky Scroll.",
|
|
path = "enabled",
|
|
type = "toggle",
|
|
default = true
|
|
},
|
|
{
|
|
label = "Maximum number of sticky lines",
|
|
description = "The maximum number of sticky lines to show",
|
|
path = "max_sticky_lines",
|
|
type = "number",
|
|
default = 5,
|
|
min = 1,
|
|
step = 1
|
|
}
|
|
}
|
|
}, config.plugins.sticky_scroll)
|
|
|
|
-- Merge user changes with the default overrides
|
|
config.plugins.sticky_scroll.filetype_overrides = common.merge(filetype_overrides, config.plugins.sticky_scroll.filetype_overrides)
|
|
|
|
|
|
-- Automatically remove docview (keys) when not needed anymore
|
|
-- Automatically create a docview entry on access
|
|
SS.managed_docviews = setmetatable({}, {
|
|
__mode = "k",
|
|
__index = function(t, k)
|
|
local v = {enabled = true, sticky_lines = {}, reference_line = 1, syntax = nil}
|
|
rawset(t, k, v)
|
|
return v
|
|
end
|
|
})
|
|
|
|
local regex_pattern = regex.compile([[(\s*)\S]])
|
|
---Return the indent level of a string.
|
|
---The indent level is counted as the number of spaces and tabs in the string.
|
|
---A tab is counted as a space, so mixed tab types can cause issues.
|
|
---
|
|
---TODO: maybe only consider the indent type of the file,
|
|
--- or even only consider valid the type of the first character in the line.
|
|
---
|
|
---@param doc core.doc
|
|
---@param line integer
|
|
---@return integer #>0 for lines with indents and text, 0 for lines with no indent, -1 for lines without any non-whitespace characters
|
|
function SS.get_level_from_indent(doc, line)
|
|
local text = doc.lines[line]
|
|
local s, e = regex.find_offsets(regex_pattern --[[@as regex]], text)
|
|
return s and e - s or -1
|
|
end
|
|
|
|
---Same as SS.get_level_from_indent, but ignores lines with only comments.
|
|
---@param doc core.doc
|
|
---@param line integer
|
|
---@return integer #>0 for lines with indents and text, 0 for lines with no indent, -1 for lines without any non-whitespace characters
|
|
function SS.get_level_default(doc, line)
|
|
for _, type, text in doc.highlighter:each_token(line) do
|
|
if type ~= "comment" then
|
|
return SS.get_level_from_indent(doc, line)
|
|
end
|
|
end
|
|
return -1
|
|
end
|
|
|
|
---Return the function to use to get the level.
|
|
---
|
|
---@param doc core.doc
|
|
---@param line integer
|
|
---@return function
|
|
function SS.get_level_getter(doc)
|
|
local get_level = SS.get_level_default
|
|
if config.plugins.sticky_scroll
|
|
and doc.syntax.name
|
|
and config.plugins.sticky_scroll.filetype_overrides[doc.syntax.name] ~= nil then
|
|
get_level = config.plugins.sticky_scroll.filetype_overrides[doc.syntax.name]
|
|
if get_level == false then
|
|
get_level = nil
|
|
end
|
|
end
|
|
return get_level
|
|
end
|
|
|
|
---Returns whether the plugin is enabled.
|
|
---If `dv` is provided, returns if the docview is enabled.
|
|
---The "global" check has priority over the docview check.
|
|
---
|
|
---@param dv core.docview?
|
|
---return boolean
|
|
function SS.should_run(dv)
|
|
if dv and not dv:is(DocView) then return false end
|
|
if dv and not SS.managed_docviews[dv].enabled then return false end
|
|
if not config.plugins.sticky_scroll or not config.plugins.sticky_scroll.enabled then return false end
|
|
return true
|
|
end
|
|
|
|
---Return an array of the sticly lines that should be shown.
|
|
---
|
|
---@param doc core.doc
|
|
---@param start_line integer #the reference line
|
|
---@param max_sticky_lines integer #the maximum allowed sticky lines
|
|
---@return table #an ordered list of lines that should be shown as sticky
|
|
function SS.get_sticky_lines(doc, start_line, max_sticky_lines)
|
|
local res = {}
|
|
local last_level
|
|
local original_start_line = start_line
|
|
start_line = common.clamp(start_line, 1, #doc.lines)
|
|
|
|
local get_level = SS.get_level_getter(doc)
|
|
if not get_level then return res end
|
|
|
|
-- Find the first usable line
|
|
repeat
|
|
if start_line <= 0 then return res end
|
|
last_level = get_level(doc, start_line)
|
|
start_line = start_line - 1
|
|
until last_level >= 0
|
|
|
|
-- If we had to skip some lines, check if we need to stick the usable one
|
|
if original_start_line ~= start_line + 1 then
|
|
local found = false
|
|
-- Check if there are valid lines after the original start line
|
|
for i = original_start_line, #doc.lines do
|
|
local next_indent_level = get_level(doc, i)
|
|
if next_indent_level >= 0 then
|
|
if next_indent_level == 0 and next_indent_level < last_level then
|
|
-- We are at the end of the block,
|
|
-- so there aren't any sticky lines to be shown
|
|
return res
|
|
end
|
|
-- If there is an indent level higher than original start line,
|
|
-- stick the usable line that was found
|
|
if next_indent_level > last_level then
|
|
table.insert(res, start_line + 1)
|
|
end
|
|
found = true
|
|
break
|
|
end
|
|
end
|
|
-- If there are no valid lines, we don't need to show sticky lines.
|
|
if not found then return res end
|
|
end
|
|
|
|
-- Find sticky lines to show, starting from the current line,
|
|
-- until we get to one that has level 0.
|
|
for i = start_line, 1, -1 do
|
|
local level = get_level(doc, i)
|
|
if level >= 0 and level < last_level then
|
|
table.insert(res, i)
|
|
last_level = level
|
|
end
|
|
if level == 0 then break end
|
|
end
|
|
|
|
-- Only keep the lines we're allowed to show
|
|
common.splice(res, 1, math.max(0, #res - max_sticky_lines))
|
|
return res
|
|
end
|
|
|
|
-- TODO: Workaround - Remove when lite-xl/lite-xl#1382 is merged and released
|
|
local function get_visible_line_range(dv)
|
|
local _, y, _, y2 = dv:get_content_bounds()
|
|
local lh = dv:get_line_height()
|
|
local minline = math.max(1, math.floor((y - style.padding.y) / lh) + 1)
|
|
local maxline = math.min(#dv.doc.lines, math.floor((y2 - style.padding.y) / lh) + 1)
|
|
return minline, maxline
|
|
end
|
|
|
|
local last_max_sticky_lines
|
|
local old_dv_update = DocView.update
|
|
function DocView:update(...)
|
|
local res = old_dv_update(self, ...)
|
|
if not SS.should_run(self) then return res end
|
|
|
|
-- Simple cache. Gets reset on every doc change.
|
|
-- Could be made smarter, but this will do for now™.
|
|
local docview = SS.managed_docviews[self]
|
|
local current_change_id = self.doc:get_change_id()
|
|
if docview.sticky_scroll_last_change_id ~= current_change_id
|
|
or last_max_sticky_lines ~= config.plugins.sticky_scroll.max_sticky_lines
|
|
or docview.syntax ~= self.doc.syntax then
|
|
docview.sticky_scroll_cache = {}
|
|
docview.reference_line = 1
|
|
docview.syntax = self.doc.syntax
|
|
docview.sticky_scroll_last_change_id = current_change_id
|
|
last_max_sticky_lines = config.plugins.sticky_scroll.max_sticky_lines
|
|
end
|
|
|
|
local minline, _ = get_visible_line_range(self)
|
|
local lh = self:get_line_height()
|
|
|
|
-- We need to find the first line that'll be visible
|
|
-- even after the sticky lines are drawn.
|
|
local from = math.max(1, minline)
|
|
local to = math.min(minline + config.plugins.sticky_scroll.max_sticky_lines, #self.doc.lines)
|
|
local new_sticky_lines = {}
|
|
local new_reference_line = to
|
|
for i = from, to do
|
|
-- Simple cache
|
|
if not docview.sticky_scroll_cache[i] then
|
|
docview.sticky_scroll_cache[i] = SS.get_sticky_lines(self.doc, i, config.plugins.sticky_scroll.max_sticky_lines)
|
|
end
|
|
local scroll_lines = docview.sticky_scroll_cache[i]
|
|
local _, nl_y = self:get_line_screen_position(i)
|
|
if nl_y >= self.position.y + lh * #scroll_lines then
|
|
break
|
|
end
|
|
new_sticky_lines = scroll_lines
|
|
new_reference_line = i
|
|
end
|
|
|
|
docview.sticky_lines = new_sticky_lines
|
|
docview.reference_line = new_reference_line
|
|
return res
|
|
end
|
|
|
|
local old_dv_draw_overlay = DocView.draw_overlay
|
|
function DocView:draw_overlay(...)
|
|
local res = old_dv_draw_overlay(self, ...)
|
|
if not SS.should_run(self) then return res end
|
|
|
|
local minline, _ = get_visible_line_range(self)
|
|
local lh = self:get_line_height()
|
|
|
|
-- Ignore the horizontal scroll position when drawing sticky lines
|
|
local scroll_x = self.scroll.x
|
|
self.scroll.x = 0
|
|
local x = self:get_line_screen_position(minline)
|
|
self.scroll.x = scroll_x
|
|
|
|
local y
|
|
local gw, gpad = self:get_gutter_width()
|
|
local data = SS.managed_docviews[self]
|
|
local _, rl_y = self:get_line_screen_position(data.reference_line)
|
|
|
|
-- We need to reset the clip, because when DocView:draw_overlay is called
|
|
-- it's too small for us.
|
|
local old_clip_rect = core.clip_rect_stack[#core.clip_rect_stack]
|
|
renderer.set_clip_rect(self.position.x, self.position.y, self.size.x, self.size.y)
|
|
|
|
local drawn = false
|
|
local max_y = 0
|
|
for i=1, #data.sticky_lines do
|
|
y = self.position.y + (#data.sticky_lines - i) * lh
|
|
local l = data.sticky_lines[i]
|
|
y = math.min(y, rl_y)
|
|
max_y = math.max(y, max_y)
|
|
drawn = true
|
|
renderer.draw_rect(self.position.x, y, self.size.x, lh, style.background)
|
|
self:draw_line_gutter(l, self.position.x, y, gpad and gw - gpad or gw)
|
|
self:draw_line_text(l, x, y)
|
|
if data.hovered_sticky_scroll_line == l then
|
|
renderer.draw_rect(self.position.x, y, self.size.x, lh, style.drag_overlay)
|
|
end
|
|
end
|
|
if drawn then
|
|
renderer.draw_rect(self.position.x, max_y + lh, self.size.x, style.divider_size, style.divider)
|
|
end
|
|
|
|
-- Restore clip rect
|
|
renderer.set_clip_rect(table.unpack(old_clip_rect))
|
|
return res
|
|
end
|
|
|
|
local old_mouse_pressed = DocView.on_mouse_pressed
|
|
function DocView:on_mouse_pressed(button, x, y, clicks, ...)
|
|
if not SS.should_run(self) then return old_mouse_pressed(self, button, x, y, clicks, ...) end
|
|
|
|
local data = SS.managed_docviews[self]
|
|
data.sticky_lines_mouse_pressed = false
|
|
if #data.sticky_lines == 0 then
|
|
return old_mouse_pressed(self, button, x, y, clicks, ...)
|
|
end
|
|
|
|
local lh = self:get_line_height()
|
|
local rl_x, rl_y = self:get_line_screen_position(data.reference_line)
|
|
if y >= math.min(rl_y + lh, lh * #data.sticky_lines + self.position.y) or y < self.position.y then
|
|
data.sticky_lines_mouse_pressed = true
|
|
return old_mouse_pressed(self, button, x, y, clicks, ...)
|
|
end
|
|
|
|
local clicked_line = data.sticky_lines[#data.sticky_lines - (y - self.position.y) // lh]
|
|
local col = self:get_x_offset_col(clicked_line, x - rl_x)
|
|
self:scroll_to_make_visible(clicked_line, col)
|
|
self.doc:set_selection(clicked_line, col)
|
|
return true
|
|
end
|
|
|
|
local old_mouse_moved = DocView.on_mouse_moved
|
|
function DocView:on_mouse_moved(x, y, ...)
|
|
if not SS.should_run(self) then return old_mouse_moved(self, x, y, ...) end
|
|
|
|
local data = SS.managed_docviews[self]
|
|
data.hovered_sticky_scroll_line = nil
|
|
if #data.sticky_lines == 0 then
|
|
return old_mouse_moved(self, x, y, ...)
|
|
end
|
|
|
|
local lh = self:get_line_height()
|
|
local _, rl_y = self:get_line_screen_position(data.reference_line)
|
|
if self.mouse_selecting
|
|
or y >= math.min(rl_y + lh, lh * #data.sticky_lines + self.position.y)
|
|
or y < self.position.y
|
|
or x < self.position.x
|
|
or x >= self.position.x + self.size.x
|
|
or self.v_scrollbar:overlaps(x, y)
|
|
then
|
|
return old_mouse_moved(self, x, y, ...)
|
|
end
|
|
|
|
self.cursor = "hand"
|
|
data.hovered_sticky_scroll_line = data.sticky_lines[#data.sticky_lines - (y - self.position.y) // lh]
|
|
return true
|
|
end
|
|
|
|
local old_scroll_to_make_visible = DocView.scroll_to_make_visible
|
|
function DocView:scroll_to_make_visible(line, col, ...)
|
|
old_scroll_to_make_visible(self, line, col, ...)
|
|
if not SS.should_run(self) then return end
|
|
|
|
-- We need to scroll the view to account for the sticky lines.
|
|
|
|
local lh = self:get_line_height()
|
|
local before_scroll = self.scroll.y
|
|
local _, ly = self:get_line_screen_position(line, col)
|
|
ly = ly - self.position.y + (before_scroll - self.scroll.to.y)
|
|
local data = SS.managed_docviews[self]
|
|
-- Avoid moving the caret under the sticky lines.
|
|
local num_sticky_lines
|
|
if data.sticky_lines_mouse_pressed or self.mouse_selecting then
|
|
-- On mouse click, use the current number of visible sticky lines
|
|
-- to avoid scrolling too much.
|
|
data.sticky_lines_mouse_pressed = false
|
|
num_sticky_lines = data.sticky_lines and #data.sticky_lines or 0
|
|
else
|
|
-- When the movement wasn't caused by mouse clicks, use the maximum number
|
|
-- of possible sticky lines, to avoid scrolling in an inconsistent way
|
|
-- when adjusting for the changing number of sticky lines.
|
|
num_sticky_lines = config.plugins.sticky_scroll.max_sticky_lines
|
|
end
|
|
if ly < num_sticky_lines * lh then
|
|
self.scroll.to.y = self.scroll.to.y - ((num_sticky_lines * lh) - ly)
|
|
end
|
|
end
|
|
|
|
-- Generic commands
|
|
command.add(function() return config.plugins.sticky_scroll end, {
|
|
["sticky-lines:toggle"] = function()
|
|
config.plugins.sticky_scroll.enabled = not config.plugins.sticky_scroll.enabled
|
|
end
|
|
})
|
|
command.add(function() return config.plugins.sticky_scroll and not config.plugins.sticky_scroll.enabled end, {
|
|
["sticky-lines:enable"] = function()
|
|
config.plugins.sticky_scroll.enabled = true
|
|
end
|
|
})
|
|
command.add(function() return config.plugins.sticky_scroll and config.plugins.sticky_scroll.enabled end, {
|
|
["sticky-lines:disable"] = function()
|
|
config.plugins.sticky_scroll.enabled = false
|
|
end
|
|
})
|
|
|
|
-- Per-docview commands
|
|
command.add(SS.should_run, {
|
|
["sticky-lines:toggle-doc"] = function()
|
|
local dv = core.active_view
|
|
SS.managed_docviews[dv].enabled = not SS.managed_docviews[dv].enabled
|
|
end
|
|
})
|
|
command.add(function()
|
|
local dv = core.active_view
|
|
return SS.should_run() and not SS.managed_docviews[dv].enabled, dv
|
|
end, {
|
|
["sticky-lines:enable-doc"] = function(dv)
|
|
SS.managed_docviews[dv].enabled = true
|
|
end
|
|
})
|
|
command.add(function()
|
|
local dv = core.active_view
|
|
return SS.should_run() and SS.managed_docviews[dv].enabled, dv
|
|
end, {
|
|
["sticky-lines:disable-doc"] = function(dv)
|
|
SS.managed_docviews[dv].enabled = false
|
|
end
|
|
})
|
|
|
|
return SS
|