Skip to content

Commit 4f30b8f

Browse files
authored
Add Virtual Indent (#627)
Allows Virtual indentation to be used simmilar to emacs `org-indent-mode` by adding virtual spaces before text under headlines to align them with a given headline. This adds the following new options mirroring Emacs: - `org_startup_indented` - `org_adapt_indentation` - `org_indent_mode_turns_off_org_adapt_indentation` And deprecates the config option `org_indent_mode`. A shim was added for backwards compatibility so this is not a breaking change. See `DOCS.md` for additional details.
1 parent bd2a651 commit 4f30b8f

File tree

11 files changed

+364
-92
lines changed

11 files changed

+364
-92
lines changed

DOCS.md

+29-5
Original file line numberDiff line numberDiff line change
@@ -265,12 +265,31 @@ Possible values:
265265
* between `\[` and `\]` delimiters - example: `\[ a=-\sqrt{2} \]`
266266
* between `\(` and `\)` delimiters - example: `\( b=2 \)`
267267

268-
#### **org_indent_mode**
269-
*type*: `string`<br />
270-
*default value*: `indent`<br />
268+
#### **org_startup_indented**
269+
270+
*type*: `boolean`<br />
271+
*default value*: `false`<br />
271272
Possible values:
272-
* `indent` - Use default indentation that follows headlines/checkboxes/previous line indent
273-
* `noindent` - Disable indentation. All lines start from 1st column
273+
* `true` - Uses *Virtual* indents to align content visually. The indents are only visual, they are not saved to the file.
274+
* `false` - Do not add any *Virtual* indentation.
275+
276+
This feature has no effect when enabled on Neovim versions < 0.10.0
277+
278+
#### **org_adapt_indentation**
279+
280+
*type*: `boolean`<br />
281+
*default value*: `true`<br />
282+
Possible values:
283+
* `true` - Use *hard* indents for content under headlines. Files will save with indents relative to headlines.
284+
* `false` - Do not add any *hard* indents. Files will save without indentation relative to headlines.
285+
286+
#### **org_indent_mode_turns_off_org_adapt_indentation**
287+
288+
*type*: `boolean`<br />
289+
*default value*: `true`<br />
290+
Possible values:
291+
* `true` - Disable [`org_adapt_indentation`](#org_adapt_indentation) by default when [`org_startup_indented`](#org_startup_indented) is enabled.
292+
* `false` - Do not disable [`org_adapt_indentation`](#org_adapt_indentation) by default when [`org_startup_indented`](#org_startup_indented) is enabled.
274293

275294
#### **org_src_window_setup**
276295
*type*: `string|function`<br />
@@ -1585,6 +1604,11 @@ set statusline=%{v:lua.orgmode.statusline()}
15851604
## Changelog
15861605
To track breaking changes, subscribe to [Notice of breaking changes](https://github.com/nvim-orgmode/orgmode/issues/217) issue where those are announced.
15871606

1607+
#### 21 January 2024
1608+
1609+
* Option `org_indent_mode` was deprecated in favor of [org_startup_indented](#org_startup_indented). To remove the
1610+
warning use `org_startup_indented`. This was introduced to support Virtual Indent more in line with Emacs.
1611+
15881612
#### 24 October 2021
15891613
* Help mapping was changed from `?` to `g?` to avoid conflict with built in backward search. See issue [#106](https://github.com/nvim-orgmode/orgmode/issues/106).
15901614

ftplugin/org.lua

+5
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ config:setup_mappings('org')
1010
config:setup_mappings('text_objects')
1111
config:setup_foldlevel()
1212

13+
if config.org_startup_indented then
14+
vim.b.org_indent_mode = true
15+
end
16+
require("orgmode.org.indent").setup()
17+
1318
vim.bo.modeline = false
1419
vim.opt_local.fillchars:append('fold: ')
1520
vim.opt_local.foldmethod = 'expr'

lua/orgmode/config/defaults.lua

+3-1
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,9 @@ local DefaultConfig = {
3535
org_log_into_drawer = nil,
3636
org_highlight_latex_and_related = nil,
3737
org_custom_exports = {},
38-
org_indent_mode = 'indent',
38+
org_adapt_indentation = true,
39+
org_startup_indented = false,
40+
org_indent_mode_turns_off_org_adapt_indentation = true,
3941
org_time_stamp_rounding_minutes = 5,
4042
org_blank_before_new_entry = {
4143
heading = true,

lua/orgmode/config/init.lua

+12-1
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ function Config:extend(opts)
4040
opts.org_priority_default = self.opts.org_priority_default
4141
end
4242
self.opts = vim.tbl_deep_extend('force', self.opts, opts)
43+
if self.org_startup_indented then
44+
self.org_adapt_indentation = not self.org_indent_mode_turns_off_org_adapt_indentation
45+
end
4346
return self
4447
end
4548

@@ -143,6 +146,14 @@ function Config:_deprecation_notify(opts)
143146
end
144147
end
145148

149+
if opts.org_indent_mode and type(opts.org_indent_mode) == 'string' then
150+
table.insert(
151+
messages,
152+
'"org_indent_mode" is deprecated in favor of "org_startup_indented". Check the documentation about the new option.'
153+
)
154+
opts.org_startup_indented = (opts.org_indent_mode == 'indent')
155+
end
156+
146157
if #messages > 0 then
147158
-- Schedule so it gets printed out once whole init.vim is loaded
148159
vim.schedule(function()
@@ -406,7 +417,7 @@ end
406417
---@param amount number
407418
---@return string
408419
function Config:get_indent(amount)
409-
if self.opts.org_indent_mode == 'indent' then
420+
if self.org_adapt_indentation then
410421
return string.rep(' ', amount)
411422
end
412423
return ''

lua/orgmode/org/indent.lua

+12-4
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
11
local config = require('orgmode.config')
2-
local headline_lib = require('orgmode.treesitter.headline')
2+
local VirtualIndent = require('orgmode.ui.virtual_indent')
33
local ts_utils = require('nvim-treesitter.ts_utils')
44
local query = nil
55

66
local function get_indent_pad(linenr)
7-
local indent_mode = config.org_indent_mode == 'indent'
8-
if indent_mode then
9-
local headline = headline_lib.from_cursor({ linenr, 0 })
7+
if config.org_adapt_indentation then
8+
local headline = require('orgmode.treesitter.headline').from_cursor({ linenr, 0 })
109
if not headline then
1110
return 0
1211
end
@@ -309,7 +308,16 @@ local function foldtext()
309308
return line .. config.org_ellipsis
310309
end
311310

311+
local function setup()
312+
local v = vim.version()
313+
314+
if config.org_startup_indented and not vim.version.lt({ v.major, v.minor, v.patch }, { 0, 10, 0 }) then
315+
VirtualIndent:new():attach()
316+
end
317+
end
318+
312319
return {
320+
setup = setup,
313321
foldexpr = foldexpr,
314322
indentexpr = indentexpr,
315323
foldtext = foldtext,

lua/orgmode/parser/section.lua

+2-2
Original file line numberDiff line numberDiff line change
@@ -411,7 +411,7 @@ end
411411
function Section:demote(amount, demote_child_sections, dryRun)
412412
amount = amount or 1
413413
demote_child_sections = demote_child_sections or false
414-
local should_indent = config.org_indent_mode == 'indent'
414+
local should_indent = config.org_adapt_indentation
415415
local lines = {}
416416
local headline_line = string.rep('*', amount) .. self.line
417417
table.insert(lines, headline_line)
@@ -449,7 +449,7 @@ end
449449
function Section:promote(amount, promote_child_sections, dryRun)
450450
amount = amount or 1
451451
promote_child_sections = promote_child_sections or false
452-
local should_dedent = config.org_indent_mode == 'indent'
452+
local should_dedent = config.org_adapt_indentation
453453
local lines = {}
454454
if self.level == 1 then
455455
utils.echo_warning('Cannot demote top level heading.')

lua/orgmode/treesitter/headline.lua

+22-7
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ local Date = require('orgmode.objects.date')
55
local Range = require('orgmode.parser.range')
66
local config = require('orgmode.config')
77
local ts = vim.treesitter
8+
local indent = require('orgmode.org.indent')
89

910
---@class Headline
1011
---@field headline TSNode
@@ -62,12 +63,18 @@ function Headline:promote(amount, recursive)
6263
return utils.echo_warning('Cannot demote top level heading.')
6364
end
6465

65-
return self:_handle_promote_demote(recursive, function(lines)
66+
return self:_handle_promote_demote(recursive, function(start_line, lines)
6667
for i, line in ipairs(lines) do
6768
if line:sub(1, 1) == '*' then
6869
lines[i] = line:sub(1 + amount)
6970
elseif vim.trim(line:sub(1, amount)) == '' then
70-
lines[i] = line:sub(1 + amount)
71+
if config.org_adapt_indentation then
72+
lines[i] = line:sub(1 + amount)
73+
else
74+
line, _ = line:gsub('^%s+', '')
75+
local indent_amount = indent.indentexpr(start_line + i)
76+
lines[i] = string.rep(' ', indent_amount) .. line
77+
end
7178
end
7279
end
7380
return lines
@@ -80,12 +87,18 @@ function Headline:demote(amount, recursive)
8087
amount = amount or 1
8188
recursive = recursive or false
8289

83-
return self:_handle_promote_demote(recursive, function(lines)
90+
return self:_handle_promote_demote(recursive, function(start_line, lines)
8491
for i, line in ipairs(lines) do
8592
if line:sub(1, 1) == '*' then
8693
lines[i] = string.rep('*', amount) .. line
8794
else
88-
lines[i] = config:apply_indent(line, amount)
95+
if config.org_adapt_indentation then
96+
lines[i] = config:apply_indent(line, amount)
97+
else
98+
line, _ = line:gsub('^%s+', '')
99+
local indent_amount = indent.indentexpr(start_line + i)
100+
lines[i] = string.rep(' ', indent_amount) .. line
101+
end
89102
end
90103
end
91104
return lines
@@ -94,8 +107,10 @@ end
94107

95108
function Headline:_handle_promote_demote(recursive, modifier)
96109
local whole_subtree = function()
97-
local text = ts.get_node_text(self.headline:parent(), 0)
98-
local lines = modifier(vim.split(text, '\n', true))
110+
local parent = self.headline:parent()
111+
local text = ts.get_node_text(parent, 0)
112+
local start, _, _ = parent:start()
113+
local lines = modifier(start, vim.split(text, '\n', true))
99114
tree_utils.set_node_lines(self.headline:parent(), lines)
100115
return self:refresh()
101116
end
@@ -118,7 +133,7 @@ function Headline:_handle_promote_demote(recursive, modifier)
118133

119134
local start = self.headline:start()
120135
local end_line = first_child_section:start()
121-
local lines = modifier(vim.api.nvim_buf_get_lines(0, start, end_line, false))
136+
local lines = modifier(start, vim.api.nvim_buf_get_lines(0, start, end_line, false))
122137
vim.api.nvim_buf_set_lines(0, start, end_line, false, lines)
123138
return self:refresh()
124139
end

lua/orgmode/ui/virtual_indent.lua

+93
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
---@class VirtualIndent
2+
---@field private _ns_id number extmarks namespace id
3+
local VirtualIndent = {
4+
enabled = false,
5+
lib = {},
6+
}
7+
8+
function VirtualIndent:new()
9+
if self.enabled then
10+
return self
11+
end
12+
self._ns_id = vim.api.nvim_create_namespace('orgmode.ui.indent')
13+
self.lib.headline = require('orgmode.treesitter.headline')
14+
self.enabled = true
15+
return self
16+
end
17+
18+
function VirtualIndent:_delete_old_extmarks(buffer, start_line, end_line)
19+
local old_extmarks = vim.api.nvim_buf_get_extmarks(
20+
buffer,
21+
self._ns_id,
22+
{ start_line, 0 },
23+
{ end_line, 0 },
24+
{ type = 'virt_text' }
25+
)
26+
for _, ext in ipairs(old_extmarks) do
27+
vim.api.nvim_buf_del_extmark(buffer, self._ns_id, ext[1])
28+
end
29+
end
30+
31+
function VirtualIndent:_get_indent_size(line)
32+
local headline = self.lib.headline.from_cursor({ line + 1, 1 })
33+
34+
if headline then
35+
local headline_line, _, _ = headline.headline:start()
36+
37+
if headline_line ~= line then
38+
return headline:level() + 1
39+
end
40+
end
41+
42+
return 0
43+
end
44+
45+
---@param bufnr number buffer id
46+
---@param start_line number start line number to set the indentation, 0-based inclusive
47+
---@param end_line number end line number to set the indentation, 0-based inclusive
48+
---@param ignore_ts? boolean whether or not to skip the treesitter start & end lookup
49+
function VirtualIndent:set_indent(bufnr, start_line, end_line, ignore_ts)
50+
ignore_ts = ignore_ts or false
51+
local headline = self.lib.headline.from_cursor({ start_line + 1, 1 })
52+
if headline and not ignore_ts then
53+
local parent = headline.headline:parent()
54+
start_line = parent:start()
55+
end_line = parent:end_()
56+
end
57+
if start_line > 0 then
58+
start_line = start_line - 1
59+
end
60+
self:_delete_old_extmarks(bufnr, start_line, end_line)
61+
for line = start_line, end_line do
62+
local indent = self:_get_indent_size(line)
63+
64+
if indent > 0 then
65+
-- NOTE: `ephemeral = true` is not implemented for `inline` virt_text_pos :(
66+
vim.api.nvim_buf_set_extmark(bufnr, self._ns_id, line, 0, {
67+
virt_text = { { string.rep(' ', indent), 'OrgIndent' } },
68+
virt_text_pos = 'inline',
69+
right_gravity = false,
70+
})
71+
end
72+
end
73+
end
74+
75+
---@param bufnr? number buffer id
76+
function VirtualIndent:attach(bufnr)
77+
bufnr = bufnr or 0
78+
self:set_indent(0, 0, vim.api.nvim_buf_line_count(bufnr) - 1, true)
79+
80+
vim.api.nvim_buf_attach(bufnr, false, {
81+
on_lines = function(_, _, _, start_line, _, end_line)
82+
-- HACK: By calling `set_indent` twice, once synchronously and once in `vim.schedule` we get smooth usage of the
83+
-- virtual indent in most cases and still properly handle undo redo. Unfortunately this is called *early* when
84+
-- `undo` or `redo` is used causing the padding to be incorrect for some headlines.
85+
self:set_indent(bufnr, start_line, end_line)
86+
vim.schedule(function()
87+
self:set_indent(bufnr, start_line, end_line)
88+
end)
89+
end,
90+
})
91+
end
92+
93+
return VirtualIndent

lua/orgmode/utils/treesitter.lua

+5-1
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,11 @@ end
9191
-- returns the nearest headline
9292
function M.closest_headline(cursor)
9393
vim.treesitter.get_parser(0, 'org', {}):parse()
94-
return M.find_headline(M.get_node_at_cursor(cursor))
94+
local node = M.get_node_at_cursor(cursor)
95+
if not node then
96+
return nil
97+
end
98+
return M.find_headline(node)
9599
end
96100

97101
function M.find_parent_type(node, type)

0 commit comments

Comments
 (0)