aboutsummaryrefslogtreecommitdiff
path: root/plugins/sticky_scroll.lua
blob: 4586ea2a6f0145c45e9788f72f5a907465133441 (plain)
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