aboutsummaryrefslogtreecommitdiff
path: root/plugins/sticky_scroll.lua
diff options
context:
space:
mode:
authorGuldoman <giulio.lettieri@gmail.com>2023-03-07 12:53:13 +0100
committerGitHub <noreply@github.com>2023-03-07 12:53:13 +0100
commitbb76ef1d3f24535315816351ad62ea705154d708 (patch)
tree6ba2590016c7c52acc8fee7c1b7043839e659175 /plugins/sticky_scroll.lua
parentb08aa476fee15a8bcf3e3b5c9780236ab6a52586 (diff)
downloadlite-xl-plugins-bb76ef1d3f24535315816351ad62ea705154d708.tar.gz
lite-xl-plugins-bb76ef1d3f24535315816351ad62ea705154d708.zip
Add `sticky_scroll` (#218)
Diffstat (limited to 'plugins/sticky_scroll.lua')
-rw-r--r--plugins/sticky_scroll.lua429
1 files changed, 429 insertions, 0 deletions
diff --git a/plugins/sticky_scroll.lua b/plugins/sticky_scroll.lua
new file mode 100644
index 0000000..4586ea2
--- /dev/null
+++ b/plugins/sticky_scroll.lua
@@ -0,0 +1,429 @@
+-- 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