lite-xl/resources/amiga/addons/plugins/sticky_scroll.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