diff --git a/lua/bullets/actions.lua b/lua/bullets/actions.lua index d16e71a..d078e8e 100644 --- a/lua/bullets/actions.lua +++ b/lua/bullets/actions.lua @@ -327,6 +327,161 @@ local function child_bullet(bullet) return nil end +local function outline_index(style) + for index, outline_style in ipairs(config.options.outline_levels) do + if outline_style == style then + return index + end + end + + return nil +end + +local function indent_depth(indent) + local unit = indent_unit() + local depth = 0 + + while indent:sub(1, #unit) == unit do + depth = depth + 1 + indent = indent:sub(#unit + 1) + end + + return depth +end + +local function parent_indent(indent) + local unit = indent_unit() + if indent:sub(-#unit) == unit then + return indent:sub(1, -#unit - 1) + end + + return indent + :gsub("\t$", "") + :gsub(string.rep(" ", vim.o.shiftwidth > 0 and vim.o.shiftwidth or vim.o.tabstop) .. "$", "") +end + +local function first_bullet_for_style(style, indent) + return bullet_for_style(style, indent) +end + +local function previous_bullet_with_style(lnum, style, indent) + for row = lnum - 1, 1, -1 do + local line = vim.api.nvim_buf_get_lines(0, row - 1, row, false)[1] + if line == "" then + return nil + end + + local bullet = resolve_bullet(parse_line(line), row) + if bullet and bullet.indent == indent and style_for_bullet(bullet) == style then + return bullet + end + end + + return nil +end + +local function bullet_for_level(style, indent, lnum) + local bullet = first_bullet_for_style(style, indent) + if not bullet then + return nil + end + + local previous = previous_bullet_with_style(lnum, style, indent) + if previous then + local marker = next_marker(previous) + if marker then + bullet.marker = marker + end + end + + return bullet +end + +local function fallback_style_for_indent(indent) + return config.options.outline_levels[indent_depth(indent) + 1] +end + +local function change_line_level(lnum, direction) + local line = vim.api.nvim_buf_get_lines(0, lnum - 1, lnum, false)[1] + local bullet = resolve_bullet(parse_line(line), lnum) + if not bullet then + return false + end + + local style = style_for_bullet(bullet) + local index = style and outline_index(style) or nil + local next_style + local next_indent + + if direction == "demote" then + next_style = index and config.options.outline_levels[index + 1] + or fallback_style_for_indent(bullet.indent .. indent_unit()) + next_indent = bullet.indent .. indent_unit() + + if not next_style then + local last_style = config.options.outline_levels[#config.options.outline_levels] + if last_style and last_style:match("^std") and bullet.type == "std" then + next_style = style + else + return false + end + end + else + if bullet.indent == "" then + vim.api.nvim_buf_set_lines(0, lnum - 1, lnum, false, { bullet.text }) + vim.api.nvim_win_set_cursor(0, { lnum, 0 }) + return true + end + + next_indent = parent_indent(bullet.indent) + next_style = index and config.options.outline_levels[index - 1] or fallback_style_for_indent(next_indent) + if not next_style then + return false + end + end + + local next_bullet = bullet_for_level(next_style, next_indent, lnum) + if not next_bullet then + return false + end + + local prefix = current_prefix(next_bullet) + vim.api.nvim_buf_set_lines(0, lnum - 1, lnum, false, { prefix .. bullet.text }) + vim.api.nvim_win_set_cursor(0, { lnum, #prefix }) + return true +end + +local function change_current_line_level(direction) + local lnum = vim.api.nvim_win_get_cursor(0)[1] + local changed = change_line_level(lnum, direction) + if changed and config.options.renumber_on_change then + M.renumber_list() + end + return changed +end + +local function visual_range(first, last) + if first and last then + return first, last + end + + local start_pos = vim.fn.getpos("'<") + local end_pos = vim.fn.getpos("'>") + return math.min(start_pos[2], end_pos[2]), math.max(start_pos[2], end_pos[2]) +end + +local function change_visual_level(direction, first, last) + first, last = visual_range(first, last) + local changed = false + for lnum = first, last do + changed = change_line_level(lnum, direction) or changed + end + if changed and config.options.renumber_on_change then + M.renumber_selection() + end + return changed +end + local function ends_with_colon(text) return text:sub(-1) == ":" or text:sub(-3) == ":" end @@ -441,13 +596,21 @@ function M.toggle_checkbox() end function M.recompute_checkboxes() end -function M.demote() end +function M.demote() + return change_current_line_level("demote") +end -function M.promote() end +function M.promote() + return change_current_line_level("promote") +end -function M.demote_visual() end +function M.demote_visual(first, last) + return change_visual_level("demote", first, last) +end -function M.promote_visual() end +function M.promote_visual(first, last) + return change_visual_level("promote", first, last) +end function M.select_checkbox() end diff --git a/lua/bullets/init.lua b/lua/bullets/init.lua index 9fdcdb9..1ca3fd0 100644 --- a/lua/bullets/init.lua +++ b/lua/bullets/init.lua @@ -42,11 +42,11 @@ local function add_commands() command("BulletPromote", function() actions().promote() end) - command("BulletDemoteVisual", function() - actions().demote_visual() + command("BulletDemoteVisual", function(opts) + actions().demote_visual(opts.line1, opts.line2) end, { range = true }) - command("BulletPromoteVisual", function() - actions().promote_visual() + command("BulletPromoteVisual", function(opts) + actions().promote_visual(opts.line1, opts.line2) end, { range = true }) command("SelectCheckbox", function() actions().select_checkbox() diff --git a/test/nested_bullets_spec.lua b/test/nested_bullets_spec.lua index 2e8dfc5..1e775be 100644 --- a/test/nested_bullets_spec.lua +++ b/test/nested_bullets_spec.lua @@ -1,5 +1,6 @@ local helpers = require("test.helpers") -local it = pending +local active_it = it +local pending_it = pending describe("Bullets.vim", function() describe("nested bullets", function() @@ -12,7 +13,166 @@ describe("Bullets.vim", function() vim.opt.tabstop = 4 end) - it("demotes an existing bullet", function() + active_it("demotes a bullet one outline level", function() + helpers.new_buffer({ + "# Hello there", + "I. this is the first bullet", + "II. second bullet", + }) + + helpers.feedkeys("gg2j>>") + + assert.are.same({ + "# Hello there", + "I. this is the first bullet", + "\tA. second bullet", + }, helpers.get_lines()) + end) + + active_it("promotes a bullet one outline level", function() + helpers.new_buffer({ + "# Hello there", + "I. this is the first bullet", + "\tA. second bullet", + "\tB. third bullet", + }) + + helpers.feedkeys("gg3j<<") + + assert.are.same({ + "# Hello there", + "I. this is the first bullet", + "\tA. second bullet", + "II. third bullet", + }, helpers.get_lines()) + end) + + active_it("demotes an empty bullet", function() + helpers.new_buffer({ + "# Hello there", + "I. this is the first bullet", + }) + + helpers.feedkeys("GAsecond bullet") + + assert.are.same({ + "# Hello there", + "I. this is the first bullet", + "\tA. second bullet", + }, helpers.get_lines()) + end) + + active_it("promotes an empty bullet", function() + helpers.new_buffer({ + "# Hello there", + "I. this is the first bullet", + "\tA. second bullet", + }) + + helpers.feedkeys("GAthird bullet") + + assert.are.same({ + "# Hello there", + "I. this is the first bullet", + "\tA. second bullet", + "II. third bullet", + }, helpers.get_lines()) + end) + + active_it("uses configured outline levels", function() + require("bullets").setup({ outline_levels = { "num", "ABC", "std*" } }) + helpers.new_buffer({ + "# Hello there", + "1. first bullet", + "\tA. second bullet", + "\t\t* third bullet", + "2. fourth bullet", + }) + + vim.api.nvim_win_set_cursor(0, { 5, 0 }) + vim.cmd("BulletDemote") + vim.api.nvim_win_set_cursor(0, { 3, 0 }) + vim.cmd("BulletPromote") + vim.api.nvim_win_set_cursor(0, { 4, 0 }) + vim.cmd("BulletDemote") + + assert.are.same({ + "# Hello there", + "1. first bullet", + "2. second bullet", + "\t\t\t* third bullet", + "\tB. fourth bullet", + }, helpers.get_lines()) + end) + + active_it("preserves the last standard outline level when demoting beyond configured levels", function() + helpers.new_buffer({ + "# Hello there", + "\t\t\t\t\t\t\t+ ninth bullet", + }) + + vim.api.nvim_win_set_cursor(0, { 2, 0 }) + vim.cmd("BulletDemote") + + assert.are.same({ + "# Hello there", + "\t\t\t\t\t\t\t\t+ ninth bullet", + }, helpers.get_lines()) + end) + + active_it("removes a bullet when promoting at the top outline level", function() + helpers.new_buffer({ + "# Hello there", + "I. first bullet", + }) + + helpers.feedkeys("ggj<<") + + assert.are.same({ + "# Hello there", + "first bullet", + }, helpers.get_lines()) + end) + + active_it("promotes bullets in a visual range", function() + require("bullets").setup({ outline_levels = { "num", "abc", "std*" } }) + helpers.new_buffer({ + "# Hello there", + "1. first bullet", + "\ta. second bullet", + "\tb. third bullet", + }) + + vim.cmd("3,4BulletPromoteVisual") + + assert.are.same({ + "# Hello there", + "1. first bullet", + "2. second bullet", + "3. third bullet", + }, helpers.get_lines()) + end) + + active_it("demotes bullets in a visual range", function() + require("bullets").setup({ outline_levels = { "num", "abc", "std*" } }) + helpers.new_buffer({ + "# Hello there", + "1. first bullet", + "2. second bullet", + "3. third bullet", + }) + + vim.cmd("3,4BulletDemoteVisual") + + assert.are.same({ + "# Hello there", + "1. first bullet", + "\ta. second bullet", + "\tb. third bullet", + }, helpers.get_lines()) + end) + + pending_it("demotes an existing bullet", function() helpers.new_buffer({ "# Hello there", "I. this is the first bullet", @@ -54,7 +214,7 @@ describe("Bullets.vim", function() }, helpers.get_lines()) end) - it("promotes an existing bullet", function() + pending_it("promotes an existing bullet", function() helpers.new_buffer({ "# Hello there", "I. this is the first bullet", @@ -94,7 +254,7 @@ describe("Bullets.vim", function() }, helpers.get_lines()) end) - it("demotes an empty bullet", function() + pending_it("demotes an empty bullet", function() helpers.new_buffer({ "# Hello there", "I. this is the first bullet", @@ -108,7 +268,7 @@ describe("Bullets.vim", function() }, helpers.get_lines()) end) - it("promotes an empty bullet", function() + pending_it("promotes an empty bullet", function() helpers.new_buffer({ "# Hello there", "I. this is the first bullet", @@ -124,7 +284,7 @@ describe("Bullets.vim", function() }, helpers.get_lines()) end) - it("restarts numbering with multiple outlines", function() + pending_it("restarts numbering with multiple outlines", function() helpers.new_buffer({ "# Hello there", "I. this is the first bullet", @@ -153,7 +313,7 @@ describe("Bullets.vim", function() }, helpers.get_lines()) end) - it("works with custom outline level definitions", function() + pending_it("works with custom outline level definitions", function() vim.g.bullets_outline_levels = { "num", "ABC", "std*" } helpers.new_buffer({ "# Hello there", @@ -192,7 +352,7 @@ describe("Bullets.vim", function() }, helpers.get_lines()) end) - it("promotes and demotes from different starting levels", function() + pending_it("promotes and demotes from different starting levels", function() helpers.new_buffer({ "# Hello there", "1. this is the first bullet", @@ -230,7 +390,7 @@ describe("Bullets.vim", function() }, helpers.get_lines()) end) - it("does not nest beyond defined levels", function() + pending_it("does not nest beyond defined levels", function() helpers.new_buffer({ "# Hello there", "I. this is the first bullet", @@ -262,7 +422,7 @@ describe("Bullets.vim", function() }, helpers.get_lines()) end) - it("removes bullet when promoting top level bullet", function() + pending_it("removes bullet when promoting top level bullet", function() helpers.new_buffer({ "# Hello there", "A. this is the first bullet", @@ -283,7 +443,7 @@ describe("Bullets.vim", function() }, helpers.get_lines()) end) - it("handle standard bullets when they are not in outline list", function() + pending_it("handle standard bullets when they are not in outline list", function() vim.g.bullets_outline_levels = { "num", "ABC" } helpers.new_buffer({ "# Hello there", @@ -306,7 +466,7 @@ describe("Bullets.vim", function() }, helpers.get_lines()) end) - it("adds new nested bullets with correct alpha/roman numerals", function() + pending_it("adds new nested bullets with correct alpha/roman numerals", function() helpers.new_buffer({ "# Hello there", "I. this is the first bullet", @@ -337,7 +497,7 @@ describe("Bullets.vim", function() }, helpers.get_lines()) end) - it("changes levels in visual mode", function() + pending_it("changes levels in visual mode", function() vim.g.bullets_outline_levels = { "num", "abc", "std*" } helpers.new_buffer({ "# Hello there", @@ -391,7 +551,7 @@ describe("Bullets.vim", function() }, helpers.get_lines()) end) - it("add and change bullets with multiple line spacing and wrapped lines", function() + pending_it("add and change bullets with multiple line spacing and wrapped lines", function() vim.g.bullets_line_spacing = 2 helpers.new_buffer({ "# Hello there", @@ -421,7 +581,7 @@ describe("Bullets.vim", function() }, helpers.get_lines()) end) - it("indents after a line ending in a colon", function() + pending_it("indents after a line ending in a colon", function() vim.g.bullets_auto_indent_after_colon = 1 helpers.new_buffer({ "# Hello there",