diff --git a/DOCS.md b/DOCS.md index 9d7f581b2..3d850501a 100644 --- a/DOCS.md +++ b/DOCS.md @@ -265,12 +265,31 @@ Possible values: * between `\[` and `\]` delimiters - example: `\[ a=-\sqrt{2} \]` * between `\(` and `\)` delimiters - example: `\( b=2 \)` -#### **org_indent_mode** -*type*: `string`
-*default value*: `indent`
+#### **org_startup_indented** + +*type*: `boolean`
+*default value*: `false`
Possible values: -* `indent` - Use default indentation that follows headlines/checkboxes/previous line indent -* `noindent` - Disable indentation. All lines start from 1st column +* `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. + +This feature has no effect when enabled on Neovim versions < 0.10.0 + +#### **org_adapt_indentation** + +*type*: `boolean`
+*default value*: `true`
+Possible values: +* `true` - Use *hard* indents for content under headlines. Files will save with indents relative to headlines. +* `false` - Do not add any *hard* indents. Files will save without indentation relative to headlines. + +#### **org_indent_mode_turns_off_org_adapt_indentation** + +*type*: `boolean`
+*default value*: `true`
+Possible values: +* `true` - Disable [`org_adapt_indentation`](#org_adapt_indentation) by default when [`org_startup_indented`](#org_startup_indented) is enabled. +* `false` - Do not disable [`org_adapt_indentation`](#org_adapt_indentation) by default when [`org_startup_indented`](#org_startup_indented) is enabled. #### **org_src_window_setup** *type*: `string|function`
@@ -1550,6 +1569,11 @@ set statusline=%{v:lua.orgmode.statusline()} ## Changelog To track breaking changes, subscribe to [Notice of breaking changes](https://github.com/nvim-orgmode/orgmode/issues/217) issue where those are announced. +#### 21 January 2024 + +* Option `org_indent_mode` was deprecated in favor of [org_startup_indented](#org_startup_indented). To remove the + warning use `org_startup_indented`. This was introduced to support Virtual Indent more in line with Emacs. + #### 24 October 2021 * 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). diff --git a/ftplugin/org.lua b/ftplugin/org.lua index 33ceac385..557699e84 100644 --- a/ftplugin/org.lua +++ b/ftplugin/org.lua @@ -10,6 +10,11 @@ config:setup_mappings('org') config:setup_mappings('text_objects') config:setup_foldlevel() +if config.org_startup_indented then + vim.b.org_indent_mode = true +end +require("orgmode.org.indent").setup() + vim.bo.modeline = false vim.opt_local.fillchars:append('fold: ') vim.opt_local.foldmethod = 'expr' diff --git a/lua/orgmode/config/defaults.lua b/lua/orgmode/config/defaults.lua index 49334e387..25bdb91c0 100644 --- a/lua/orgmode/config/defaults.lua +++ b/lua/orgmode/config/defaults.lua @@ -34,7 +34,9 @@ local DefaultConfig = { org_log_into_drawer = nil, org_highlight_latex_and_related = nil, org_custom_exports = {}, - org_indent_mode = 'indent', + org_adapt_indentation = true, + org_startup_indented = false, + org_indent_mode_turns_off_org_adapt_indentation = true, org_time_stamp_rounding_minutes = 5, org_blank_before_new_entry = { heading = true, diff --git a/lua/orgmode/config/init.lua b/lua/orgmode/config/init.lua index ee22fc196..a740d6611 100644 --- a/lua/orgmode/config/init.lua +++ b/lua/orgmode/config/init.lua @@ -40,6 +40,9 @@ function Config:extend(opts) opts.org_priority_default = self.opts.org_priority_default end self.opts = vim.tbl_deep_extend('force', self.opts, opts) + if self.org_startup_indented then + self.org_adapt_indentation = not self.org_indent_mode_turns_off_org_adapt_indentation + end return self end @@ -143,6 +146,14 @@ function Config:_deprecation_notify(opts) end end + if opts.org_indent_mode and type(opts.org_indent_mode) == 'string' then + table.insert( + messages, + '"org_indent_mode" is deprecated in favor of "org_startup_indented". Check the documentation about the new option.' + ) + opts.org_startup_indented = (opts.org_indent_mode == 'indent') + end + if #messages > 0 then -- Schedule so it gets printed out once whole init.vim is loaded vim.schedule(function() @@ -406,7 +417,7 @@ end ---@param amount number ---@return string function Config:get_indent(amount) - if self.opts.org_indent_mode == 'indent' then + if self.org_adapt_indentation then return string.rep(' ', amount) end return '' diff --git a/lua/orgmode/org/indent.lua b/lua/orgmode/org/indent.lua index 390e0ff4a..3f183de88 100644 --- a/lua/orgmode/org/indent.lua +++ b/lua/orgmode/org/indent.lua @@ -1,12 +1,11 @@ local config = require('orgmode.config') -local headline_lib = require('orgmode.treesitter.headline') +local VirtualIndent = require('orgmode.ui.virtual_indent') local ts_utils = require('nvim-treesitter.ts_utils') local query = nil local function get_indent_pad(linenr) - local indent_mode = config.org_indent_mode == 'indent' - if indent_mode then - local headline = headline_lib.from_cursor({ linenr, 0 }) + if config.org_adapt_indentation then + local headline = require('orgmode.treesitter.headline').from_cursor({ linenr, 0 }) if not headline then return 0 end @@ -309,7 +308,16 @@ local function foldtext() return line .. config.org_ellipsis 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() + end +end + return { + setup = setup, foldexpr = foldexpr, indentexpr = indentexpr, foldtext = foldtext, diff --git a/lua/orgmode/parser/section.lua b/lua/orgmode/parser/section.lua index e8550e3e4..e5e812c37 100644 --- a/lua/orgmode/parser/section.lua +++ b/lua/orgmode/parser/section.lua @@ -406,7 +406,7 @@ end function Section:demote(amount, demote_child_sections, dryRun) amount = amount or 1 demote_child_sections = demote_child_sections or false - local should_indent = config.org_indent_mode == 'indent' + local should_indent = config.org_adapt_indentation local lines = {} local headline_line = string.rep('*', amount) .. self.line table.insert(lines, headline_line) @@ -444,7 +444,7 @@ end function Section:promote(amount, promote_child_sections, dryRun) amount = amount or 1 promote_child_sections = promote_child_sections or false - local should_dedent = config.org_indent_mode == 'indent' + local should_dedent = config.org_adapt_indentation local lines = {} if self.level == 1 then utils.echo_warning('Cannot demote top level heading.') diff --git a/lua/orgmode/treesitter/headline.lua b/lua/orgmode/treesitter/headline.lua index 48df63637..df68908d0 100644 --- a/lua/orgmode/treesitter/headline.lua +++ b/lua/orgmode/treesitter/headline.lua @@ -5,6 +5,7 @@ local Date = require('orgmode.objects.date') local Range = require('orgmode.parser.range') local config = require('orgmode.config') local ts = vim.treesitter +local indent = require('orgmode.org.indent') ---@class Headline ---@field headline userdata @@ -62,12 +63,18 @@ function Headline:promote(amount, recursive) return utils.echo_warning('Cannot demote top level heading.') end - return self:_handle_promote_demote(recursive, function(lines) + return self:_handle_promote_demote(recursive, function(start_line, lines) for i, line in ipairs(lines) do if line:sub(1, 1) == '*' then lines[i] = line:sub(1 + amount) elseif vim.trim(line:sub(1, amount)) == '' then - lines[i] = line:sub(1 + amount) + if config.org_adapt_indentation then + lines[i] = line:sub(1 + amount) + else + line, _ = line:gsub('^%s+', '') + local indent_amount = indent.indentexpr(start_line + i) + lines[i] = string.rep(' ', indent_amount) .. line + end end end return lines @@ -80,12 +87,18 @@ function Headline:demote(amount, recursive) amount = amount or 1 recursive = recursive or false - return self:_handle_promote_demote(recursive, function(lines) + return self:_handle_promote_demote(recursive, function(start_line, lines) for i, line in ipairs(lines) do if line:sub(1, 1) == '*' then lines[i] = string.rep('*', amount) .. line else - lines[i] = config:apply_indent(line, amount) + if config.org_adapt_indentation then + lines[i] = config:apply_indent(line, amount) + else + line, _ = line:gsub('^%s+', '') + local indent_amount = indent.indentexpr(start_line + i) + lines[i] = string.rep(' ', indent_amount) .. line + end end end return lines @@ -94,8 +107,10 @@ end function Headline:_handle_promote_demote(recursive, modifier) local whole_subtree = function() - local text = ts.get_node_text(self.headline:parent(), 0) - local lines = modifier(vim.split(text, '\n', true)) + local parent = self.headline:parent() + local text = ts.get_node_text(parent, 0) + local start, _, _ = parent:start() + local lines = modifier(start, vim.split(text, '\n', true)) tree_utils.set_node_lines(self.headline:parent(), lines) return self:refresh() end @@ -118,7 +133,7 @@ function Headline:_handle_promote_demote(recursive, modifier) local start = self.headline:start() local end_line = first_child_section:start() - local lines = modifier(vim.api.nvim_buf_get_lines(0, start, end_line, false)) + local lines = modifier(start, vim.api.nvim_buf_get_lines(0, start, end_line, false)) vim.api.nvim_buf_set_lines(0, start, end_line, false, lines) return self:refresh() end diff --git a/lua/orgmode/ui/virtual_indent.lua b/lua/orgmode/ui/virtual_indent.lua new file mode 100644 index 000000000..1b5a75f7e --- /dev/null +++ b/lua/orgmode/ui/virtual_indent.lua @@ -0,0 +1,93 @@ +---@class VirtualIndent +---@field private _ns_id number extmarks namespace id +local VirtualIndent = { + enabled = false, + lib = {}, +} + +function VirtualIndent:new() + if self.enabled then + return self + end + self._ns_id = vim.api.nvim_create_namespace('orgmode.ui.indent') + self.lib.headline = require('orgmode.treesitter.headline') + self.enabled = true + return self +end + +function VirtualIndent:_delete_old_extmarks(buffer, start_line, end_line) + local old_extmarks = vim.api.nvim_buf_get_extmarks( + buffer, + 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]) + end +end + +function VirtualIndent:_get_indent_size(line) + local headline = self.lib.headline.from_cursor({ line + 1, 1 }) + + if headline then + local headline_line, _, _ = headline.headline:start() + + if headline_line ~= line then + return headline:level() + 1 + end + end + + 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) + ignore_ts = ignore_ts or false + local headline = self.lib.headline.from_cursor({ start_line + 1, 1 }) + if headline and not ignore_ts then + local parent = headline.headline:parent() + start_line = parent:start() + end_line = parent:end_() + end + if start_line > 0 then + start_line = start_line - 1 + end + self:_delete_old_extmarks(bufnr, 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 :( + vim.api.nvim_buf_set_extmark(bufnr, self._ns_id, line, 0, { + virt_text = { { string.rep(' ', indent), 'OrgIndent' } }, + virt_text_pos = 'inline', + right_gravity = false, + }) + end + 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) + + vim.api.nvim_buf_attach(bufnr, false, { + on_lines = function(_, _, _, start_line, _, end_line) + -- 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) + vim.schedule(function() + self:set_indent(bufnr, start_line, end_line) + end) + end, + }) +end + +return VirtualIndent diff --git a/lua/orgmode/utils/treesitter.lua b/lua/orgmode/utils/treesitter.lua index 4d0b41412..c059da3bb 100644 --- a/lua/orgmode/utils/treesitter.lua +++ b/lua/orgmode/utils/treesitter.lua @@ -91,7 +91,11 @@ end -- returns the nearest headline function M.closest_headline(cursor) vim.treesitter.get_parser(0, 'org', {}):parse() - return M.find_headline(M.get_node_at_cursor(cursor)) + local node = M.get_node_at_cursor(cursor) + if not node then + return nil + end + return M.find_headline(node) end function M.find_parent_type(node, type) diff --git a/tests/plenary/org/indent_spec.lua b/tests/plenary/org/indent_spec.lua index 3c05bf3e9..3d7e5efc7 100644 --- a/tests/plenary/org/indent_spec.lua +++ b/tests/plenary/org/indent_spec.lua @@ -1,5 +1,4 @@ local config = require('orgmode.config') -local Indent = require('orgmode.org.indent') local helpers = require('tests.plenary.ui.helpers') -- Helper assert function. @@ -7,8 +6,8 @@ local function expect_whole_buffer(expected) assert.are.same(expected, vim.api.nvim_buf_get_lines(0, 0, -1, false)) end --- We want to run all tests under both values for `org_indent_mode`: "indent" --- and "noindent". So it is easier to put all tests into test functions and +-- We want to run all tests under both values for `org_startup_indented`: "true" +-- and "false". So it is easier to put all tests into test functions and -- check the indent mode, then run them under two different `describe()`. local function test_full_reindent() @@ -67,7 +66,7 @@ local function test_full_reindent() helpers.load_file_content(unformatted_file) vim.cmd([[silent norm 0gg=G]]) local expected - if config.org_indent_mode == 'indent' then + if config.org_adapt_indentation then expected = { '* TODO First task', ' SCHEDULED: <1970-01-01 Thu>', @@ -120,7 +119,7 @@ local function test_full_reindent() ' }', ' #+END_SRC', } - elseif config.org_indent_mode == 'noindent' then + else expected = { '* TODO First task', 'SCHEDULED: <1970-01-01 Thu>', @@ -181,20 +180,11 @@ local function test_newly_written_list() helpers.load_file_content({}) local user_input = vim.api.nvim_replace_termcodes('i- new itemsecond linethird line', true, true, true) vim.api.nvim_feedkeys(user_input, 'ntix', false) - local expected - if config.org_indent_mode == 'indent' then - expected = { - '- new item', - ' second line', - ' third line', - } - elseif config.org_indent_mode == 'noindent' then - expected = { - '- new item', - ' second line', - ' third line', - } - end + local expected = { + '- new item', + ' second line', + ' third line', + } expect_whole_buffer(expected) end @@ -203,24 +193,13 @@ local function test_insertion_to_an_existing_list() vim.cmd([[normal! o]]) local user_input = vim.api.nvim_replace_termcodes('i- new itemsecond linethird line', true, true, true) vim.api.nvim_feedkeys(user_input, 'ntix', false) - local expected - if config.org_indent_mode == 'indent' then - expected = { - '- first item', - '- new item', - ' second line', - ' third line', - '- third item', - } - elseif config.org_indent_mode == 'noindent' then - expected = { - '- first item', - '- new item', - ' second line', - ' third line', - '- third item', - } - end + local expected = { + '- first item', + '- new item', + ' second line', + ' third line', + '- third item', + } expect_whole_buffer(expected) end @@ -241,7 +220,7 @@ end describe('with "indent",', function() before_each(function() - config:extend({ org_indent_mode = 'indent' }) + config:extend({ org_startup_indented = true }) end) it('"0gg=G" reindents the whole file', function() @@ -263,7 +242,7 @@ end) describe('with "noindent",', function() before_each(function() - config:extend({ org_indent_mode = 'noindent' }) + config:extend({ org_startup_indented = false }) end) it('"0gg=G" reindents the whole file', function() @@ -282,3 +261,81 @@ describe('with "noindent",', function() test_add_line_breaks_to_existing_file() end) end) + +describe('with "indent" and "VirtualIndent" is enabled', function() + before_each(function() + config:extend({ org_startup_indented = true }) + end) + + it('has the correct amount of virtual indentation', function() + if not vim.b.org_indent_mode then + return + end + + -- In order: { content, virtcol } + -- See `:h virtcol` for details + 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) + + 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) diff --git a/tests/plenary/ui/mappings/headline_spec.lua b/tests/plenary/ui/mappings/headline_spec.lua index 9fcae7830..522960dd5 100644 --- a/tests/plenary/ui/mappings/headline_spec.lua +++ b/tests/plenary/ui/mappings/headline_spec.lua @@ -1,4 +1,5 @@ local helpers = require('tests.plenary.ui.helpers') +local config = require('orgmode.config') describe('Heading mappings', function() after_each(function() @@ -90,26 +91,52 @@ describe('Heading mappings', function() 'Content Level 3', }, vim.api.nvim_buf_get_lines(0, 2, 8, false)) vim.fn.cursor(3, 1) + local check + if config.org_adapt_indentation then + check = { + '** TODO Test orgmode', + ' DEADLINE: <2021-07-21 Wed 22:02>', + '*** TODO [#A] Test orgmode level 2 :PRIVATE:', + ' Some content for level 2', + '**** NEXT [#1] Level 3', + ' Content Level 3', + } + else + check = { + '** TODO Test orgmode', + 'DEADLINE: <2021-07-21 Wed 22:02>', + '*** TODO [#A] Test orgmode level 2 :PRIVATE:', + 'Some content for level 2', + '**** NEXT [#1] Level 3', + 'Content Level 3', + } + end vim.cmd([[norm >s]]) - assert.are.same({ - '** TODO Test orgmode', - ' DEADLINE: <2021-07-21 Wed 22:02>', - '*** TODO [#A] Test orgmode level 2 :PRIVATE:', - ' Some content for level 2', - '**** NEXT [#1] Level 3', - ' Content Level 3', - }, vim.api.nvim_buf_get_lines(0, 2, 8, false)) + assert.are.same(check, vim.api.nvim_buf_get_lines(0, 2, 8, false)) -- Support count + local check + if config.org_adapt_indentation then + check = { + '****** TODO Test orgmode', + ' DEADLINE: <2021-07-21 Wed 22:02>', + '******* TODO [#A] Test orgmode level 2 :PRIVATE:', + ' Some content for level 2', + '******** NEXT [#1] Level 3', + ' Content Level 3', + } + else + check = { + '****** TODO Test orgmode', + 'DEADLINE: <2021-07-21 Wed 22:02>', + '******* TODO [#A] Test orgmode level 2 :PRIVATE:', + 'Some content for level 2', + '******** NEXT [#1] Level 3', + 'Content Level 3', + } + end vim.cmd([[norm 4>s]]) - assert.are.same({ - '****** TODO Test orgmode', - ' DEADLINE: <2021-07-21 Wed 22:02>', - '******* TODO [#A] Test orgmode level 2 :PRIVATE:', - ' Some content for level 2', - '******** NEXT [#1] Level 3', - ' Content Level 3', - }, vim.api.nvim_buf_get_lines(0, 2, 8, false)) + assert.are.same(check, vim.api.nvim_buf_get_lines(0, 2, 8, false)) end) it('should promote the heading (org_do_promote)', function() @@ -177,26 +204,52 @@ describe('Heading mappings', function() vim.fn.cursor(1, 1) -- Support count + local check + if config.org_adapt_indentation then + check = { + '*** TODO Test orgmode', + ' DEADLINE: <2021-07-21 Wed 22:02>', + '**** TODO [#A] Test orgmode level 2 :PRIVATE:', + ' Some content for level 2', + '***** NEXT [#1] Level 3', + ' Content Level 3', + } + else + check = { + '*** TODO Test orgmode', + 'DEADLINE: <2021-07-21 Wed 22:02>', + '**** TODO [#A] Test orgmode level 2 :PRIVATE:', + 'Some content for level 2', + '***** NEXT [#1] Level 3', + 'Content Level 3', + } + end vim.cmd([[norm 2', - '**** TODO [#A] Test orgmode level 2 :PRIVATE:', - ' Some content for level 2', - '***** NEXT [#1] Level 3', - ' Content Level 3', - }, vim.api.nvim_buf_get_lines(0, 0, 6, false)) + assert.are.same(check, vim.api.nvim_buf_get_lines(0, 0, 6, false)) -- Handle overflow + local check + if config.org_adapt_indentation then + check = { + '* TODO Test orgmode', + ' DEADLINE: <2021-07-21 Wed 22:02>', + '** TODO [#A] Test orgmode level 2 :PRIVATE:', + ' Some content for level 2', + '*** NEXT [#1] Level 3', + ' Content Level 3', + } + else + check = { + '* TODO Test orgmode', + 'DEADLINE: <2021-07-21 Wed 22:02>', + '** TODO [#A] Test orgmode level 2 :PRIVATE:', + 'Some content for level 2', + '*** NEXT [#1] Level 3', + 'Content Level 3', + } + end vim.cmd([[norm 5', - '** TODO [#A] Test orgmode level 2 :PRIVATE:', - ' Some content for level 2', - '*** NEXT [#1] Level 3', - ' Content Level 3', - }, vim.api.nvim_buf_get_lines(0, 0, 6, false)) + assert.are.same(check, vim.api.nvim_buf_get_lines(0, 0, 6, false)) end) it('should promote line to (TODO) heading', function()