From 2b2eb81119f5865ad208f93ac11f116810346613 Mon Sep 17 00:00:00 2001 From: Price Hiller Date: Wed, 7 Feb 2024 14:41:24 -0600 Subject: [PATCH 1/6] feat: make VirtualIndent react to changes in `vim.b.org_indent_mode` --- lua/orgmode/org/indent.lua | 8 ++- lua/orgmode/ui/virtual_indent.lua | 95 ++++++++++++++++++++++++------- 2 files changed, 79 insertions(+), 24 deletions(-) diff --git a/lua/orgmode/org/indent.lua b/lua/orgmode/org/indent.lua index 0df4bcd61..95132ed60 100644 --- a/lua/orgmode/org/indent.lua +++ b/lua/orgmode/org/indent.lua @@ -314,8 +314,12 @@ end local function setup() local v = vim.version() - if config.org_startup_indented and not vim.version.lt({ v.major, v.minor, v.patch }, { 0, 10, 0 }) then - VirtualIndent:new():attach() + if not vim.version.lt({ v.major, v.minor, v.patch }, { 0, 10, 0 }) then + if config.org_startup_indented then + VirtualIndent:new():attach() + else + VirtualIndent:new():start_watch_org_indent() + end end end diff --git a/lua/orgmode/ui/virtual_indent.lua b/lua/orgmode/ui/virtual_indent.lua index 4354b786b..c900203bc 100644 --- a/lua/orgmode/ui/virtual_indent.lua +++ b/lua/orgmode/ui/virtual_indent.lua @@ -1,30 +1,48 @@ local tree_utils = require('orgmode.utils.treesitter') ---@class OrgVirtualIndent ---@field private _ns_id number extmarks namespace id +---@field private _bufnr integer Buffer VirtualIndent is attached to +---@field private _attached boolean Whether or not VirtualIndent is attached for its buffer +---@field private _bufnrs {integer: boolean} Buffers with VirtualIndent attached +---@field private _timer uv_timer_t Timer used for tracking `org_indent_mode` local VirtualIndent = { - enabled = false, - lib = {}, + _ns_id = vim.api.nvim_create_namespace('orgmode.ui.indent'), + _bufnrs = {}, } -function VirtualIndent:new() - if self.enabled then - return self +--- Creates a new instance of VirtualIndent for a given buffer or returns the existing instance if +--- one exists +---@param bufnr? integer Buffer to use for VirtualIndent when attached +---@return OrgVirtualIndent +function VirtualIndent:new(bufnr) + bufnr = bufnr or vim.api.nvim_get_current_buf() + + local curr_instance = VirtualIndent._bufnrs[bufnr] + if curr_instance then + return curr_instance end - self._ns_id = vim.api.nvim_create_namespace('orgmode.ui.indent') - self.enabled = true - return self + + local new = {} + setmetatable(new, self) + self.__index = self + + new._bufnr = bufnr + new._attached = false + VirtualIndent._bufnrs[new._bufnr] = new + new._timer = vim.uv.new_timer() + return new end -function VirtualIndent:_delete_old_extmarks(buffer, start_line, end_line) +function VirtualIndent:_delete_old_extmarks(start_line, end_line) local old_extmarks = vim.api.nvim_buf_get_extmarks( - buffer, + self._bufnr, self._ns_id, { start_line, 0 }, { end_line, 0 }, { type = 'virt_text' } ) for _, ext in ipairs(old_extmarks) do - vim.api.nvim_buf_del_extmark(buffer, self._ns_id, ext[1]) + vim.api.nvim_buf_del_extmark(self._bufnr, self._ns_id, ext[1]) end end @@ -43,11 +61,10 @@ function VirtualIndent:_get_indent_size(line) return 0 end ----@param bufnr number buffer id ---@param start_line number start line number to set the indentation, 0-based inclusive ---@param end_line number end line number to set the indentation, 0-based inclusive ---@param ignore_ts? boolean whether or not to skip the treesitter start & end lookup -function VirtualIndent:set_indent(bufnr, start_line, end_line, ignore_ts) +function VirtualIndent:set_indent(start_line, end_line, ignore_ts) ignore_ts = ignore_ts or false local headline = tree_utils.closest_headline_node({ start_line + 1, 1 }) if headline and not ignore_ts then @@ -60,13 +77,13 @@ function VirtualIndent:set_indent(bufnr, start_line, end_line, ignore_ts) if start_line > 0 then start_line = start_line - 1 end - self:_delete_old_extmarks(bufnr, start_line, end_line) + self:_delete_old_extmarks(start_line, end_line) for line = start_line, end_line do local indent = self:_get_indent_size(line) if indent > 0 then -- NOTE: `ephemeral = true` is not implemented for `inline` virt_text_pos :( - pcall(vim.api.nvim_buf_set_extmark, bufnr, self._ns_id, line, 0, { + pcall(vim.api.nvim_buf_set_extmark, self._bufnr, self._ns_id, line, 0, { virt_text = { { string.rep(' ', indent), 'OrgIndent' } }, virt_text_pos = 'inline', right_gravity = false, @@ -75,22 +92,56 @@ function VirtualIndent:set_indent(bufnr, start_line, end_line, ignore_ts) end end ----@param bufnr? number buffer id -function VirtualIndent:attach(bufnr) - bufnr = bufnr or 0 - self:set_indent(0, 0, vim.api.nvim_buf_line_count(bufnr) - 1, true) +--- Begins a timer to check `vim.b.org_indent_mode` and correctly attach or detatch VirtualIndent as +--- necessary +function VirtualIndent:start_watch_org_indent() + self._timer:start( + 50, + 50, + vim.schedule_wrap(function() + local success, indent_mode_enabled = pcall(vim.api.nvim_buf_get_var, self._bufnr, 'org_indent_mode') + if success and indent_mode_enabled then + if not self._attached then + self:attach() + end + elseif self._attached then + self:detach() + end + end) + ) +end + +--- Stops the current VirtualIndent instance from reacting to changes in `vim.b.org_indent_mode` +function VirtualIndent:stop_watch_org_indent() + self._timer:stop() +end + +--- Enables virtual indentation in registered buffer +function VirtualIndent:attach() + self._attached = true + self:set_indent(0, vim.api.nvim_buf_line_count(self._bufnr) - 1, true) + self:start_watch_org_indent() - vim.api.nvim_buf_attach(bufnr, false, { + vim.api.nvim_buf_attach(self._bufnr, false, { on_lines = function(_, _, _, start_line, _, end_line) + if not self._attached then + return true + end -- HACK: By calling `set_indent` twice, once synchronously and once in `vim.schedule` we get smooth usage of the -- virtual indent in most cases and still properly handle undo redo. Unfortunately this is called *early* when -- `undo` or `redo` is used causing the padding to be incorrect for some headlines. - self:set_indent(bufnr, start_line, end_line) + self:set_indent(start_line, end_line) vim.schedule(function() - self:set_indent(bufnr, start_line, end_line) + self:set_indent(start_line, end_line) end) end, }) end +function VirtualIndent:detach() + self._attached = false + vim.api.nvim_buf_set_var(self._bufnr, 'org_indent_mode', false) + self:_delete_old_extmarks(0, vim.api.nvim_buf_line_count(self._bufnr) - 1) +end + return VirtualIndent From ab22e896536861a9b7b4cd38d4c47b69644a37db Mon Sep 17 00:00:00 2001 From: Price Hiller Date: Sun, 28 Jan 2024 02:14:57 -0600 Subject: [PATCH 2/6] test: add test to validate VirtualIndent dynamic attach functionality --- tests/plenary/org/indent_spec.lua | 83 +++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/tests/plenary/org/indent_spec.lua b/tests/plenary/org/indent_spec.lua index 73b8303ba..d61883c81 100644 --- a/tests/plenary/org/indent_spec.lua +++ b/tests/plenary/org/indent_spec.lua @@ -338,4 +338,87 @@ describe('with "indent" and "VirtualIndent" is enabled', function() assert.are.equal(content_virtcols[line][2], vim.fn.virtcol('.')) end end) + + it('Virtual Indent detaches and reattaches in response to toggling `vim.b.org_indent_mode`', function() + if not vim.b.org_indent_mode then + return + end + + local content_virtcols = { + { '* TODO First task', 1 }, + { 'SCHEDULED: <1970-01-01 Thu>', 3 }, + { '', 2 }, + { '1. Ordered list', 3 }, + { ' a) nested list', 3 }, + { ' over-indented', 3 }, + { ' over-indented', 3 }, + { ' b) nested list', 3 }, + { ' under-indented', 3 }, + { '2. Ordered list', 3 }, + { 'Not part of the list', 3 }, + { '', 2 }, + { '** Second task', 1 }, + { 'DEADLINE: <1970-01-01 Thu>', 4 }, + { '', 3 }, + { '- Unordered list', 4 }, + { ' + nested list', 4 }, + { ' over-indented', 4 }, + { ' over-indented', 4 }, + { ' + nested list', 4 }, + { ' under-indented', 4 }, + { '- unordered list', 4 }, + { ' + nested list', 4 }, + { ' * triple nested list', 4 }, + { ' continuation', 4 }, + { ' part of the first-level list', 4 }, + { 'Not part of the list', 4 }, + { '', 3 }, + { '*** Incorrectly indented block', 1 }, + { '#+BEGIN_SRC json', 5 }, + { '{', 5 }, + { ' "key": "value",', 5 }, + { ' "another key": "another value"', 5 }, + { '}', 5 }, + { '#+END_SRC', 5 }, + { '', 4 }, + { '- Correctly reindents to list indentation level', 5 }, + { ' #+BEGIN_SRC json', 5 }, + { ' {', 5 }, + { ' "key": "value",', 5 }, + { ' "another key": "another value"', 5 }, + { ' }', 5 }, + { ' #+END_SRC', 5 }, + { '- Correctly reindents when entire block overindented', 5 }, + { ' #+BEGIN_SRC json', 5 }, + { ' {', 5 }, + { ' "key": "value",', 5 }, + { ' "another key": "another value"', 5 }, + { ' }', 5 }, + { ' #+END_SRC', 5 }, + } + local content = {} + for _, content_virtcol in pairs(content_virtcols) do + table.insert(content, content_virtcol[1]) + end + helpers.load_file_content(content) + + -- Check if VirtualIndent correctly detaches in response to disabling `vim.b.org_indent_mode` + vim.b.org_indent_mode = false + -- Give VirtualIndent long enough to react to the change in `vim.b.org_indent_mode` + vim.wait(60) + for line = 1, vim.api.nvim_buf_line_count(0) do + vim.api.nvim_win_set_cursor(0, { line, 0 }) + assert.are.equal(0, vim.fn.virtcol('.')) + end + + -- Check if VirtualIndent correctly attaches in response to disabling `vim.b.org_indent_mode` + vim.b.org_indent_mode = true + -- Give VirtualIndent long enough to react to the change in `vim.b.org_indent_mode` + vim.wait(60) + for line = 1, vim.api.nvim_buf_line_count(0) do + vim.api.nvim_win_set_cursor(0, { line, 0 }) + assert.are.same(content_virtcols[line][1], vim.api.nvim_buf_get_lines(0, line - 1, line, false)[1]) + assert.are.equal(content_virtcols[line][2], vim.fn.virtcol('.')) + end + end) end) From e5482dfab164f741b00dac06ebf0843460c8c3a3 Mon Sep 17 00:00:00 2001 From: Price Hiller Date: Sun, 28 Jan 2024 02:30:49 -0600 Subject: [PATCH 3/6] docs: update docs to reflect dynamic virtual indent attach --- DOCS.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/DOCS.md b/DOCS.md index 1620dfb0f..4bb543094 100644 --- a/DOCS.md +++ b/DOCS.md @@ -274,6 +274,10 @@ Possible values: * `true` - Uses *Virtual* indents to align content visually. The indents are only visual, they are not saved to the file. * `false` - Do not add any *Virtual* indentation. +You can toggle Virtual indents on the fly by setting `vim.b.org_indent_mode` to either `true` or `false` when in a org +buffer. For example, if virtual indents were enabled in the current buffer then you could disable them immediately by +setting `vim.b.org_indent_mode = false`. + This feature has no effect when enabled on Neovim versions < 0.10.0 #### **org_adapt_indentation** From ac27fa2e819fe16e81cf832f8443735ef71644a3 Mon Sep 17 00:00:00 2001 From: Price Hiller Date: Wed, 7 Feb 2024 11:17:25 -0600 Subject: [PATCH 4/6] 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. --- lua/orgmode/ui/virtual_indent.lua | 36 ++++++++++++++++++------------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/lua/orgmode/ui/virtual_indent.lua b/lua/orgmode/ui/virtual_indent.lua index c900203bc..5afbb825b 100644 --- a/lua/orgmode/ui/virtual_indent.lua +++ b/lua/orgmode/ui/virtual_indent.lua @@ -5,6 +5,7 @@ local tree_utils = require('orgmode.utils.treesitter') ---@field private _attached boolean Whether or not VirtualIndent is attached for its buffer ---@field private _bufnrs {integer: boolean} Buffers with VirtualIndent attached ---@field private _timer uv_timer_t Timer used for tracking `org_indent_mode` +---@field private _watcher_running boolean Whether or not VirtualIndent is reacting to `vim.borg_indent_mode` local VirtualIndent = { _ns_id = vim.api.nvim_create_namespace('orgmode.ui.indent'), _bufnrs = {}, @@ -29,6 +30,7 @@ function VirtualIndent:new(bufnr) new._bufnr = bufnr new._attached = false VirtualIndent._bufnrs[new._bufnr] = new + new._watcher_running = false new._timer = vim.uv.new_timer() return new end @@ -92,27 +94,31 @@ function VirtualIndent:set_indent(start_line, end_line, ignore_ts) end end ---- Begins a timer to check `vim.b.org_indent_mode` and correctly attach or detatch VirtualIndent as ---- necessary +--- Begins a timer to check `vim.b.org_indent_mode` if `vim.b.org_indent_mode` is not already being +--- monitored function VirtualIndent:start_watch_org_indent() - self._timer:start( - 50, - 50, - vim.schedule_wrap(function() - local success, indent_mode_enabled = pcall(vim.api.nvim_buf_get_var, self._bufnr, 'org_indent_mode') - if success and indent_mode_enabled then - if not self._attached then - self:attach() + if not self._watcher_running then + self._watcher_running = true + self._timer:start( + 50, + 50, + vim.schedule_wrap(function() + local success, indent_mode_enabled = pcall(vim.api.nvim_buf_get_var, self._bufnr, 'org_indent_mode') + if success and indent_mode_enabled then + if not self._attached then + self:attach() + end + elseif self._attached then + self:detach() end - elseif self._attached then - self:detach() - end - end) - ) + end) + ) + end end --- Stops the current VirtualIndent instance from reacting to changes in `vim.b.org_indent_mode` function VirtualIndent:stop_watch_org_indent() + self._watcher_running = false self._timer:stop() end From 9e26d13f9969cfe66b9aac3ee9f30630753a63c6 Mon Sep 17 00:00:00 2001 From: Price Hiller Date: Wed, 7 Feb 2024 15:10:46 -0600 Subject: [PATCH 5/6] feat: add dict_watcher utility --- lua/orgmode/utils/dict_watcher.lua | 32 ++++++++++++++++++++++++++++++ plugin/orgmode.vim | 4 ++++ 2 files changed, 36 insertions(+) create mode 100644 lua/orgmode/utils/dict_watcher.lua diff --git a/lua/orgmode/utils/dict_watcher.lua b/lua/orgmode/utils/dict_watcher.lua new file mode 100644 index 000000000..929be9028 --- /dev/null +++ b/lua/orgmode/utils/dict_watcher.lua @@ -0,0 +1,32 @@ +-- NOTE: Be aware of https://github.com/neovim/neovim/issues/21469. Upstream *might* decide to +-- deprecate this, seems unlikely but something to keep an eye on. +local watchers = {} +local M = {} + +---@param change_dict { old?: any, new: any } +---@param key string +---@param dict table +function M.dict_changed(change_dict, key, dict) + if watchers[key] then + watchers[key](change_dict, key, dict) + end +end + +---@param key string +---@param callback fun(change_dict: { old?: any, new: any }, key: string, dict: table) +function M.watch_buffer_variable(key, callback) + vim.cmd(([[ + call dictwatcheradd(b:, '%s', 'OrgmodeWatchDictChanges') + ]]):format(key)) + watchers[key] = callback +end + +---@param key string +function M.unwatch_buffer_variable(key) + vim.cmd(([[ + call dictwatcherdel(b:, '%s', 'OrgmodeWatchDictChanges') + ]]):format(key)) + watchers[key] = nil +end + +return M diff --git a/plugin/orgmode.vim b/plugin/orgmode.vim index ec965a43a..38b92e5a8 100644 --- a/plugin/orgmode.vim +++ b/plugin/orgmode.vim @@ -6,3 +6,7 @@ function! OrgmodeInput(prompt, default, ...) abort endif return input(a:prompt, a:default) endfunction + +function OrgmodeWatchDictChanges(dict, key, change_dict) abort + return luaeval('require("orgmode.utils.dict_watcher").dict_changed(_A[1], _A[2], _A[3])', [a:change_dict, a:key, a:dict]) +endfunction From 1d256cfd29b8d63b40d0c2afe92cad982c1d0e4a Mon Sep 17 00:00:00 2001 From: Price Hiller Date: Wed, 7 Feb 2024 21:05:46 -0600 Subject: [PATCH 6/6] refactor: use `dict_watcher` to monitor `vim.b.org_indent_mode` --- ftplugin/org.lua | 1 + lua/orgmode/ui/virtual_indent.lua | 43 ++++++++++++++----------------- 2 files changed, 20 insertions(+), 24 deletions(-) diff --git a/ftplugin/org.lua b/ftplugin/org.lua index b37f6269a..588753df8 100644 --- a/ftplugin/org.lua +++ b/ftplugin/org.lua @@ -15,6 +15,7 @@ if config.org_startup_indented then end require('orgmode.org.indent').setup() +vim.b.org_bufnr = vim.api.nvim_get_current_buf() vim.bo.modeline = false vim.opt_local.fillchars:append('fold: ') vim.opt_local.foldmethod = 'expr' diff --git a/lua/orgmode/ui/virtual_indent.lua b/lua/orgmode/ui/virtual_indent.lua index 5afbb825b..250fd365b 100644 --- a/lua/orgmode/ui/virtual_indent.lua +++ b/lua/orgmode/ui/virtual_indent.lua @@ -1,14 +1,15 @@ local tree_utils = require('orgmode.utils.treesitter') +local dict_watcher = require('orgmode.utils.dict_watcher') ---@class OrgVirtualIndent ---@field private _ns_id number extmarks namespace id ---@field private _bufnr integer Buffer VirtualIndent is attached to ---@field private _attached boolean Whether or not VirtualIndent is attached for its buffer ----@field private _bufnrs {integer: boolean} Buffers with VirtualIndent attached ----@field private _timer uv_timer_t Timer used for tracking `org_indent_mode` ----@field private _watcher_running boolean Whether or not VirtualIndent is reacting to `vim.borg_indent_mode` +---@field private _bufnrs table Buffers with VirtualIndent attached +---@field private _watcher_running boolean Whether or not VirtualIndent is reacting to `vim.b.org_indent_mode` local VirtualIndent = { _ns_id = vim.api.nvim_create_namespace('orgmode.ui.indent'), _bufnrs = {}, + _watcher_running = false, } --- Creates a new instance of VirtualIndent for a given buffer or returns the existing instance if @@ -24,14 +25,12 @@ function VirtualIndent:new(bufnr) end local new = {} + VirtualIndent._bufnrs[bufnr] = new setmetatable(new, self) self.__index = self new._bufnr = bufnr new._attached = false - VirtualIndent._bufnrs[new._bufnr] = new - new._watcher_running = false - new._timer = vim.uv.new_timer() return new end @@ -94,32 +93,28 @@ function VirtualIndent:set_indent(start_line, end_line, ignore_ts) end end ---- Begins a timer to check `vim.b.org_indent_mode` if `vim.b.org_indent_mode` is not already being ---- monitored +--- Make all VirtualIndent instances react to changes in `org_indent_mode` function VirtualIndent:start_watch_org_indent() if not self._watcher_running then self._watcher_running = true - self._timer:start( - 50, - 50, - vim.schedule_wrap(function() - local success, indent_mode_enabled = pcall(vim.api.nvim_buf_get_var, self._bufnr, 'org_indent_mode') - if success and indent_mode_enabled then - if not self._attached then - self:attach() - end - elseif self._attached then - self:detach() - end - end) - ) + dict_watcher.watch_buffer_variable('org_indent_mode', function(indent_mode, _, buf_vars) + local vindent = VirtualIndent._bufnrs[buf_vars.org_bufnr] + local indent_mode_enabled = indent_mode.new or false + ---@diagnostic disable-next-line: invisible + if indent_mode_enabled and not vindent._attached then + vindent:attach() + ---@diagnostic disable-next-line: invisible + elseif not indent_mode_enabled and vindent._attached then + vindent:detach() + end + end) end end ---- Stops the current VirtualIndent instance from reacting to changes in `vim.b.org_indent_mode` +--- Stops VirtualIndent instances from reacting to changes in `vim.b.org_indent_mode` function VirtualIndent:stop_watch_org_indent() self._watcher_running = false - self._timer:stop() + dict_watcher.unwatch_buffer_variable('org_indent_mode') end --- Enables virtual indentation in registered buffer