Skip to content

Commit

Permalink
Merge pull request #54 from kaymmm/nested-outline
Browse files Browse the repository at this point in the history
add support for nested outlines
  • Loading branch information
dkarter authored Mar 9, 2020
2 parents dd209b2 + d3084b6 commit 0976e87
Show file tree
Hide file tree
Showing 4 changed files with 875 additions and 25 deletions.
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,12 @@ Capybara integration testing. ❤️
- [x] detect lists that have multiline bullets (should have no empty lines between
lines).
- [x] add alphabetic list
- [ ] support for intelligent alphanumeric indented bullets e.g. 1. \t a. \t 1.
- [x] support for intelligent alphanumeric indented bullets e.g. 1. \t a. \t 1.
- [ ] update documentation for nested bullets
- [ ] support for nested numerical bullets, e.g., 1. -> 1.1 -> 1.1.1, 1.1.2
- [ ] change nested outline levels in visual mode
- [ ] support renumbering of alphabetical, roman numerals, and nested lists
- [ ] add option to turn non-bullet lines into new bullets with `C-t`/`>>`

---

Expand Down
209 changes: 185 additions & 24 deletions plugin/bullets.vim
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
" Vim plugin for automated bulleted lists
" Last Change: February 23, 2020
" Last Change: March 2, 2020
" Maintainer: Dorian Karter
" License: MIT
" FileTypes: markdown, text, gitcommit
Expand Down Expand Up @@ -55,6 +55,13 @@ while s:power >= 0
let s:abc_max += pow(26,s:power)
let s:power -= 1
endwhile

if !exists('g:bullets_outline_levels')
" Capitalization matters: all caps will make the symbol caps, lower = lower
" Standard bullets should include the marker symbol after 'std'
let g:bullets_outline_levels = ['ROM', 'ABC', 'num', 'abc', 'rom', 'std-', 'std*', 'std+']
endif

" ------------------------------------------------------ }}}

" Parse Bullet Type ------------------------------------------- {{{
Expand Down Expand Up @@ -197,40 +204,50 @@ fun! s:match_checkbox_bullet_item(input_text)
endfun

fun! s:match_bullet_list_item(input_text)
let l:std_bullet_regex = '\v(^\s*(-|\*+|\.+|#\.|\\item)(\s+))(.*)'
let l:std_bullet_regex = '\v(^(\s*)(-|\*+|\.+|#\.|\+|\\item)(\s+))(.*)'
let l:matches = matchlist(a:input_text, l:std_bullet_regex)

if empty(l:matches)
return {}
endif

let l:bullet_length = strlen(l:matches[1])
let l:whole_bullet = l:matches[1]
let l:trailing_space = l:matches[3]
let l:text_after_bullet = l:matches[4]
let l:leading_space = l:matches[2]
let l:bullet = l:matches[3]
let l:trailing_space = l:matches[4]
let l:text_after_bullet = l:matches[5]

return {
\ 'bullet_type': 'std',
\ 'bullet_length': l:bullet_length,
\ 'whole_bullet': l:whole_bullet,
\ 'leading_space': l:leading_space,
\ 'bullet': l:bullet,
\ 'closure': '',
\ 'trailing_space': l:trailing_space,
\ 'text_after_bullet': l:text_after_bullet
\ }
endfun
" ------------------------------------------------------- }}}

" Resolve Bullet Type ----------------------------------- {{{
fun! s:closest_bullet_types(from_line_num)
fun! s:closest_bullet_types(from_line_num, max_indent)
let l:lnum = a:from_line_num
let l:ltxt = getline(l:lnum)
let l:curr_indent = indent(l:lnum)
let l:bullet_kinds = s:parse_bullet(l:lnum, l:ltxt)

