Skip to content

Commit 0976e87

Browse files
authored
Merge pull request #54 from kaymmm/nested-outline
add support for nested outlines
2 parents dd209b2 + d3084b6 commit 0976e87

File tree

4 files changed

+875
-25
lines changed

4 files changed

+875
-25
lines changed

README.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,12 @@ Capybara integration testing. ❤️
9292
- [x] detect lists that have multiline bullets (should have no empty lines between
9393
lines).
9494
- [x] add alphabetic list
95-
- [ ] support for intelligent alphanumeric indented bullets e.g. 1. \t a. \t 1.
95+
- [x] support for intelligent alphanumeric indented bullets e.g. 1. \t a. \t 1.
96+
- [ ] update documentation for nested bullets
97+
- [ ] support for nested numerical bullets, e.g., 1. -> 1.1 -> 1.1.1, 1.1.2
98+
- [ ] change nested outline levels in visual mode
99+
- [ ] support renumbering of alphabetical, roman numerals, and nested lists
100+
- [ ] add option to turn non-bullet lines into new bullets with `C-t`/`>>`
96101

97102
---
98103

plugin/bullets.vim

Lines changed: 185 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
" Vim plugin for automated bulleted lists
2-
" Last Change: February 23, 2020
2+
" Last Change: March 2, 2020
33
" Maintainer: Dorian Karter
44
" License: MIT
55
" FileTypes: markdown, text, gitcommit
@@ -55,6 +55,13 @@ while s:power >= 0
5555
let s:abc_max += pow(26,s:power)
5656
let s:power -= 1
5757
endwhile
58+
59+
if !exists('g:bullets_outline_levels')
60+
" Capitalization matters: all caps will make the symbol caps, lower = lower
61+
" Standard bullets should include the marker symbol after 'std'
62+
let g:bullets_outline_levels = ['ROM', 'ABC', 'num', 'abc', 'rom', 'std-', 'std*', 'std+']
63+
endif
64+
5865
" ------------------------------------------------------ }}}
5966

6067
" Parse Bullet Type ------------------------------------------- {{{
@@ -197,40 +204,50 @@ fun! s:match_checkbox_bullet_item(input_text)
197204
endfun
198205

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

203210
if empty(l:matches)
204211
return {}
205212
endif
206213

207214
let l:bullet_length = strlen(l:matches[1])
208-
let l:whole_bullet = l:matches[1]
209-
let l:trailing_space = l:matches[3]
210-
let l:text_after_bullet = l:matches[4]
215+
let l:leading_space = l:matches[2]
216+
let l:bullet = l:matches[3]
217+
let l:trailing_space = l:matches[4]
218+
let l:text_after_bullet = l:matches[5]
211219

212220
return {
213221
\ 'bullet_type': 'std',
214222
\ 'bullet_length': l:bullet_length,
215-
\ 'whole_bullet': l:whole_bullet,
223+
\ 'leading_space': l:leading_space,
224+
\ 'bullet': l:bullet,
225+
\ 'closure': '',
216226
\ 'trailing_space': l:trailing_space,
217227
\ 'text_after_bullet': l:text_after_bullet
218228
\ }
219229
endfun
220230
" ------------------------------------------------------- }}}
221231

222232
" Resolve Bullet Type ----------------------------------- {{{
223-
fun! s:closest_bullet_types(from_line_num)
233+
fun! s:closest_bullet_types(from_line_num, max_indent)
224234
let l:lnum = a:from_line_num
225235
let l:ltxt = getline(l:lnum)
236+
let l:curr_indent = indent(l:lnum)
226237
let l:bullet_kinds = s:parse_bullet(l:lnum, l:ltxt)
227238

228239
" Support for wrapped text bullets
229240
" DEMO: https://raw.githubusercontent.com/dkarter/bullets.vim/master/img/wrapped-bullets.gif
230-
while l:lnum > 1 && s:is_indented(l:ltxt) && l:bullet_kinds == []
231-
let l:lnum = l:lnum - 1
241+
while l:lnum > 1 && (l:curr_indent != 0 || l:bullet_kinds != [])
242+
\ && (a:max_indent < l:curr_indent || l:bullet_kinds == [])
243+
if l:bullet_kinds != []
244+
let l:lnum = l:lnum - g:bullets_line_spacing
245+
else
246+
let l:lnum = l:lnum - 1
247+
endif
232248
let l:ltxt = getline(l:lnum)
233249
let l:bullet_kinds = s:parse_bullet(l:lnum, l:ltxt)
250+
let l:curr_indent = indent(l:lnum)
234251
endwhile
235252

