Skip to content

Fuzzy Table Of Contents

Maxim Kim edited this page Aug 21, 2022 · 13 revisions
images/vim-rst-toc.gif

I have come up with a wrapper function that can accept

  • a title;
  • a list of things;
  • a handler.

This function, FilterMenu, shows popup with a list of things and lets user narrow it down to a specific list element. Then on Enter, handler process the selected element.

So you need to find all Sections/Headings and provide it to that function...

ONLY FOR VIM9+

FilterMenu function

Create a file ~/.vim/autoload/popup.vim and put following there:

vim9script
export def FilterMenu(title: string, items: list<any>, Callback: func(any, string), Setup: func(number) = null_function, close_on_bs: bool = false)
    if empty(prop_type_get('FilterMenuMatch'))
        hi def link FilterMenuMatch Constant
        prop_type_add('FilterMenuMatch', {highlight: "FilterMenuMatch", override: true, priority: 1000, combine: true})
    endif
    var prompt = ""
    var hint = ">>> type to filter <<<"
    var items_dict: list<dict<any>>
    var items_count = items->len()
    if items_count < 1
        items_dict = [{text: ""}]
    elseif items[0]->type() != v:t_dict
        items_dict = items->mapnew((_, v) => {
            return {text: v}
        })
    else
        items_dict = items
    endif

    var filtered_items: list<any> = [items_dict]
    def Printify(itemsAny: list<any>, props: list<any>): list<any>
        if itemsAny[0]->len() == 0 | return [] | endif
        if itemsAny->len() > 1
            return itemsAny[0]->mapnew((idx, v) => {
                return {text: v.text, props: itemsAny[1][idx]->mapnew((_, c) => {
                    return {col: v.text->byteidx(c) + 1, length: 1, type: 'FilterMenuMatch'}
                })}
            })
        else
            return itemsAny[0]->mapnew((_, v) => {
                return {text: v.text}
            })
        endif
    enddef
    var height = min([&lines - 6, items->len()])
    var pos_top = ((&lines - height) / 2) - 1
    var winid = popup_create(Printify(filtered_items, []), {
        title: $" ({items_count}/{items_count}) {title}: {hint} ",
        line: pos_top,
        minwidth: (&columns * 0.6)->float2nr(),
        maxwidth: (&columns - 5),
        minheight: height,
        maxheight: height,
        border: [],
        borderchars: ['', '', '', '', '', '', '', ''],
        drag: 0,
        wrap: 1,
        cursorline: false,
        padding: [0, 1, 0, 1],
        mapping: 0,
        filter: (id, key) => {
            if key == "\<esc>"
                popup_close(id, -1)
            elseif ["\<cr>", "\<C-j>", "\<C-v>", "\<C-t>"]->index(key) > -1
                    && filtered_items[0]->len() > 0 && items_count > 0
                popup_close(id, {idx: getcurpos(id)[1], key: key})
            elseif key == "\<tab>" || key == "\<C-n>"
                var ln = getcurpos(id)[1]
                win_execute(id, "normal! j")
                if ln == getcurpos(id)[1]
                    win_execute(id, "normal! gg")
                endif
            elseif key == "\<S-tab>" || key == "\<C-p>"
                var ln = getcurpos(id)[1]
                win_execute(id, "normal! k")
                if ln == getcurpos(id)[1]
                    win_execute(id, "normal! G")
                endif
            elseif ["\<cursorhold>", "\<ignore>"]->index(key) == -1
                if key == "\<C-U>" && !empty(prompt)
                    prompt = ""
                    filtered_items = [items_dict]
                elseif (key == "\<C-h>" || key == "\<bs>")
                    if empty(prompt) && close_on_bs
                        popup_close(id, {idx: getcurpos(id)[1], key: key})
                        return true
                    endif
                    prompt = prompt->strcharpart(0, prompt->strchars() - 1)
                    if empty(prompt)
                        filtered_items = [items_dict]
                    else
                        filtered_items = items_dict->matchfuzzypos(prompt, {key: "text"})
                    endif
                elseif key =~ '\p'
                    prompt ..= key
                    filtered_items = items_dict->matchfuzzypos(prompt, {key: "text"})
                endif
                popup_settext(id, Printify(filtered_items, []))
                popup_setoptions(id,
                    {title: $" ({items_count > 0 ? filtered_items[0]->len() : 0}/{items_count}) {title}: {prompt ?? hint} "})
            endif
            return true
        },
        callback: (id, result) => {
                if result->type() == v:t_number
                    if result > 0
                        Callback(filtered_items[0][result - 1], "")
                    endif
                else
                    Callback(filtered_items[0][result.idx - 1], result.key)
                endif
            }
        })

    win_execute(winid, "setl nu cursorline cursorlineopt=both")
    if Setup != null_function
        Setup(winid)
    endif
enddef

Toc function and a mapping

Create a file ~/.vim/after/ftplugin/rst.vim and put follwing there:

vim9script

import autoload 'popup.vim'
def Toc()
    var toc: list<dict<any>> = []
    var lvl_ch: list<string> = []
    for nr in range(1, line('$'))
        var line = getline(nr)
        var pline = getline(nr - 1)
        var ppline = getline(nr - 2)
        if line =~ '^\([-=#*~]\)\1*\s*$'
            if pline =~ '\S' && ppline == line
                var lvl = lvl_ch->index(line[0] .. line[0])
                if lvl == -1
                    lvl_ch->add(line[0] .. line[0])
                    lvl = lvl_ch->len() - 1
                endif
                toc->add({text: $'{repeat("\t", lvl)}{pline->trim()} ({nr - 1})', linenr: nr - 1})
            elseif pline =~ '^\S' && pline !~ '^\([-=#*~]\)\1*\s*$'
                var lvl = lvl_ch->index(line[0])
                if lvl == -1
                    lvl_ch->add(line[0])
                    lvl = lvl_ch->len() - 1
                endif
                toc->add({text: $'{repeat("\t", lvl)}{pline->trim()} ({nr - 1})', linenr: nr - 1})
            endif
        endif
    endfor

    popup.FilterMenu("TOC", toc,
        (res, key) => {
            exe $":{res.linenr}"
            normal! zz
        },
        (winid) => {
            win_execute(winid, "setl ts=4 list")
            win_execute(winid, $"syn match FilterMenuLineNr '(\\d\\+)$'")
            hi def link FilterMenuLineNr Comment
        })
enddef
nnoremap <buffer> <space>z <scriptcmd>Toc()<CR>

Instead of the last line nnoremap <buffer> ... use your own mapping to launch Table Of Contents.

Don't forget to restart vim.

Have fun!

PS, you can have the same for the markdown...