Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions lua/orgmode/config/_meta.lua
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,8 @@
---@field org_toggle_archive_tag? OrgMappingValue Default: '<prefix>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_demote_subtree? OrgMappingValue Default: '>s'
---@field org_meta_return? OrgMappingValue Add heading, item or row (context-dependent) Default: '<Leader><CR>'
Expand Down
2 changes: 2 additions & 0 deletions lua/orgmode/config/defaults.lua
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,8 @@ local DefaultConfig = {
org_toggle_archive_tag = '<prefix>A',
org_do_promote = '<<',
org_do_demote = '>>',
org_do_promote_visual = '<',
org_do_demote_visual = '>',
org_promote_subtree = '<s',
org_demote_subtree = '>s',
org_meta_return = '<Leader><CR>', -- Add heading, item or row (context-dependent)
Expand Down
8 changes: 8 additions & 0 deletions lua/orgmode/config/mappings/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,14 @@ return {
'org_mappings.do_demote',
{ opts = { desc = 'org demote headline', help_desc = 'Demote headline' } }
),
org_do_promote_visual = m.action('org_mappings.do_promote_visual', {
modes = { 'x' },
opts = { desc = 'org promote visual', help_desc = 'Promote selected headlines' },
}),
org_do_demote_visual = m.action('org_mappings.do_demote_visual', {
modes = { 'x' },
opts = { desc = 'org demote visual', help_desc = 'Demote selected headlines' },
}),
org_promote_subtree = m.action(
'org_mappings.do_promote',
{ args = { true }, opts = { desc = 'org promote subtree', help_desc = 'Promote whole subtree' } }
Expand Down
128 changes: 128 additions & 0 deletions lua/orgmode/org/mappings.lua
Original file line number Diff line number Diff line change
Expand Up @@ -569,6 +569,134 @@ function OrgMappings:do_demote(whole_subtree)
vim.fn.winrestview(win_view)
end

local function _for_each_line_in_visual_selection(fn)
local mode = vim.api.nvim_get_mode().mode
if mode ~= 'v' and mode ~= 'V' then
return
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
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! <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('^%*+%s') then
local new_line = line:gsub('^%*+%s', function(match)
local asterisks = match:match('^%*+')
if #asterisks > 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('<C-d>'), '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('<C-t>'), 'n', true)
end

function OrgMappings:org_return()
local actions = {
function()
Expand Down
161 changes: 161 additions & 0 deletions tests/plenary/ui/mappings/visual_promote_demote_spec.lua
Original file line number Diff line number Diff line change
@@ -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)
Loading