236253
return l:bullet_kinds
@@ -259,8 +276,14 @@ endfun
259276
" Roman Numeral vs Alphabetic Bullets ---------------------------------- {{{
260277
fun! s:resolve_rom_or_abc(bullet_types)
261278
let l:first_type = a:bullet_types[0]
262-
let l:prev_search_starting_line = get(l:first_type, 'starting_at_line_num') - 1
263-
let l:prev_bullet_types = s:closest_bullet_types(l:prev_search_starting_line)
279+
let l:prev_search_starting_line = l:first_type.starting_at_line_num - g:bullets_line_spacing
280+
let l:bullet_indent = indent(l:first_type.starting_at_line_num)
281+
let l:prev_bullet_types = s:closest_bullet_types(l:prev_search_starting_line, l:bullet_indent)
282+
283+
while l:prev_bullet_types != [] && l:bullet_indent > indent(l:prev_search_starting_line)
284+
let l:prev_search_starting_line -= g:bullets_line_spacing
285+
let l:prev_bullet_types = s:closest_bullet_types(l:prev_search_starting_line, l:bullet_indent)
286+
endwhile
264287

265288
if len(l:prev_bullet_types) == 0
266289

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

321344
if l:bullet_type ==# 'rom'
322-
return s:next_rom_bullet(a:bullet)
345+
let l:next_bullet_marker = s:next_rom_bullet(a:bullet)
323346
elseif l:bullet_type ==# 'abc'
324-
return s:next_abc_bullet(a:bullet)
347+
let l:next_bullet_marker = s:next_abc_bullet(a:bullet)
325348
elseif l:bullet_type ==# 'num'
326-
return s:next_num_bullet(a:bullet)
349+
let l:next_bullet_marker = s:next_num_bullet(a:bullet)
327350
elseif l:bullet_type ==# 'chk'
328-
return s:next_chk_bullet(a:bullet)
351+
let l:next_bullet_marker = s:next_chk_bullet(a:bullet)
329352
else
330-
return a:bullet.whole_bullet
353+
let l:next_bullet_marker = a:bullet.bullet
331354
endif
355+
let l:closure = has_key(a:bullet, 'closure') ? a:bullet.closure : ''
356+
return a:bullet.leading_space . l:next_bullet_marker . l:closure . ' '
332357
endfun
333358

334359
fun! s:next_rom_bullet(bullet)
335360
let l:islower = a:bullet.bullet ==# tolower(a:bullet.bullet)
336-
let l:next_num = s:arabic2roman(s:roman2arabic(a:bullet.bullet) + 1, l:islower)
337-
return a:bullet.leading_space . l:next_num . a:bullet.closure . ' '
361+
return s:arabic2roman(s:roman2arabic(a:bullet.bullet) + 1, l:islower)
338362
endfun
339363

340364
fun! s:next_abc_bullet(bullet)
341365
let l:islower = a:bullet.bullet ==# tolower(a:bullet.bullet)
342-
let l:next_num = s:dec2abc(s:abc2dec(a:bullet.bullet) + 1, l:islower)
343-
return a:bullet.leading_space . l:next_num . a:bullet.closure . ' '
366+
return s:dec2abc(s:abc2dec(a:bullet.bullet) + 1, l:islower)
344367
endfun
345368

346369
fun! s:next_num_bullet(bullet)
347-
let l:next_num = a:bullet.bullet + 1
348-
return a:bullet.leading_space . l:next_num . a:bullet.closure . ' '
370+
return a:bullet.bullet + 1
349371
endfun
350372

351373
fun! s:next_chk_bullet(bullet)
352-
return a:bullet.leading_space . '- [ ] '
374+
return '- [ ]'
353375
endfun
354376
" }}}
355377

@@ -363,7 +385,8 @@ endfun
363385
fun! s:insert_new_bullet()
364386
let l:curr_line_num = line('.')
365387
let l:next_line_num = l:curr_line_num + g:bullets_line_spacing
366-
let l:closest_bullet_types = s:closest_bullet_types(l:curr_line_num)
388+
let l:curr_indent = indent(l:curr_line_num)
389+
let l:closest_bullet_types = s:closest_bullet_types(l:curr_line_num, l:curr_indent)
367390
let l:bullet = s:resolve_bullet_type(l:closest_bullet_types)
368391
" need to find which line starts the previous bullet started at and start
369392
" searching up from there
@@ -573,6 +596,137 @@ endfun
573596
command! -range=% RenumberSelection call <SID>renumber_selection()
574597
" --------------------------------------------------------- }}}
575598

