diff --git a/lua/orgmode/config/_meta.lua b/lua/orgmode/config/_meta.lua index edd4a828b..c30f7a069 100644 --- a/lua/orgmode/config/_meta.lua +++ b/lua/orgmode/config/_meta.lua @@ -126,6 +126,8 @@ ---@field org_toggle_archive_tag? OrgMappingValue Default: 'A' ---@field org_do_promote? OrgMappingValue Default: '<<' ---@field org_do_demote? OrgMappingValue Default: '>>' +---@field org_do_promote_visual? OrgMappingValue Promote selected headlines in visual mode. Default: '<' +---@field org_do_demote_visual? OrgMappingValue Demote selected headlines in visual mode. Default: '>' ---@field org_promote_subtree? OrgMappingValue Default: 's' ---@field org_meta_return? OrgMappingValue Add heading, item or row (context-dependent) Default: '' diff --git a/lua/orgmode/config/defaults.lua b/lua/orgmode/config/defaults.lua index 2a5ebd64f..4fce950f9 100644 --- a/lua/orgmode/config/defaults.lua +++ b/lua/orgmode/config/defaults.lua @@ -173,6 +173,8 @@ local DefaultConfig = { org_toggle_archive_tag = 'A', org_do_promote = '<<', org_do_demote = '>>', + org_do_promote_visual = '<', + org_do_demote_visual = '>', org_promote_subtree = ' end_line then + start_line, end_line = end_line, start_line + end + + if start_line == 0 or end_line == 0 then + return + end + + for line_nr = start_line, end_line do + fn(line_nr) + end +end + +local function _visual_selection_has_headline() + local mode = vim.api.nvim_get_mode().mode + if mode ~= 'v' and mode ~= 'V' then + return false + end + + local start_line = vim.fn.line('v') + local end_line = vim.fn.line('.') + + if start_line > end_line then + start_line, end_line = end_line, start_line + end + + if start_line == 0 or end_line == 0 then + return false + end + + for line_nr = start_line, end_line do + local line = vim.fn.getline(line_nr) + if line:match('^%*+%s') then + return true + end + end + + return false +end + +function OrgMappings:do_promote_visual() + if not _visual_selection_has_headline() then + vim.cmd('normal! 1 then + return string.rep('*', #asterisks - 1) .. ' ' + else + return '' + end + end) + vim.fn.setline(line_nr, new_line) + elseif line:match('^%*+$') then + vim.fn.setline(line_nr, '') + end + end) +end + +function OrgMappings:do_demote_visual() + if not _visual_selection_has_headline() then + vim.cmd('normal! >g> ') + vim.cmd('normal! gv') + return + end + + _for_each_line_in_visual_selection(function(line_nr) + local line = vim.fn.getline(line_nr) + if line:match('^%*+') then + local new_line = line:gsub('^(%*+)', '%1*') + vim.fn.setline(line_nr, new_line) + end + end) +end + +function OrgMappings:insert_mode_promote() + local line_number = vim.fn.line('.') + local line = vim.fn.getline(line_number) + + if line:match('^%*+') then + local cursor_col = vim.fn.col('.') + local new_line = line:gsub('^%*+', function(asterisks) + if #asterisks > 1 then + return string.rep('*', #asterisks - 1) + else + return '' + end + end) + vim.fn.setline(line_number, new_line) + vim.fn.cursor(line_number, math.max(1, cursor_col - 1)) + return + end + + return vim.api.nvim_feedkeys(utils.esc(''), 'n', true) +end + +function OrgMappings:insert_mode_demote() + local line_number = vim.fn.line('.') + local line = vim.fn.getline(line_number) + + if line:match('^%*+') then + local cursor_col = vim.fn.col('.') + local new_line = line:gsub('^(%*+)', function(asterisks) + return asterisks .. '*' + end) + vim.fn.setline(line_number, new_line) + vim.fn.cursor(line_number, cursor_col + 1) + return + end + + return vim.api.nvim_feedkeys(utils.esc(''), 'n', true) +end + function OrgMappings:org_return() local actions = { function() diff --git a/tests/plenary/ui/mappings/visual_promote_demote_spec.lua b/tests/plenary/ui/mappings/visual_promote_demote_spec.lua new file mode 100644 index 000000000..e59a42c40 --- /dev/null +++ b/tests/plenary/ui/mappings/visual_promote_demote_spec.lua @@ -0,0 +1,161 @@ +local helpers = require('tests.plenary.helpers') + +describe('Visual promote/demote', function() + after_each(function() + vim.cmd([[silent! %bw!]]) + end) + + it('should promote multiple selected headings in visual mode', function() + helpers.create_file({ + '* TODO Test heading 1', + '* TODO Test heading 2', + '* TODO Test heading 3', + }) + + vim.fn.cursor(1, 1) + vim.cmd('normal! V') + vim.cmd('normal! jj') + + local org = require('orgmode') + org.action('org_mappings.do_promote_visual') + + local lines = vim.api.nvim_buf_get_lines(0, 0, -1, false) + assert.are.same({ + 'TODO Test heading 1', + 'TODO Test heading 2', + 'TODO Test heading 3', + }, lines) + end) + + it('should demote multiple selected headings in visual mode', function() + helpers.create_file({ + '* TODO Test heading 1', + '* TODO Test heading 2', + '* TODO Test heading 3', + }) + + vim.fn.cursor(1, 1) + vim.cmd('normal! V') + vim.cmd('normal! jj') + + local org = require('orgmode') + org.action('org_mappings.do_demote_visual') + + local lines = vim.api.nvim_buf_get_lines(0, 0, -1, false) + assert.are.same({ + '** TODO Test heading 1', + '** TODO Test heading 2', + '** TODO Test heading 3', + }, lines) + end) + + it('should promote mixed content (heading and non-heading)', function() + helpers.create_file({ + 'some plain text', + '* TODO Test heading 2', + 'another plain text', + }) + + vim.fn.cursor(1, 1) + vim.cmd('normal! V') + vim.cmd('normal! jj') + + local org = require('orgmode') + org.action('org_mappings.do_promote_visual') + + local lines = vim.api.nvim_buf_get_lines(0, 0, -1, false) + assert.are.same({ + 'some plain text', + 'TODO Test heading 2', + 'another plain text', + }, lines) + end) + + it('should demote mixed content (heading and non-heading)', function() + helpers.create_file({ + 'some plain text', + '* TODO Test heading 2', + 'another plain text', + }) + + vim.fn.cursor(1, 1) + vim.cmd('normal! V') + vim.cmd('normal! jj') + + local org = require('orgmode') + org.action('org_mappings.do_demote_visual') + + local lines = vim.api.nvim_buf_get_lines(0, 0, -1, false) + assert.are.same({ + 'some plain text', + '** TODO Test heading 2', + 'another plain text', + }, lines) + end) + + it('should handle multiple levels of headings correctly', function() + helpers.create_file({ + '* Level 1 heading', + '** Level 2 heading', + '*** Level 3 heading', + }) + + vim.fn.cursor(1, 1) + vim.cmd('normal! V') + vim.cmd('normal! jj') + + local org = require('orgmode') + org.action('org_mappings.do_promote_visual') + + local lines = vim.api.nvim_buf_get_lines(0, 0, -1, false) + assert.are.same({ + 'Level 1 heading', + '* Level 2 heading', + '** Level 3 heading', + }, lines) + end) + + it('should fallback to default behavior when selection has no headline on promote', function() + helpers.create_file({ + 'plain text line 1', + 'plain text line 2', + 'plain text line 3', + }) + + vim.fn.cursor(1, 1) + vim.cmd('normal! V') + vim.cmd('normal! jj') + + local org = require('orgmode') + org.action('org_mappings.do_promote_visual') + + local lines = vim.api.nvim_buf_get_lines(0, 0, -1, false) + assert.are.same({ + 'plain text line 1', + 'plain text line 2', + 'plain text line 3', + }, lines) + end) + + it('should fallback to default behavior when selection has no headline on demote', function() + helpers.create_file({ + 'plain text line 1', + 'plain text line 2', + 'plain text line 3', + }) + + vim.fn.cursor(1, 1) + vim.cmd('normal! V') + vim.cmd('normal! jj') + + local org = require('orgmode') + org.action('org_mappings.do_demote_visual') + + local lines = vim.api.nvim_buf_get_lines(0, 0, -1, false) + assert.are.same({ + ' plain text line 1', + ' plain text line 2', + ' plain text line 3', + }, lines) + end) +end)