Skip to content

Commit a117e1b

Browse files
feat: visual promote/demote
1 parent 929337a commit a117e1b

5 files changed

Lines changed: 219 additions & 0 deletions

File tree

lua/orgmode/config/_meta.lua

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,8 @@
126126
---@field org_toggle_archive_tag? OrgMappingValue Default: '<prefix>A'
127127
---@field org_do_promote? OrgMappingValue Default: '<<'
128128
---@field org_do_demote? OrgMappingValue Default: '>>'
129+
---@field org_do_promote_visual? OrgMappingValue Promote selected headlines in visual mode. Default: '<<'
130+
---@field org_do_demote_visual? OrgMappingValue Demote selected headlines in visual mode. Default: '>>'
129131
---@field org_promote_subtree? OrgMappingValue Default: '<s'
130132
---@field org_demote_subtree? OrgMappingValue Default: '>s'
131133
---@field org_meta_return? OrgMappingValue Add heading, item or row (context-dependent) Default: '<Leader><CR>'

lua/orgmode/config/defaults.lua

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,8 @@ local DefaultConfig = {
173173
org_toggle_archive_tag = '<prefix>A',
174174
org_do_promote = '<<',
175175
org_do_demote = '>>',
176+
org_do_promote_visual = '<<',
177+
org_do_demote_visual = '>>',
176178
org_promote_subtree = '<s',
177179
org_demote_subtree = '>s',
178180
org_meta_return = '<Leader><CR>', -- Add heading, item or row (context-dependent)

lua/orgmode/config/mappings/init.lua

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,14 @@ return {
261261
'org_mappings.do_demote',
262262
{ opts = { desc = 'org demote headline', help_desc = 'Demote headline' } }
263263
),
264+
org_do_promote_visual = m.action('org_mappings.do_promote_visual', {
265+
modes = { 'x' },
266+
opts = { desc = 'org promote visual', help_desc = 'Promote selected headlines' },
267+
}),
268+
org_do_demote_visual = m.action('org_mappings.do_demote_visual', {
269+
modes = { 'x' },
270+
opts = { desc = 'org demote visual', help_desc = 'Demote selected headlines' },
271+
}),
264272
org_promote_subtree = m.action(
265273
'org_mappings.do_promote',
266274
{ args = { true }, opts = { desc = 'org promote subtree', help_desc = 'Promote whole subtree' } }

lua/orgmode/org/mappings.lua

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -569,6 +569,95 @@ function OrgMappings:do_demote(whole_subtree)
569569
vim.fn.winrestview(win_view)
570570
end
571571

572+
local function _for_each_line_in_visual_selection(fn)
573+
local mode = vim.api.nvim_get_mode().mode
574+
if mode ~= 'v' and mode ~= 'V' then
575+
return
576+
end
577+
578+
local start_line = vim.fn.line('v')
579+
local end_line = vim.fn.line('.')
580+
581+
if start_line > end_line then
582+
start_line, end_line = end_line, start_line
583+
end
584+
585+
if start_line == 0 or end_line == 0 then
586+
return
587+
end
588+
589+
for line_nr = start_line, end_line do
590+
fn(line_nr)
591+
end
592+
end
593+
594+
function OrgMappings:do_promote_visual()
595+
_for_each_line_in_visual_selection(function(line_nr)
596+
local line = vim.fn.getline(line_nr)
597+
if line:match('^%*+%s') then
598+
local new_line = line:gsub('^%*+%s', function(match)
599+
local asterisks = match:match('^%*+')
600+
if #asterisks > 1 then
601+
return string.rep('*', #asterisks - 1) .. ' '
602+
else
603+
return ''
604+
end
605+
end)
606+
vim.fn.setline(line_nr, new_line)
607+
elseif line:match('^%*+$') then
608+
vim.fn.setline(line_nr, '')
609+
end
610+
end)
611+
end
612+
613+
function OrgMappings:do_demote_visual()
614+
_for_each_line_in_visual_selection(function(line_nr)
615+
local line = vim.fn.getline(line_nr)
616+
if line:match('^%*+') then
617+
local new_line = line:gsub('^(%*+)', '%1*')
618+
vim.fn.setline(line_nr, new_line)
619+
end
620+
end)
621+
end
622+
623+
function OrgMappings:insert_mode_promote()
624+
local line_number = vim.fn.line('.')
625+
local line = vim.fn.getline(line_number)
626+
627+
if line:match('^%*+') then
628+
local cursor_col = vim.fn.col('.')
629+
local new_line = line:gsub('^%*+', function(asterisks)
630+
if #asterisks > 1 then
631+
return string.rep('*', #asterisks - 1)
632+
else
633+
return ''
634+
end
635+
end)
636+
vim.fn.setline(line_number, new_line)
637+
vim.fn.cursor(line_number, math.max(1, cursor_col - 1))
638+
return
639+
end
640+
641+
return vim.api.nvim_feedkeys(utils.esc('<C-d>'), 'n', true)
642+
end
643+
644+
function OrgMappings:insert_mode_demote()
645+
local line_number = vim.fn.line('.')
646+
local line = vim.fn.getline(line_number)
647+
648+
if line:match('^%*+') then
649+
local cursor_col = vim.fn.col('.')
650+
local new_line = line:gsub('^(%*+)', function(asterisks)
651+
return asterisks .. '*'
652+
end)
653+
vim.fn.setline(line_number, new_line)
654+
vim.fn.cursor(line_number, cursor_col + 1)
655+
return
656+
end
657+
658+
return vim.api.nvim_feedkeys(utils.esc('<C-t>'), 'n', true)
659+
end
660+
572661
function OrgMappings:org_return()
573662
local actions = {
574663
function()
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
local helpers = require('tests.plenary.helpers')
2+
3+
describe('Visual promote/demote', function()
4+
after_each(function()
5+
vim.cmd([[silent! %bw!]])
6+
end)
7+
8+
it('should promote multiple selected headings in visual mode', function()
9+
helpers.create_file({
10+
'* TODO Test heading 1',
11+
'* TODO Test heading 2',
12+
'* TODO Test heading 3',
13+
})
14+
15+
vim.fn.cursor(1, 1)
16+
vim.cmd('normal! V')
17+
vim.cmd('normal! jj')
18+
19+
local org = require('orgmode')
20+
org.action('org_mappings.do_promote_visual')
21+
22+
local lines = vim.api.nvim_buf_get_lines(0, 0, -1, false)
23+
assert.are.same({
24+
'TODO Test heading 1',
25+
'TODO Test heading 2',
26+
'TODO Test heading 3',
27+
}, lines)
28+
end)
29+
30+
it('should demote multiple selected headings in visual mode', function()
31+
helpers.create_file({
32+
'* TODO Test heading 1',
33+
'* TODO Test heading 2',
34+
'* TODO Test heading 3',
35+
})
36+
37+
vim.fn.cursor(1, 1)
38+
vim.cmd('normal! V')
39+
vim.cmd('normal! jj')
40+
41+
local org = require('orgmode')
42+
org.action('org_mappings.do_demote_visual')
43+
44+
local lines = vim.api.nvim_buf_get_lines(0, 0, -1, false)
45+
assert.are.same({
46+
'** TODO Test heading 1',
47+
'** TODO Test heading 2',
48+
'** TODO Test heading 3',
49+
}, lines)
50+
end)
51+
52+
it('should promote mixed content (heading and non-heading)', function()
53+
helpers.create_file({
54+
'some plain text',
55+
'* TODO Test heading 2',
56+
'another plain text',
57+
})
58+
59+
vim.fn.cursor(1, 1)
60+
vim.cmd('normal! V')
61+
vim.cmd('normal! jj')
62+
63+
local org = require('orgmode')
64+
org.action('org_mappings.do_promote_visual')
65+
66+
local lines = vim.api.nvim_buf_get_lines(0, 0, -1, false)
67+
assert.are.same({
68+
'some plain text',
69+
'TODO Test heading 2',
70+
'another plain text',
71+
}, lines)
72+
end)
73+
74+
it('should demote mixed content (heading and non-heading)', function()
75+
helpers.create_file({
76+
'some plain text',
77+
'* TODO Test heading 2',
78+
'another plain text',
79+
})
80+
81+
vim.fn.cursor(1, 1)
82+
vim.cmd('normal! V')
83+
vim.cmd('normal! jj')
84+
85+
local org = require('orgmode')
86+
org.action('org_mappings.do_demote_visual')
87+
88+
local lines = vim.api.nvim_buf_get_lines(0, 0, -1, false)
89+
assert.are.same({
90+
'some plain text',
91+
'** TODO Test heading 2',
92+
'another plain text',
93+
}, lines)
94+
end)
95+
96+
it('should handle multiple levels of headings correctly', function()
97+
helpers.create_file({
98+
'* Level 1 heading',
99+
'** Level 2 heading',
100+
'*** Level 3 heading',
101+
})
102+
103+
vim.fn.cursor(1, 1)
104+
vim.cmd('normal! V')
105+
vim.cmd('normal! jj')
106+
107+
local org = require('orgmode')
108+
org.action('org_mappings.do_promote_visual')
109+
110+
local lines = vim.api.nvim_buf_get_lines(0, 0, -1, false)
111+
assert.are.same({
112+
'Level 1 heading',
113+
'* Level 2 heading',
114+
'** Level 3 heading',
115+
}, lines)
116+
end)
117+
end)
118+

0 commit comments

Comments
 (0)