" Support for wrapped text bullets
" DEMO: https://raw.githubusercontent.com/dkarter/bullets.vim/master/img/wrapped-bullets.gif
while l:lnum > 1 && s:is_indented(l:ltxt) && l:bullet_kinds == []
let l:lnum = l:lnum - 1
while l:lnum > 1 && (l:curr_indent != 0 || l:bullet_kinds != [])
\ && (a:max_indent < l:curr_indent || l:bullet_kinds == [])
if l:bullet_kinds != []
let l:lnum = l:lnum - g:bullets_line_spacing
else
let l:lnum = l:lnum - 1
endif
let l:ltxt = getline(l:lnum)
let l:bullet_kinds = s:parse_bullet(l:lnum, l:ltxt)
let l:curr_indent = indent(l:lnum)
endwhile

return l:bullet_kinds
Expand Down Expand Up @@ -259,8 +276,14 @@ endfun
" Roman Numeral vs Alphabetic Bullets ---------------------------------- {{{
fun! s:resolve_rom_or_abc(bullet_types)
let l:first_type = a:bullet_types[0]
let l:prev_search_starting_line = get(l:first_type, 'starting_at_line_num') - 1
let l:prev_bullet_types = s:closest_bullet_types(l:prev_search_starting_line)
let l:prev_search_starting_line = l:first_type.starting_at_line_num - g:bullets_line_spacing
let l:bullet_indent = indent(l:first_type.starting_at_line_num)
let l:prev_bullet_types = s:closest_bullet_types(l:prev_search_starting_line, l:bullet_indent)

while l:prev_bullet_types != [] && l:bullet_indent > indent(l:prev_search_starting_line)
let l:prev_search_starting_line -= g:bullets_line_spacing
let l:prev_bullet_types = s:closest_bullet_types(l:prev_search_starting_line, l:bullet_indent)
endwhile

if len(l:prev_bullet_types) == 0

Expand Down Expand Up @@ -319,37 +342,36 @@ fun! s:next_bullet_str(bullet)
let l:bullet_type = get(a:bullet, 'bullet_type')

if l:bullet_type ==# 'rom'
return s:next_rom_bullet(a:bullet)
let l:next_bullet_marker = s:next_rom_bullet(a:bullet)
elseif l:bullet_type ==# 'abc'
return s:next_abc_bullet(a:bullet)
let l:next_bullet_marker = s:next_abc_bullet(a:bullet)
elseif l:bullet_type ==# 'num'
return s:next_num_bullet(a:bullet)
let l:next_bullet_marker = s:next_num_bullet(a:bullet)
elseif l:bullet_type ==# 'chk'
return s:next_chk_bullet(a:bullet)
let l:next_bullet_marker = s:next_chk_bullet(a:bullet)
else
return a:bullet.whole_bullet
let l:next_bullet_marker = a:bullet.bullet
endif
let l:closure = has_key(a:bullet, 'closure') ? a:bullet.closure : ''
return a:bullet.leading_space . l:next_bullet_marker . l:closure . ' '
endfun

fun! s:next_rom_bullet(bullet)
let l:islower = a:bullet.bullet ==# tolower(a:bullet.bullet)
let l:next_num = s:arabic2roman(s:roman2arabic(a:bullet.bullet) + 1, l:islower)
return a:bullet.leading_space . l:next_num . a:bullet.closure . ' '
return s:arabic2roman(s:roman2arabic(a:bullet.bullet) + 1, l:islower)
endfun

fun! s:next_abc_bullet(bullet)
let l:islower = a:bullet.bullet ==# tolower(a:bullet.bullet)
let l:next_num = s:dec2abc(s:abc2dec(a:bullet.bullet) + 1, l:islower)
return a:bullet.leading_space . l:next_num . a:bullet.closure . ' '
return s:dec2abc(s:abc2dec(a:bullet.bullet) + 1, l:islower)
endfun

fun! s:next_num_bullet(bullet)
let l:next_num = a:bullet.bullet + 1
return a:bullet.leading_space . l:next_num . a:bullet.closure . ' '
return a:bullet.bullet + 1
endfun

fun! s:next_chk_bullet(bullet)
return a:bullet.leading_space . '- [ ] '
return '- [ ]'
endfun
" }}}

