Skip to content

Commit aaedd5c

Browse files
authored
feat: make VirtualIndent dynamically attach/detach based on vim.b.org_indent_mode (#658)
* feat: make VirtualIndent react to changes in `vim.b.org_indent_mode` * test: add test to validate VirtualIndent dynamic attach functionality * docs: update docs to reflect dynamic virtual indent attach * refactor: make VirtualIndent `start_watch_org_indent` idempotent This ensures we can call `start_watch_org_indent` as much as we want without starting a bunch of timers in the background. This enforces the use of `start_watch_org_indent` and `stop_watch_org_indent` for managing the timer. * feat: add dict_watcher utility * refactor: use `dict_watcher` to monitor `vim.b.org_indent_mode`
1 parent 72164f9 commit aaedd5c

File tree

7 files changed

+204
-24
lines changed

7 files changed

+204
-24
lines changed

DOCS.md

+4
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,10 @@ Possible values:
274274
* `true` - Uses *Virtual* indents to align content visually. The indents are only visual, they are not saved to the file.
275275
* `false` - Do not add any *Virtual* indentation.
276276

277+
You can toggle Virtual indents on the fly by setting `vim.b.org_indent_mode` to either `true` or `false` when in a org
278+
buffer. For example, if virtual indents were enabled in the current buffer then you could disable them immediately by
279+
setting `vim.b.org_indent_mode = false`.
280+
277281
This feature has no effect when enabled on Neovim versions < 0.10.0
278282

279283
#### **org_adapt_indentation**

ftplugin/org.lua

+1
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ if config.org_startup_indented then
1515
end
1616
require('orgmode.org.indent').setup()
1717

18+
vim.b.org_bufnr = vim.api.nvim_get_current_buf()
1819
vim.bo.modeline = false
1920
vim.opt_local.fillchars:append('fold: ')
2021
vim.opt_local.foldmethod = 'expr'

lua/orgmode/org/indent.lua

+6-2
Original file line numberDiff line numberDiff line change
@@ -314,8 +314,12 @@ end
314314
local function setup()
315315
local v = vim.version()
316316

317-
if config.org_startup_indented and not vim.version.lt({ v.major, v.minor, v.patch }, { 0, 10, 0 }) then
318-
VirtualIndent:new():attach()
317+
if not vim.version.lt({ v.major, v.minor, v.patch }, { 0, 10, 0 }) then
318+
if config.org_startup_indented then
319+
VirtualIndent:new():attach()
320+
else
321+
VirtualIndent:new():start_watch_org_indent()
322+
end
319323
end
320324
end
321325

lua/orgmode/ui/virtual_indent.lua

+74-22
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,49 @@
11
local tree_utils = require('orgmode.utils.treesitter')
2+
local dict_watcher = require('orgmode.utils.dict_watcher')
23
---@class OrgVirtualIndent
34
---@field private _ns_id number extmarks namespace id
5+
---@field private _bufnr integer Buffer VirtualIndent is attached to
6+
---@field private _attached boolean Whether or not VirtualIndent is attached for its buffer
7+
---@field private _bufnrs table<integer, OrgVirtualIndent> Buffers with VirtualIndent attached
8+
---@field private _watcher_running boolean Whether or not VirtualIndent is reacting to `vim.b.org_indent_mode`
49
local VirtualIndent = {
5-
enabled = false,
6-
lib = {},
10+
_ns_id = vim.api.nvim_create_namespace('orgmode.ui.indent'),
11+
_bufnrs = {},
12+
_watcher_running = false,
713
}
814

9-
function VirtualIndent:new()
10-
if self.enabled then
11-
return self
15+
--- Creates a new instance of VirtualIndent for a given buffer or returns the existing instance if
16+
--- one exists
17+
---@param bufnr? integer Buffer to use for VirtualIndent when attached
18+
---@return OrgVirtualIndent
19+
function VirtualIndent:new(bufnr)
20+
bufnr = bufnr or vim.api.nvim_get_current_buf()
21+
22+
local curr_instance = VirtualIndent._bufnrs[bufnr]
23+
if curr_instance then
24+
return curr_instance
1225
end
13-
self._ns_id = vim.api.nvim_create_namespace('orgmode.ui.indent')
14-
self.enabled = true
15-
return self
26+
27+
local new = {}
28+
VirtualIndent._bufnrs[bufnr] = new
29+
setmetatable(new, self)
30+
self.__index = self
31+
32+
new._bufnr = bufnr
33+
new._attached = false
34+
return new
1635
end
1736

18-
function VirtualIndent:_delete_old_extmarks(buffer, start_line, end_line)
37+
function VirtualIndent:_delete_old_extmarks(start_line, end_line)
1938
local old_extmarks = vim.api.nvim_buf_get_extmarks(
20-
buffer,
39+
self._bufnr,
2140
self._ns_id,
2241
{ start_line, 0 },
2342
{ end_line, 0 },
2443
{ type = 'virt_text' }
2544
)
2645
for _, ext in ipairs(old_extmarks) do
27-
vim.api.nvim_buf_del_extmark(buffer, self._ns_id, ext[1])
46+
vim.api.nvim_buf_del_extmark(self._bufnr, self._ns_id, ext[1])
2847
end
2948
end
3049

@@ -43,11 +62,10 @@ function VirtualIndent:_get_indent_size(line)
4362
return 0
4463
end
4564

46-
---@param bufnr number buffer id
4765
---@param start_line number start line number to set the indentation, 0-based inclusive
4866
---@param end_line number end line number to set the indentation, 0-based inclusive
4967
---@param ignore_ts? boolean whether or not to skip the treesitter start & end lookup
50-
function VirtualIndent:set_indent(bufnr, start_line, end_line, ignore_ts)
68+
function VirtualIndent:set_indent(start_line, end_line, ignore_ts)
5169
ignore_ts = ignore_ts or false
5270
local headline = tree_utils.closest_headline_node({ start_line + 1, 1 })
5371
if headline and not ignore_ts then
@@ -60,13 +78,13 @@ function VirtualIndent:set_indent(bufnr, start_line, end_line, ignore_ts)
6078
if start_line > 0 then
6179
start_line = start_line - 1
6280
end
63-
self:_delete_old_extmarks(bufnr, start_line, end_line)
81+
self:_delete_old_extmarks(start_line, end_line)
6482
for line = start_line, end_line do
6583
local indent = self:_get_indent_size(line)
6684

6785
if indent > 0 then
6886
-- NOTE: `ephemeral = true` is not implemented for `inline` virt_text_pos :(
69-
pcall(vim.api.nvim_buf_set_extmark, bufnr, self._ns_id, line, 0, {
87+
pcall(vim.api.nvim_buf_set_extmark, self._bufnr, self._ns_id, line, 0, {
7088
virt_text = { { string.rep(' ', indent), 'OrgIndent' } },
7189
virt_text_pos = 'inline',
7290
right_gravity = false,
@@ -75,22 +93,56 @@ function VirtualIndent:set_indent(bufnr, start_line, end_line, ignore_ts)
7593
end
7694
end
7795

78-
---@param bufnr? number buffer id
79-
function VirtualIndent:attach(bufnr)
80-
bufnr = bufnr or 0
81-
self:set_indent(0, 0, vim.api.nvim_buf_line_count(bufnr) - 1, true)
96+
--- Make all VirtualIndent instances react to changes in `org_indent_mode`
97+
function VirtualIndent:start_watch_org_indent()
98+
if not self._watcher_running then
99+
self._watcher_running = true
100+
dict_watcher.watch_buffer_variable('org_indent_mode', function(indent_mode, _, buf_vars)
101+
local vindent = VirtualIndent._bufnrs[buf_vars.org_bufnr]
102+
local indent_mode_enabled = indent_mode.new or false
103+
---@diagnostic disable-next-line: invisible
104+
if indent_mode_enabled and not vindent._attached then
105+
vindent:attach()
106+
---@diagnostic disable-next-line: invisible
107+
elseif not indent_mode_enabled and vindent._attached then
108+
vindent:detach()
109+
end
110+
end)
111+
end
112+
end
113+
114+
--- Stops VirtualIndent instances from reacting to changes in `vim.b.org_indent_mode`
115+
function VirtualIndent:stop_watch_org_indent()
116+
self._watcher_running = false
117+
dict_watcher.unwatch_buffer_variable('org_indent_mode')
118+
end
119+
120+
--- Enables virtual indentation in registered buffer
121+
function VirtualIndent:attach()
122+
self._attached = true
123+
self:set_indent(0, vim.api.nvim_buf_line_count(self._bufnr) - 1, true)
124+
self:start_watch_org_indent()
82125

83-
vim.api.nvim_buf_attach(bufnr, false, {
126+
vim.api.nvim_buf_attach(self._bufnr, false, {
84127
on_lines = function(_, _, _, start_line, _, end_line)
128+
if not self._attached then
129+
return true
130+
end
85131
-- HACK: By calling `set_indent` twice, once synchronously and once in `vim.schedule` we get smooth usage of the
86132
-- virtual indent in most cases and still properly handle undo redo. Unfortunately this is called *early* when
87133
-- `undo` or `redo` is used causing the padding to be incorrect for some headlines.
88-
self:set_indent(bufnr, start_line, end_line)
134+
self:set_indent(start_line, end_line)
89135
vim.schedule(function()
90-
self:set_indent(bufnr, start_line, end_line)
136+
self:set_indent(start_line, end_line)
91137
end)
92138
end,
93139
})
94140
end
95141

142+
function VirtualIndent:detach()
143+
self._attached = false
144+
vim.api.nvim_buf_set_var(self._bufnr, 'org_indent_mode', false)
145+
self:_delete_old_extmarks(0, vim.api.nvim_buf_line_count(self._bufnr) - 1)
146+
end
147+
96148
return VirtualIndent

lua/orgmode/utils/dict_watcher.lua

+32
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
-- NOTE: Be aware of https://github.com/neovim/neovim/issues/21469. Upstream *might* decide to
2+
-- deprecate this, seems unlikely but something to keep an eye on.
3+
local watchers = {}
4+
local M = {}
5+
6+
---@param change_dict { old?: any, new: any }
7+
---@param key string
8+
---@param dict table
9+
function M.dict_changed(change_dict, key, dict)
10+
if watchers[key] then
11+
watchers[key](change_dict, key, dict)
12+
end
13+
end
14+
15+
---@param key string
16+
---@param callback fun(change_dict: { old?: any, new: any }, key: string, dict: table)
17+
function M.watch_buffer_variable(key, callback)
18+
vim.cmd(([[
19+
call dictwatcheradd(b:, '%s', 'OrgmodeWatchDictChanges')
20+
]]):format(key))
21+
watchers[key] = callback
22+
end
23+
24+
---@param key string
25+
function M.unwatch_buffer_variable(key)
26+
vim.cmd(([[
27+
call dictwatcherdel(b:, '%s', 'OrgmodeWatchDictChanges')
28+
]]):format(key))
29+
watchers[key] = nil
30+
end
31+
32+
return M

plugin/orgmode.vim

+4
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,7 @@ function! OrgmodeInput(prompt, default, ...) abort
66
endif
77
return input(a:prompt, a:default)
88
endfunction
9+
10+
function OrgmodeWatchDictChanges(dict, key, change_dict) abort
11+
return luaeval('require("orgmode.utils.dict_watcher").dict_changed(_A[1], _A[2], _A[3])', [a:change_dict, a:key, a:dict])
12+
endfunction

tests/plenary/org/indent_spec.lua

+83
Original file line numberDiff line numberDiff line change
@@ -338,4 +338,87 @@ describe('with "indent" and "VirtualIndent" is enabled', function()
338338
assert.are.equal(content_virtcols[line][2], vim.fn.virtcol('.'))
339339
end
340340
end)
341+
342+
it('Virtual Indent detaches and reattaches in response to toggling `vim.b.org_indent_mode`', function()
343+
if not vim.b.org_indent_mode then
344+
return
345+
end
346+
347+
local content_virtcols = {
348+
{ '* TODO First task', 1 },
349+
{ 'SCHEDULED: <1970-01-01 Thu>', 3 },
350+
{ '', 2 },
351+
{ '1. Ordered list', 3 },
352+
{ ' a) nested list', 3 },
353+
{ ' over-indented', 3 },
354+
{ ' over-indented', 3 },
355+
{ ' b) nested list', 3 },
356+
{ ' under-indented', 3 },
357+
{ '2. Ordered list', 3 },
358+
{ 'Not part of the list', 3 },
359+
{ '', 2 },
360+
{ '** Second task', 1 },
361+
{ 'DEADLINE: <1970-01-01 Thu>', 4 },
362+
{ '', 3 },
363+
{ '- Unordered list', 4 },
364+
{ ' + nested list', 4 },
365+
{ ' over-indented', 4 },
366+
{ ' over-indented', 4 },
367+
{ ' + nested list', 4 },
368+
{ ' under-indented', 4 },
369+
{ '- unordered list', 4 },
370+
{ ' + nested list', 4 },
371+
{ ' * triple nested list', 4 },
372+
{ ' continuation', 4 },
373+
{ ' part of the first-level list', 4 },
374+
{ 'Not part of the list', 4 },
375+
{ '', 3 },
376+
{ '*** Incorrectly indented block', 1 },
377+
{ '#+BEGIN_SRC json', 5 },
378+
{ '{', 5 },
379+
{ ' "key": "value",', 5 },
380+
{ ' "another key": "another value"', 5 },
381+
{ '}', 5 },
382+
{ '#+END_SRC', 5 },
383+
{ '', 4 },
384+
{ '- Correctly reindents to list indentation level', 5 },
385+
{ ' #+BEGIN_SRC json', 5 },
386+
{ ' {', 5 },
387+
{ ' "key": "value",', 5 },
388+
{ ' "another key": "another value"', 5 },
389+
{ ' }', 5 },
390+
{ ' #+END_SRC', 5 },
391+
{ '- Correctly reindents when entire block overindented', 5 },
392+
{ ' #+BEGIN_SRC json', 5 },
393+
{ ' {', 5 },
394+
{ ' "key": "value",', 5 },
395+
{ ' "another key": "another value"', 5 },
396+
{ ' }', 5 },
397+
{ ' #+END_SRC', 5 },
398+
}
399+
local content = {}
400+
for _, content_virtcol in pairs(content_virtcols) do
401+
table.insert(content, content_virtcol[1])
402+
end
403+
helpers.load_file_content(content)
404+
405+
-- Check if VirtualIndent correctly detaches in response to disabling `vim.b.org_indent_mode`
406+
vim.b.org_indent_mode = false
407+
-- Give VirtualIndent long enough to react to the change in `vim.b.org_indent_mode`
408+
vim.wait(60)
409+
for line = 1, vim.api.nvim_buf_line_count(0) do
410+
vim.api.nvim_win_set_cursor(0, { line, 0 })
411+
assert.are.equal(0, vim.fn.virtcol('.'))
412+
end
413+
414+
-- Check if VirtualIndent correctly attaches in response to disabling `vim.b.org_indent_mode`
415+
vim.b.org_indent_mode = true
416+
-- Give VirtualIndent long enough to react to the change in `vim.b.org_indent_mode`
417+
vim.wait(60)
418+
for line = 1, vim.api.nvim_buf_line_count(0) do
419+
vim.api.nvim_win_set_cursor(0, { line, 0 })
420+
assert.are.same(content_virtcols[line][1], vim.api.nvim_buf_get_lines(0, line - 1, line, false)[1])
421+
assert.are.equal(content_virtcols[line][2], vim.fn.virtcol('.'))
422+
end
423+
end)
341424
end)

0 commit comments

Comments
 (0)