1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
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
|