599+
" Changing outline level ---------------------------------- {{{
600+
fun! s:change_bullet_level(direction)
601+
let l:lnum = line('.')
602+
let l:curr_line = s:parse_bullet(l:lnum, getline(l:lnum))
603+
604+
if a:direction == 1
605+
if l:curr_line != [] && indent(l:lnum) == 0
606+
" Promoting a bullet at the highest level will delete the bullet
607+
call setline(l:lnum, l:curr_line[0].text_after_bullet)
608+
else
609+
execute "normal! a\<C-d>"
610+
endif
611+
else
612+
execute "normal! a\<C-t>"
613+
endif
614+
615+
let l:curr_indent = indent(l:lnum)
616+
let l:curr_bullet= s:closest_bullet_types(l:lnum, l:curr_indent)
617+
let l:curr_bullet = s:resolve_bullet_type(l:curr_bullet)
618+
619+
if l:curr_bullet == {}
620+
" Only change the bullet level if it's currently a bullet.
621+
return
622+
endif
623+
624+
let l:curr_line = l:curr_bullet.starting_at_line_num
625+
let l:closest_bullet = s:closest_bullet_types(l:curr_line - g:bullets_line_spacing, l:curr_indent)
626+
let l:closest_bullet = s:resolve_bullet_type(l:closest_bullet)
627+
628+
if l:closest_bullet == {}
629+
" If there is no parent/sibling bullet then this bullet shouldn't change.
630+
return
631+
endif
632+
633+
let l:islower = l:closest_bullet.bullet ==# tolower(l:closest_bullet.bullet)
634+
let l:closest_type = l:islower ? l:closest_bullet.bullet_type :
635+
\ toupper(l:closest_bullet.bullet_type)
636+
637+
if l:closest_bullet.bullet_type ==# 'std'
638+
" Append the bullet marker to the type, e.g., 'std*'
639+
640+
let l:closest_type = l:closest_type . l:closest_bullet.bullet
641+
endif
642+
643+
let l:closest_index = index(g:bullets_outline_levels, l:closest_type)
644+
645+
if l:closest_index == -1
646+
" We are in a list using markers that aren't specified in
647+
" g:bullets_outline_levels so we shouldn't try to change the current
648+
" bullet.
649+
return
650+
endif
651+
652+
let l:closest_indent = indent(l:closest_bullet.starting_at_line_num)
653+
654+
if (l:curr_indent == l:closest_indent)
655+
" The closest bullet is a sibling so the current bullet should
656+
" increment to the next bullet marker.
657+
658+
let l:next_bullet = s:next_bullet_str(l:closest_bullet)
659+
let l:next_bullet_str = s:pad_to_length(l:next_bullet, l:closest_bullet.bullet_length)
660+
\ . l:curr_bullet.text_after_bullet
661+
662+
elseif l:closest_index + 1 < len(g:bullets_outline_levels) || l:curr_indent < l:closest_indent
663+
" The current bullet is a child of the closest bullet so figure out
664+
" what bullet type it should have and set its marker to the first
665+
" character of that type.
666+
667+
let l:next_index = l:closest_index + 1
668+
let l:next_type = g:bullets_outline_levels[l:next_index]
669+
let l:next_islower = l:next_type ==# tolower(l:next_type)
670+
let l:trailing_space = ' '
671+
672+
let l:curr_bullet.closure = l:closest_bullet.closure
673+
674+
" set the bullet marker to the first character of that type
675+
if l:next_type ==? 'rom'
676+
let l:next_num = s:arabic2roman(1, l:next_islower)
677+
elseif l:next_type ==? 'abc'
678+
let l:next_num = s:dec2abc(1, l:next_islower)
679+
elseif l:next_type ==# 'num'
680+
let l:next_num = '1'
681+
else
682+
" standard bullet; l:next_type contains the bullet symbol to use
683+
let l:next_num = strpart(l:next_type, len(l:next_type) - 1)
684+
let l:curr_bullet.closure = ''
685+
endif
686+
687+
let l:next_bullet_str =
688+
\ l:curr_bullet.leading_space
689+
\ . l:next_num
690+
\ . l:curr_bullet.closure
691+
\ . l:trailing_space
692+
\ . l:curr_bullet.text_after_bullet
693+
694+
else
695+
" We're outside of the defined outline levels
696+
let l:next_bullet_str =
697+
\ l:curr_bullet.leading_space
698+
\ . l:curr_bullet.text_after_bullet
699+
endif
700+
701+
" Apply the new bullet
702+
if l:next_bullet_str !=# ''
703+
call setline(l:lnum, l:next_bullet_str)
704+
execute 'normal! $'
705+
706+
elseif g:bullets_delete_last_bullet_if_empty
707+
let l:orig_line = s:parse_bullet(l:lnum, getline(l:lnum))
708+
if l:orig_line != []
709+
call setline(l:lnum, l:orig_line[0].leading_space . l:orig_line[0].text_after_bullet)
710+
endif
711+
endif
712+
713+
return
714+
endfun
715+
716+
fun! s:bullet_demote()
717+
call s:change_bullet_level(-1)
718+
endfun
719+
720+
fun! s:bullet_promote()
721+
call s:change_bullet_level(1)
722+
endfun
723+
724+
command! BulletDemote call <SID>bullet_demote()
725+
command! BulletPromote call <SID>bullet_promote()
726+
727+
728+
" --------------------------------------------------------- }}}
729+
576730
" Keyboard mappings --------------------------------------- {{{
577731
fun! s:add_local_mapping(mapping_type, mapping, action)
578732
let l:file_types = join(g:bullets_enabled_file_types, ',')
@@ -613,6 +767,13 @@ augroup TextBulletsMappings
613767