Expand All @@ -363,7 +385,8 @@ endfun
fun! s:insert_new_bullet()
let l:curr_line_num = line('.')
let l:next_line_num = l:curr_line_num + g:bullets_line_spacing
let l:closest_bullet_types = s:closest_bullet_types(l:curr_line_num)
let l:curr_indent = indent(l:curr_line_num)
let l:closest_bullet_types = s:closest_bullet_types(l:curr_line_num, l:curr_indent)
let l:bullet = s:resolve_bullet_type(l:closest_bullet_types)
" need to find which line starts the previous bullet started at and start
" searching up from there
Expand Down Expand Up @@ -573,6 +596,137 @@ endfun
command! -range=% RenumberSelection call <SID>renumber_selection()
" --------------------------------------------------------- }}}

" Changing outline level ---------------------------------- {{{
fun! s:change_bullet_level(direction)
let l:lnum = line('.')
let l:curr_line = s:parse_bullet(l:lnum, getline(l:lnum))

if a:direction == 1
if l:curr_line != [] && indent(l:lnum) == 0
" Promoting a bullet at the highest level will delete the bullet
call setline(l:lnum, l:curr_line[0].text_after_bullet)
else
execute "normal! a\<C-d>"
endif
else
execute "normal! a\<C-t>"
endif

let l:curr_indent = indent(l:lnum)
let l:curr_bullet= s:closest_bullet_types(l:lnum, l:curr_indent)
let l:curr_bullet = s:resolve_bullet_type(l:curr_bullet)

if l:curr_bullet == {}
" Only change the bullet level if it's currently a bullet.
return
endif

let l:curr_line = l:curr_bullet.starting_at_line_num
let l:closest_bullet = s:closest_bullet_types(l:curr_line - g:bullets_line_spacing, l:curr_indent)
let l:closest_bullet = s:resolve_bullet_type(l:closest_bullet)

if l:closest_bullet == {}
" If there is no parent/sibling bullet then this bullet shouldn't change.
return
endif

let l:islower = l:closest_bullet.bullet ==# tolower(l:closest_bullet.bullet)
let l:closest_type = l:islower ? l:closest_bullet.bullet_type :
\ toupper(l:closest_bullet.bullet_type)

if l:closest_bullet.bullet_type ==# 'std'
" Append the bullet marker to the type, e.g., 'std*'

let l:closest_type = l:closest_type . l:closest_bullet.bullet
endif

let l:closest_index = index(g:bullets_outline_levels, l:closest_type)

if l:closest_index == -1
" We are in a list using markers that aren't specified in
" g:bullets_outline_levels so we shouldn't try to change the current
" bullet.
return
endif

let l:closest_indent = indent(l:closest_bullet.starting_at_line_num)

if (l:curr_indent == l:closest_indent)
" The closest bullet is a sibling so the current bullet should
" increment to the next bullet marker.

let l:next_bullet = s:next_bullet_str(l:closest_bullet)
let l:next_bullet_str = s:pad_to_length(l:next_bullet, l:closest_bullet.bullet_length)
\ . l:curr_bullet.text_after_bullet

elseif l:closest_index + 1 < len(g:bullets_outline_levels) || l:curr_indent < l:closest_indent
" The current bullet is a child of the closest bullet so figure out
" what bullet type it should have and set its marker to the first
" character of that type.

let l:next_index = l:closest_index + 1
let l:next_type = g:bullets_outline_levels[l:next_index]
let l:next_islower = l:next_type ==# tolower(l:next_type)
let l:trailing_space = ' '

let l:curr_bullet.closure = l:closest_bullet.closure

" set the bullet marker to the first character of that type
if l:next_type ==? 'rom'
let l:next_num = s:arabic2roman(1, l:next_islower)
elseif l:next_type ==? 'abc'
let l:next_num = s:dec2abc(1, l:next_islower)
elseif l:next_type ==# 'num'
let l:next_num = '1'
else
" standard bullet; l:next_type contains the bullet symbol to use
let l:next_num = strpart(l:next_type, len(l:next_type) - 1)
let l:curr_bullet.closure = ''
endif