614768
" Toggle checkbox
615769
call s:add_local_mapping('nnoremap', '<leader>x', ':ToggleCheckbox<cr>')
770+
771+
" Promote and Demote outline level
772+
call s:add_local_mapping('inoremap', '<C-t>', '<C-o>:call <SID>bullet_demote()<cr>')
773+
call s:add_local_mapping('nnoremap', '>>', ':call <SID>bullet_demote()<cr>')
774+
call s:add_local_mapping('inoremap', '<C-d>', '<C-o>:call <SID>bullet_promote()<cr>')
775+
call s:add_local_mapping('nnoremap', '<<', ':call <SID>bullet_promote()<cr>')
776+
616777
end
617778
augroup END
618779
" --------------------------------------------------------- }}}

spec/alphabetic_bullets_spec.rb

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,49 @@
132132
TEXT
133133
end
134134

135+
it 'does not add a new bullet when mixed case' do
136+
test_bullet_inserted('not a bullet', <<-INIT, <<-EXPECTED)
137+
# Hello there
138+
Ab. this is the first bullet
139+
INIT
140+
# Hello there
141+
Ab. this is the first bullet
142+
not a bullet
143+
EXPECTED
144+
end
145+
146+
# it 'correctly numbers after wrapped lines starting with short words' do
147+
# # TODO: maybe take guidance from Pandoc and require two spaces after the
148+
# closure to allow us to differentiate between bullets and abbreviations
149+
# and words. Might also consider only allowing single letters.
150+
# test_bullet_inserted('second bullet', <<-INIT, <<-EXPECTED)
151+
# # Hello there
152+
# a. first bullet might not catch
153+
# me. second line.
154+
# INIT
155+
# # Hello there
156+
# a. first bullet might not catch
157+
# \tme. second line.
158+
# b. second bullet
159+
# EXPECTED
160+
# end
161+
162+
# it 'correctly numbers after lines beginning with initialized names' do
163+
# # TODO: maybe take guidance from Pandoc and require two spaces after the
164+
# closure to allow us to differentiate between bullets and abbreviations
165+
# and words. Might also consider only allowing single letters.
166+
# test_bullet_inserted('Second bullet', <<-INIT, <<-EXPECTED)
167+
# # Hello there
168+
# I. The first president of the USA was
169+
# G. Washington.
170+
# INIT
171+
# # Hello there
172+
# I. The first president of the USA was
173+
# G. Washington.
174+
# II. Second bullet
175+
# EXPECTED
176+
# end
177+
135178
describe 'g:bullets_max_alpha_characters' do
136179
it 'stops adding items after configured max (default 2)' do
137180
filename = "#{SecureRandom.hex(6)}.txt"

0 commit comments

Comments
 (0)