let l:next_bullet_str =
\ l:curr_bullet.leading_space
\ . l:next_num
\ . l:curr_bullet.closure
\ . l:trailing_space
\ . l:curr_bullet.text_after_bullet

else
" We're outside of the defined outline levels
let l:next_bullet_str =
\ l:curr_bullet.leading_space
\ . l:curr_bullet.text_after_bullet
endif

" Apply the new bullet
if l:next_bullet_str !=# ''
call setline(l:lnum, l:next_bullet_str)
execute 'normal! $'

elseif g:bullets_delete_last_bullet_if_empty
let l:orig_line = s:parse_bullet(l:lnum, getline(l:lnum))
if l:orig_line != []
call setline(l:lnum, l:orig_line[0].leading_space . l:orig_line[0].text_after_bullet)
endif
endif

return
endfun

fun! s:bullet_demote()
call s:change_bullet_level(-1)
endfun

fun! s:bullet_promote()
call s:change_bullet_level(1)
endfun

command! BulletDemote call <SID>bullet_demote()
command! BulletPromote call <SID>bullet_promote()


" --------------------------------------------------------- }}}

" Keyboard mappings --------------------------------------- {{{
fun! s:add_local_mapping(mapping_type, mapping, action)
let l:file_types = join(g:bullets_enabled_file_types, ',')
Expand Down Expand Up @@ -613,6 +767,13 @@ augroup TextBulletsMappings

" Toggle checkbox
call s:add_local_mapping('nnoremap', '<leader>x', ':ToggleCheckbox<cr>')

" Promote and Demote outline level
call s:add_local_mapping('inoremap', '<C-t>', '<C-o>:call <SID>bullet_demote()<cr>')
call s:add_local_mapping('nnoremap', '>>', ':call <SID>bullet_demote()<cr>')
call s:add_local_mapping('inoremap', '<C-d>', '<C-o>:call <SID>bullet_promote()<cr>')
call s:add_local_mapping('nnoremap', '<<', ':call <SID>bullet_promote()<cr>')

end
augroup END
" --------------------------------------------------------- }}}
Expand Down
43 changes: 43 additions & 0 deletions spec/alphabetic_bullets_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,49 @@
TEXT
end

it 'does not add a new bullet when mixed case' do
test_bullet_inserted('not a bullet', <<-INIT, <<-EXPECTED)
# Hello there
Ab. this is the first bullet
INIT
# Hello there
Ab. this is the first bullet
not a bullet
EXPECTED
end

# it 'correctly numbers after wrapped lines starting with short words' do
# # TODO: maybe take guidance from Pandoc and require two spaces after the
# closure to allow us to differentiate between bullets and abbreviations
# and words. Might also consider only allowing single letters.
# test_bullet_inserted('second bullet', <<-INIT, <<-EXPECTED)
# # Hello there
# a. first bullet might not catch
# me. second line.
# INIT
# # Hello there
# a. first bullet might not catch
# \tme. second line.
# b. second bullet
# EXPECTED
# end

# it 'correctly numbers after lines beginning with initialized names' do
# # TODO: maybe take guidance from Pandoc and require two spaces after the
# closure to allow us to differentiate between bullets and abbreviations
# and words. Might also consider only allowing single letters.
# test_bullet_inserted('Second bullet', <<-INIT, <<-EXPECTED)
# # Hello there
# I. The first president of the USA was
# G. Washington.
# INIT
# # Hello there
# I. The first president of the USA was
# G. Washington.
# II. Second bullet
# EXPECTED
# end

describe 'g:bullets_max_alpha_characters' do
it 'stops adding items after configured max (default 2)' do
filename = "#{SecureRandom.hex(6)}.txt"
Expand Down
Loading

0 comments on commit 0976e87

Please sign in to comment.