runtime(doc): include a TOC Vim9 plugin
closes: #10446
See :h help-TOC
Signed-off-by: lagygoill <lacygoill@lacygoill.me>
Signed-off-by: Christian Brabandt <cb@256bit.org>
diff --git a/runtime/doc/helphelp.txt b/runtime/doc/helphelp.txt
index 40039e3..86c4775 100644
--- a/runtime/doc/helphelp.txt
+++ b/runtime/doc/helphelp.txt
@@ -1,4 +1,4 @@
-*helphelp.txt* For Vim version 9.1. Last change: 2024 Apr 10
+*helphelp.txt* For Vim version 9.1. Last change: 2024 Nov 02
VIM REFERENCE MANUAL by Bram Moolenaar
@@ -246,6 +246,61 @@
To rebuild the help tags in the runtime directory
(requires write permission there): >
:helptags $VIMRUNTIME/doc
+<
+ *help-TOC* *help-toc-install*
+
+If you want to access an interactive table of contents, from any position in
+the file, you can use the helptoc plugin. Load the plugin with: >
+
+ packadd helptoc
+
+Then you can use the `:HelpToc` command to open a popup menu.
+The latter supports the following normal commands: >
+
+ key | effect
+ ----+---------------------------------------------------------
+ j | select next entry
+ k | select previous entry
+ J | same as j, and jump to corresponding line in main buffer
+ K | same as k, and jump to corresponding line in main buffer
+ c | select nearest entry from cursor position in main buffer
+ g | select first entry
+ G | select last entry
+ H | collapse one level
+ L | expand one level
+ p | print current entry on command-line
+
+ P | same as p but automatically, whenever selection changes
+ | press multiple times to toggle feature on/off
+
+ q | quit menu
+ z | redraw menu with current entry at center
+ + | increase width of popup menu
+ - | decrease width of popup menu
+ ? | show/hide a help window
+
+ <C-D> | scroll down half a page
+ <C-U> | scroll up half a page
+ <PageUp> | scroll down a whole page
+ <PageDown> | scroll up a whole page
+ <Home> | select first entry
+ <End> | select last entry
+
+The plugin can also provide a table of contents in man pages, markdown files,
+and terminal buffers. In the latter, the entries will be the past executed
+shell commands. To find those, the following regex is used: >
+
+ ^\w\+@\w\+:\f\+\$\s
+
+This is meant to match a default bash prompt. If it doesn't match your prompt,
+you can change the regex with the `shell_prompt` key from the `g:helptoc`
+dictionary variable: >
+
+ let g:helptoc = {'shell_prompt': 'regex matching your shell prompt'}
+
+Tip: After inserting a pattern to look for with the `/` command, if you press
+<Esc> instead of <CR>, you can then get more context for each remaining entry
+by pressing `J` or `K`.
==============================================================================
2. Translated help files *help-translated*
diff --git a/runtime/doc/tags b/runtime/doc/tags
index 9091976..1ae1ff6 100644
--- a/runtime/doc/tags
+++ b/runtime/doc/tags
@@ -8062,11 +8062,13 @@
hebrew hebrew.txt /*hebrew*
hebrew.txt hebrew.txt /*hebrew.txt*
help helphelp.txt /*help*
+help-TOC helphelp.txt /*help-TOC*
help-buffer-options helphelp.txt /*help-buffer-options*
help-context help.txt /*help-context*
help-curwin tips.txt /*help-curwin*
help-summary usr_02.txt /*help-summary*
help-tags tags 1
+help-toc-install helphelp.txt /*help-toc-install*
help-translated helphelp.txt /*help-translated*
help-writing helphelp.txt /*help-writing*
help-xterm-window helphelp.txt /*help-xterm-window*
diff --git a/runtime/doc/version9.txt b/runtime/doc/version9.txt
index 2a38466..66f98e1 100644
--- a/runtime/doc/version9.txt
+++ b/runtime/doc/version9.txt
@@ -1,4 +1,4 @@
-*version9.txt* For Vim version 9.1. Last change: 2024 Oct 27
+*version9.txt* For Vim version 9.1. Last change: 2024 Nov 02
VIM REFERENCE MANUAL by Bram Moolenaar
@@ -41602,6 +41602,7 @@
selection in the quickfix list with the "u" action.
- the putty terminal is detected using an |TermResponse| autocommand in
|defaults.vim| and Vim switches to a dark background
+- the |help-TOC| package is included to ease navigating the documentation.
*added-9.2*
Added ~
diff --git a/runtime/pack/dist/opt/helptoc/autoload/helptoc.vim b/runtime/pack/dist/opt/helptoc/autoload/helptoc.vim
new file mode 100644
index 0000000..087da79
--- /dev/null
+++ b/runtime/pack/dist/opt/helptoc/autoload/helptoc.vim
@@ -0,0 +1,940 @@
+vim9script noclear
+
+# Config {{{1
+
+const SHELL_PROMPT: string = g:
+ ->get('helptoc', {})
+ ->get('shell_prompt', '^\w\+@\w\+:\f\+\$\s')
+
+# Init {{{1
+
+const HELP_TEXT: list<string> =<< trim END
+ normal commands in help window
+ ──────────────────────────────
+ ? hide this help window
+ <C-J> scroll down one line
+ <C-K> scroll up one line
+
+ normal commands in TOC menu
+ ───────────────────────────
+ j select next entry
+ k select previous entry
+ J same as j, and jump to corresponding line in main buffer
+ K same as k, and jump to corresponding line in main buffer
+ c select nearest entry from cursor position in main buffer
+ g select first entry
+ G select last entry
+ H collapse one level
+ L expand one level
+ p print selected entry on command-line
+
+ P same as p but automatically, whenever selection changes
+ press multiple times to toggle feature on/off
+
+ q quit menu
+ z redraw menu with selected entry at center
+ + increase width of popup menu
+ - decrease width of popup menu
+ / look for given text with fuzzy algorithm
+ ? show help window
+
+ <C-D> scroll down half a page
+ <C-U> scroll up half a page
+ <PageUp> scroll down a whole page
+ <PageDown> scroll up a whole page
+ <Home> select first entry
+ <End> select last entry
+
+ title meaning
+ ─────────────
+ example: 12/34 (5/6)
+ broken down:
+
+ 12 index of selected entry
+ 34 index of last entry
+ 5 index of deepest level currently visible
+ 6 index of maximum possible level
+
+ tip
+ ───
+ after inserting a pattern to look for with the / command,
+ if you press <Esc> instead of <CR>, you can then get
+ more context for each remaining entry by pressing J or K
+END
+
+const MATCH_ENTRY: dict<dict<func: bool>> = {
+ help: {},
+
+ man: {
+ 1: (line: string, _): bool => line =~ '^\S',
+ 2: (line: string, _): bool => line =~ '^\%( \{3\}\)\=\S',
+ 3: (line: string, _): bool => line =~ '^\s\+\(\%(+\|-\)\S\+,\s\+\)*\%(+\|-\)\S\+',
+ },
+
+ markdown: {
+ 1: (line: string, nextline: string): bool =>
+ (line =~ '^#[^#]' || nextline =~ '^=\+$') && line =~ '\w',
+ 2: (line: string, nextline: string): bool =>
+ (line =~ '^##[^#]' || nextline =~ '^-\+$') && line =~ '\w',
+ 3: (line: string, _): bool => line =~ '^###[^#]',
+ 4: (line: string, _): bool => line =~ '^####[^#]',
+ 5: (line: string, _): bool => line =~ '^#####[^#]',
+ 6: (line: string, _): bool => line =~ '^######[^#]',
+ },
+
+ terminal: {
+ 1: (line: string, _): bool => line =~ SHELL_PROMPT,
+ }
+}
+
+const HELP_RULERS: dict<string> = {
+ '=': '^=\{40,}$',
+ '-': '^-\{40,}',
+}
+const HELP_RULER: string = HELP_RULERS->values()->join('\|')
+
+# the regex is copied from the help syntax plugin
+const HELP_TAG: string = '\*[#-)!+-~]\+\*\%(\s\|$\)\@='
+
+# Adapted from `$VIMRUNTIME/syntax/help.vim`.{{{
+#
+# The original regex is:
+#
+# ^[-A-Z .][-A-Z0-9 .()_]*\ze\(\s\+\*\|$\)
+#
+# Allowing a space or a hyphen at the start can give false positives, and is
+# useless, so we don't allow them.
+#}}}
+const HELP_HEADLINE: string = '^\C[A-Z.][-A-Z0-9 .()_]*\%(\s\+\*+\@!\|$\)'
+# ^--^
+# To prevent some false positives under `:help feature-list`.
+
+var lvls: dict<number>
+def InitHelpLvls()
+ lvls = {
+ '*01.1*': 0,
+ '1.': 0,
+ '1.2': 0,
+ '1.2.3': 0,
+ 'header ~': 0,
+ HEADLINE: 0,
+ tag: 0,
+ }
+enddef
+
+const AUGROUP: string = 'HelpToc'
+var fuzzy_entries: list<dict<any>>
+var help_winid: number
+var print_entry: bool
+var selected_entry_match: number
+
+# Interface {{{1
+export def Open() #{{{2
+ var type: string = GetType()
+ if !MATCH_ENTRY->has_key(type)
+ return
+ endif
+ if type == 'terminal' && win_gettype() == 'popup'
+ # trying to deal with a popup menu on top of a popup terminal seems
+ # too tricky for now
+ echomsg 'does not work in a popup window; only in a regular window'
+ return
+ endif
+
+ # invalidate the cache if the buffer's contents has changed
+ if exists('b:toc') && &filetype != 'man'
+ if b:toc.changedtick != b:changedtick
+ # in a terminal buffer, `b:changedtick` does not change
+ || type == 'terminal' && line('$') > b:toc.linecount
+ unlet! b:toc
+ endif
+ endif
+
+ if !exists('b:toc')
+ SetToc()
+ endif
+
+ var winpos: list<number> = winnr()->win_screenpos()
+ var height: number = winheight(0) - 2
+ var width: number = winwidth(0)
+ b:toc.width = b:toc.width ?? width / 3
+ # the popup needs enough space to display the help message in its title
+ if b:toc.width < 30
+ b:toc.width = 30
+ endif
+ # Is `popup_menu()` OK with a list of dictionaries?{{{
+ #
+ # Yes, see `:help popup_create-arguments`.
+ # Although, it expects dictionaries with the keys `text` and `props`.
+ # But we use dictionaries with the keys `text` and `lnum`.
+ # IOW, we abuse the feature which lets us use text properties in a popup.
+ #}}}
+ var winid: number = GetTocEntries()
+ ->popup_menu({
+ line: winpos[0],
+ col: winpos[1] + width - 1,
+ pos: 'topright',
+ scrollbar: false,
+ highlight: type == 'terminal' ? 'Terminal' : 'Normal',
+ border: [],
+ borderchars: ['─', '│', '─', '│', '┌', '┐', '┘', '└'],
+ minheight: height,
+ maxheight: height,
+ minwidth: b:toc.width,
+ maxwidth: b:toc.width,
+ filter: Filter,
+ callback: Callback,
+ })
+ Win_execute(winid, [$'ownsyntax {&filetype}', '&l:conceallevel = 3'])
+ # In a help file, we might reduce some noisy tags to a trailing asterisk.
+ # Hide those.
+ if type == 'help'
+ matchadd('Conceal', '\*$', 0, -1, {window: winid})
+ endif
+ SelectNearestEntryFromCursor(winid)
+
+ # can't set the title before jumping to the relevant line, otherwise the
+ # indicator in the title might be wrong
+ SetTitle(winid)
+enddef
+#}}}1
+# Core {{{1
+def SetToc() #{{{2
+ var toc: dict<any> = {entries: []}
+ var type: string = GetType()
+ toc.changedtick = b:changedtick
+ if !toc->has_key('width')
+ toc.width = 0
+ endif
+ # We cache the toc in `b:toc` to get better performance.{{{
+ #
+ # Without caching, when we press `H`, `L`, `H`, `L`, ... quickly for a few
+ # seconds, there is some lag if we then try to move with `j` and `k`.
+ # This can only be perceived in big man pages like with `:Man ffmpeg-all`.
+ #}}}
+ b:toc = toc
+
+ if type == 'help'
+ SetTocHelp()
+ return
+ endif
+
+ if type == 'terminal'
+ b:toc.linecount = line('$')
+ endif
+
+ var curline: string = getline(1)
+ var nextline: string
+ var lvl_and_test: list<list<any>> = MATCH_ENTRY
+ ->get(type, {})
+ ->items()
+ ->sort((l: list<any>, ll: list<any>): number => l[0]->str2nr() - ll[0]->str2nr())
+
+ for lnum: number in range(1, line('$'))
+ nextline = getline(lnum + 1)
+ for [lvl: string, IsEntry: func: bool] in lvl_and_test
+ if IsEntry(curline, nextline)
+ b:toc.entries->add({
+ lnum: lnum,
+ lvl: lvl->str2nr(),
+ text: curline,
+ })
+ break
+ endif
+ endfor
+ curline = nextline
+ endfor
+
+ InitMaxAndCurLvl()
+enddef
+
+def SetTocHelp() #{{{2
+ var main_ruler: string
+ for line: string in getline(1, '$')
+ if line =~ HELP_RULER
+ main_ruler = line =~ '=' ? HELP_RULERS['='] : HELP_RULERS['-']
+ break
+ endif
+ endfor
+
+ var prevline: string
+ var curline: string = getline(1)
+ var nextline: string
+ var in_list: bool
+ var last_numbered_entry: number
+ InitHelpLvls()
+ for lnum: number in range(1, line('$'))
+ nextline = getline(lnum + 1)
+
+ if main_ruler != '' && curline =~ main_ruler
+ last_numbered_entry = 0
+ # The information gathered in `lvls` might not be applicable to all
+ # the main sections of a help file. Let's reset it whenever we find
+ # a ruler.
+ InitHelpLvls()
+ endif
+
+ # Do not assume that a list ends on an empty line.
+ # See the list at `:help gdb` for a counter-example.
+ if in_list
+ && curline !~ '^\d\+.\s'
+ && curline !~ '^\s*$'
+ && curline !~ '^[< \t]'
+ in_list = false
+ endif
+
+ if prevline =~ '^\d\+\.\s'
+ && curline !~ '^\s*$'
+ && curline !~ $'^\s*{HELP_TAG}'
+ in_list = true
+ endif
+
+ # 1.
+ if prevline =~ '^\d\+\.\s'
+ # let's assume that the start of a main entry is always followed by an
+ # empty line, or a line starting with a tag
+ && (curline =~ '^>\=\s*$' || curline =~ $'^\s*{HELP_TAG}')
+ # ignore a numbered line in a list
+ && !in_list
+ var current_numbered_entry: number = prevline
+ ->matchstr('^\d\+\ze\.\s')
+ ->str2nr()
+ if current_numbered_entry > last_numbered_entry
+ AddEntryInTocHelp('1.', lnum - 1, prevline)
+ last_numbered_entry = prevline
+ ->matchstr('^\d\+\ze\.\s')
+ ->str2nr()
+ endif
+ endif
+
+ # 1.2
+ if curline =~ '^\d\+\.\d\+\s'
+ if curline =~ $'\%({HELP_TAG}\s*\|\~\)$'
+ || (prevline =~ $'^\s*{HELP_TAG}' || nextline =~ $'^\s*{HELP_TAG}')
+ || (prevline =~ HELP_RULER || nextline =~ HELP_RULER)
+ || (prevline =~ '^\s*$' && nextline =~ '^\s*$')
+ AddEntryInTocHelp('1.2', lnum, curline)
+ endif
+ # 1.2.3
+ elseif curline =~ '^\s\=\d\+\.\d\+\.\d\+\s'
+ AddEntryInTocHelp('1.2.3', lnum, curline)
+ endif
+
+ # HEADLINE
+ if curline =~ HELP_HEADLINE
+ && curline !~ '^CTRL-'
+ && prevline->IsSpecialHelpLine()
+ && (nextline->IsSpecialHelpLine() || nextline =~ '^\s*(\|^\t\|^N[oO][tT][eE]:')
+ AddEntryInTocHelp('HEADLINE', lnum, curline)
+ endif
+
+ # header ~
+ if curline =~ '\~$'
+ && curline =~ '\w'
+ && curline !~ '^[ \t<]\|\t\|---+---\|^NOTE:'
+ && curline !~ '^\d\+\.\%(\d\+\%(\.\d\+\)\=\)\=\s'
+ && prevline !~ $'^\s*{HELP_TAG}'
+ && prevline !~ '\~$'
+ && nextline !~ '\~$'
+ AddEntryInTocHelp('header ~', lnum, curline)
+ endif
+
+ # *some_tag*
+ if curline =~ HELP_TAG
+ AddEntryInTocHelp('tag', lnum, curline)
+ endif
+
+ # In the Vim user manual, a main section is a special case.{{{
+ #
+ # It's not a simple numbered section:
+ #
+ # 01.1
+ #
+ # It's used as a tag:
+ #
+ # *01.1* Two manuals
+ # ^ ^
+ #}}}
+ if prevline =~ main_ruler && curline =~ '^\*\d\+\.\d\+\*'
+ AddEntryInTocHelp('*01.1*', lnum, curline)
+ endif
+
+ [prevline, curline] = [curline, nextline]
+ endfor
+
+ # let's ignore the tag on the first line (not really interesting)
+ if b:toc.entries->get(0, {})->get('lnum') == 1
+ b:toc.entries->remove(0)
+ endif
+
+ # let's also ignore anything before the first `1.` line
+ var i: number = b:toc.entries
+ ->copy()
+ ->map((_, entry: dict<any>) => entry.text)
+ ->match('^\s*1\.\s')
+ if i > 0
+ b:toc.entries->remove(0, i - 1)
+ endif
+
+ InitMaxAndCurLvl()
+
+ # set level of tag entries to the deepest level
+ var has_tag: bool = b:toc.entries
+ ->copy()
+ ->map((_, entry: dict<any>) => entry.text)
+ ->match(HELP_TAG) >= 0
+ if has_tag
+ ++b:toc.maxlvl
+ endif
+ b:toc.entries
+ ->map((_, entry: dict<any>) => entry.lvl == 0
+ ? entry->extend({lvl: b:toc.maxlvl})
+ : entry)
+
+ # fix indentation
+ var min_lvl: number = b:toc.entries
+ ->copy()
+ ->map((_, entry: dict<any>) => entry.lvl)
+ ->min()
+ for entry: dict<any> in b:toc.entries
+ entry.text = entry.text
+ ->substitute('^\s*', () => repeat(' ', (entry.lvl - min_lvl) * 3), '')
+ endfor
+enddef
+
+def AddEntryInTocHelp(type: string, lnum: number, line: string) #{{{2
+ # don't add a duplicate entry
+ if lnum == b:toc.entries->get(-1, {})->get('lnum')
+ # For a numbered line containing a tag, *do* add an entry.
+ # But only for its numbered prefix, not for its tag.
+ # The former is the line's most meaningful representation.
+ if b:toc.entries->get(-1, {})->get('type') == 'tag'
+ b:toc.entries->remove(-1)
+ else
+ return
+ endif
+ endif
+
+ var text: string = line
+ if type == 'tag'
+ var tags: list<string>
+ text->substitute(HELP_TAG, () => !!tags->add(submatch(0)), 'g')
+ text = tags
+ # we ignore errors and warnings because those are meaningless in
+ # a TOC where no context is available
+ ->filter((_, tag: string) => tag !~ '\*[EW]\d\+\*')
+ ->join()
+ if text !~ HELP_TAG
+ return
+ endif
+ endif
+
+ var maxlvl: number = lvls->values()->max()
+ if type == 'tag'
+ lvls[type] = 0
+ elseif type == '1.2'
+ lvls[type] = lvls[type] ?? lvls->get('1.', maxlvl) + 1
+ elseif type == '1.2.3'
+ lvls[type] = lvls[type] ?? lvls->get('1.2', maxlvl) + 1
+ else
+ lvls[type] = lvls[type] ?? maxlvl + 1
+ endif
+
+ # Ignore noisy tags.{{{
+ #
+ # 14. Linking groups *:hi-link* *:highlight-link* *E412* *E413*
+ # ^----------------------------------------^
+ # ^\s*\d\+\.\%(\d\+\.\=\)*\s\+.\{-}\zs\*.*
+ # ---
+ #
+ # We don't use conceal because then, `matchfuzzypos()` could match concealed
+ # characters, which would be confusing.
+ #}}}
+ # MAKING YOUR OWN SYNTAX FILES *mysyntaxfile*
+ # ^------------^
+ # ^\s*[A-Z].\{-}\*\zs.*
+ #
+ var after_HEADLINE: string = '^\s*[A-Z].\{-}\*\zs.*'
+ # 14. Linking groups *:hi-link* *:highlight-link* *E412* *E413*
+ # ^----------------------------------------^
+ # ^\s*\d\+\.\%(\d\+\.\=\)*\s\+.\{-}\*\zs.*
+ var after_numbered: string = '^\s*\d\+\.\%(\d\+\.\=\)*\s\+.\{-}\*\zs.*'
+ # 01.3 Using the Vim tutor *tutor* *vimtutor*
+ # ^----------------^
+ var after_numbered_tutor: string = '^\*\d\+\.\%(\d\+\.\=\)*.\{-}\t\*\zs.*'
+ var noisy_tags: string = $'{after_HEADLINE}\|{after_numbered}\|{after_numbered_tutor}'
+ text = text->substitute(noisy_tags, '', '')
+ # We don't remove the trailing asterisk, because the help syntax plugin
+ # might need it to highlight some headlines.
+
+ b:toc.entries->add({
+ lnum: lnum,
+ lvl: lvls[type],
+ text: text,
+ type: type,
+ })
+enddef
+
+def InitMaxAndCurLvl() #{{{2
+ b:toc.maxlvl = b:toc.entries
+ ->copy()
+ ->map((_, entry: dict<any>) => entry.lvl)
+ ->max()
+ b:toc.curlvl = b:toc.maxlvl
+enddef
+
+def Popup_settext(winid: number, entries: list<dict<any>>) #{{{2
+ var text: list<any>
+ # When we fuzzy search the toc, the dictionaries in `entries` contain a
+ # `props` key, to highlight each matched character individually.
+ # We don't want to process those dictionaries further.
+ # The processing should already have been done by the caller.
+ if entries->get(0, {})->has_key('props')
+ text = entries
+ else
+ text = entries
+ ->copy()
+ ->map((_, entry: dict<any>): string => entry.text)
+ endif
+ popup_settext(winid, text)
+ SetTitle(winid)
+ redraw
+enddef
+
+def SetTitle(winid: number) #{{{2
+ var curlnum: number
+ var lastlnum: number = line('$', winid)
+ var is_empty: bool = lastlnum == 1
+ && winid->winbufnr()->getbufoneline(1) == ''
+ if is_empty
+ [curlnum, lastlnum] = [0, 0]
+ else
+ curlnum = line('.', winid)
+ endif
+ var newtitle: string = printf(' %*d/%d (%d/%d)',
+ len(lastlnum), curlnum,
+ lastlnum,
+ b:toc.curlvl,
+ b:toc.maxlvl,
+ )
+
+ var width: number = winid->popup_getoptions().minwidth
+ newtitle = printf('%s%*s',
+ newtitle,
+ width - newtitle->strlen(),
+ 'press ? for help ')
+
+ popup_setoptions(winid, {title: newtitle})
+enddef
+
+def SelectNearestEntryFromCursor(winid: number) #{{{2
+ var lnum: number = line('.')
+ var firstline: number = b:toc.entries
+ ->copy()
+ ->filter((_, line: dict<any>): bool => line.lvl <= b:toc.curlvl && line.lnum <= lnum)
+ ->len()
+ if firstline == 0
+ return
+ endif
+ Win_execute(winid, $'normal! {firstline}Gzz')
+enddef
+
+def Filter(winid: number, key: string): bool #{{{2
+ # support various normal commands for moving/scrolling
+ if [
+ 'j', 'J', 'k', 'K', "\<Down>", "\<Up>", "\<C-N>", "\<C-P>",
+ "\<C-D>", "\<C-U>",
+ "\<PageUp>", "\<PageDown>",
+ 'g', 'G', "\<Home>", "\<End>",
+ 'z'
+ ]->index(key) >= 0
+ var scroll_cmd: string = {
+ J: 'j',
+ K: 'k',
+ g: '1G',
+ "\<Home>": '1G',
+ "\<End>": 'G',
+ z: 'zz'
+ }->get(key, key)
+
+ var old_lnum: number = line('.', winid)
+ Win_execute(winid, $'normal! {scroll_cmd}')
+ var new_lnum: number = line('.', winid)
+
+ if print_entry
+ PrintEntry(winid)
+ endif
+
+ # wrap around the edges
+ if new_lnum == old_lnum
+ scroll_cmd = {
+ j: '1G',
+ J: '1G',
+ k: 'G',
+ K: 'G',
+ "\<Down>": '1G',
+ "\<Up>": 'G',
+ "\<C-N>": '1G',
+ "\<C-P>": 'G',
+ }->get(key, '')
+ if !scroll_cmd->empty()
+ Win_execute(winid, $'normal! {scroll_cmd}')
+ endif
+ endif
+
+ # move the cursor to the corresponding line in the main buffer
+ if key == 'J' || key == 'K'
+ var lnum: number = GetBufLnum(winid)
+ execute $'normal! 0{lnum}zt'
+ # install a match in the regular buffer to highlight the position of
+ # the entry in the latter
+ MatchDelete()
+ selected_entry_match = matchaddpos('IncSearch', [lnum], 0, -1)
+ endif
+ SetTitle(winid)
+
+ return true
+
+ elseif key == 'c'
+ SelectNearestEntryFromCursor(winid)
+ return true
+
+ # when we press `p`, print the selected line (useful when it's truncated)
+ elseif key == 'p'
+ PrintEntry(winid)
+ return true
+
+ # same thing, but automatically
+ elseif key == 'P'
+ print_entry = !print_entry
+ if print_entry
+ PrintEntry(winid)
+ else
+ echo ''
+ endif
+ return true
+
+ elseif key == 'q'
+ popup_close(winid, -1)
+ return true
+
+ elseif key == '?'
+ ToggleHelp(winid)
+ return true
+
+ # scroll help window
+ elseif key == "\<C-J>" || key == "\<C-K>"
+ var scroll_cmd: string = {"\<C-J>": 'j', "\<C-K>": 'k'}->get(key, key)
+ if scroll_cmd == 'j' && line('.', help_winid) == line('$', help_winid)
+ scroll_cmd = '1G'
+ elseif scroll_cmd == 'k' && line('.', help_winid) == 1
+ scroll_cmd = 'G'
+ endif
+ Win_execute(help_winid, $'normal! {scroll_cmd}')
+ return true
+
+ # increase/decrease the popup's width
+ elseif key == '+' || key == '-'
+ var width: number = winid->popup_getoptions().minwidth
+ if key == '-' && width == 1
+ || key == '+' && winid->popup_getpos().col == 1
+ return true
+ endif
+ width = width + (key == '+' ? 1 : -1)
+ # remember the last width if we close and re-open the TOC later
+ b:toc.width = width
+ popup_setoptions(winid, {minwidth: width, maxwidth: width})
+ return true
+
+ elseif key == 'H' && b:toc.curlvl > 1
+ || key == 'L' && b:toc.curlvl < b:toc.maxlvl
+ CollapseOrExpand(winid, key)
+ return true
+
+ elseif key == '/'
+ # This is probably what the user expect if they've started a first fuzzy
+ # search, press Escape, then start a new one.
+ DisplayNonFuzzyToc(winid)
+
+ [{
+ group: AUGROUP,
+ event: 'CmdlineChanged',
+ pattern: '@',
+ cmd: $'FuzzySearch({winid})',
+ replace: true,
+ }, {
+ group: AUGROUP,
+ event: 'CmdlineLeave',
+ pattern: '@',
+ cmd: 'TearDown()',
+ replace: true,
+ }]->autocmd_add()
+
+ # Need to evaluate `winid` right now with an `eval`'ed and `execute()`'ed heredoc because:{{{
+ #
+ # - the mappings can only access the script-local namespace
+ # - `winid` is in the function namespace; not in the script-local one
+ #}}}
+ var input_mappings: list<string> =<< trim eval END
+ cnoremap <buffer><nowait> <Down> <ScriptCmd>Filter({winid}, 'j')<CR>
+ cnoremap <buffer><nowait> <Up> <ScriptCmd>Filter({winid}, 'k')<CR>
+ cnoremap <buffer><nowait> <C-N> <ScriptCmd>Filter({winid}, 'j')<CR>
+ cnoremap <buffer><nowait> <C-P> <ScriptCmd>Filter({winid}, 'k')<CR>
+ END
+ input_mappings->execute()
+
+ var look_for: string
+ try
+ popup_setoptions(winid, {mapping: true})
+ look_for = input('look for: ', '', $'custom,{Complete->string()}') | redraw | echo ''
+ catch /Vim:Interrupt/
+ TearDown()
+ finally
+ popup_setoptions(winid, {mapping: false})
+ endtry
+ return look_for == '' ? true : popup_filter_menu(winid, "\<CR>")
+ endif
+
+ return popup_filter_menu(winid, key)
+enddef
+
+def FuzzySearch(winid: number) #{{{2
+ var look_for: string = getcmdline()
+ if look_for == ''
+ DisplayNonFuzzyToc(winid)
+ return
+ endif
+
+ # We match against *all* entries; not just the currently visible ones.
+ # Rationale: If we use a (fuzzy) search, we're probably lost. We don't know
+ # where the info is.
+ var matches: list<list<any>> = b:toc.entries
+ ->copy()
+ ->matchfuzzypos(look_for, {key: 'text'})
+
+ fuzzy_entries = matches->get(0, [])->copy()
+ var pos: list<list<number>> = matches->get(1, [])
+
+ var text: list<dict<any>>
+ if !has('textprop')
+ text = matches->get(0, [])
+ else
+ var buf: number = winid->winbufnr()
+ if prop_type_get('help-fuzzy-toc', {bufnr: buf}) == {}
+ prop_type_add('help-fuzzy-toc', {
+ bufnr: buf,
+ combine: false,
+ highlight: 'IncSearch',
+ })
+ endif
+ text = matches
+ ->get(0, [])
+ ->map((i: number, match: dict<any>) => ({
+ text: match.text,
+ props: pos[i]->copy()->map((_, col: number) => ({
+ col: col + 1,
+ length: 1,
+ type: 'help-fuzzy-toc',
+ }))}))
+ endif
+ Win_execute(winid, 'normal! 1Gzt')
+ Popup_settext(winid, text)
+enddef
+
+def DisplayNonFuzzyToc(winid: number) #{{{2
+ fuzzy_entries = null_list
+ Popup_settext(winid, GetTocEntries())
+enddef
+
+def PrintEntry(winid: number) #{{{2
+ echo GetTocEntries()[line('.', winid) - 1]['text']
+enddef
+
+def CollapseOrExpand(winid: number, key: string) #{{{2
+ # Must be saved before we reset the popup contents, so we can
+ # automatically select the least unexpected entry in the updated popup.
+ var buf_lnum: number = GetBufLnum(winid)
+
+ # find the nearest lower level for which the contents of the TOC changes
+ if key == 'H'
+ while b:toc.curlvl > 1
+ var old: list<dict<any>> = GetTocEntries()
+ --b:toc.curlvl
+ var new: list<dict<any>> = GetTocEntries()
+ # In `:help`, there are only entries in levels 3.
+ # We don't want to collapse to level 2, nor 1.
+ # It would clear the TOC which is confusing.
+ if new->empty()
+ ++b:toc.curlvl
+ break
+ endif
+ var did_change: bool = new != old
+ if did_change || b:toc.curlvl == 1
+ break
+ endif
+ endwhile
+ # find the nearest upper level for which the contents of the TOC changes
+ else
+ while b:toc.curlvl < b:toc.maxlvl
+ var old: list<dict<any>> = GetTocEntries()
+ ++b:toc.curlvl
+ var did_change: bool = GetTocEntries() != old
+ if did_change || b:toc.curlvl == b:toc.maxlvl
+ break
+ endif
+ endwhile
+ endif
+
+ # update the popup contents
+ var toc_entries: list<dict<any>> = GetTocEntries()
+ Popup_settext(winid, toc_entries)
+
+ # Try to select the same entry; if it's no longer visible, select its
+ # direct parent.
+ var toc_lnum: number = 0
+ for entry: dict<any> in toc_entries
+ if entry.lnum > buf_lnum
+ break
+ endif
+ ++toc_lnum
+ endfor
+ Win_execute(winid, $'normal! {toc_lnum ?? 1}Gzz')
+enddef
+
+def MatchDelete() #{{{2
+ if selected_entry_match == 0
+ return
+ endif
+
+ selected_entry_match->matchdelete()
+ selected_entry_match = 0
+enddef
+
+def Callback(winid: number, choice: number) #{{{2
+ MatchDelete()
+
+ if help_winid != 0
+ help_winid->popup_close()
+ help_winid = 0
+ endif
+
+ if choice == -1
+ fuzzy_entries = null_list
+ return
+ endif
+
+ var lnum: number = GetTocEntries()
+ ->get(choice - 1, {})
+ ->get('lnum')
+
+ fuzzy_entries = null_list
+
+ if lnum == 0
+ return
+ endif
+
+ cursor(lnum, 1)
+ normal! zvzt
+enddef
+
+def ToggleHelp(menu_winid: number) #{{{2
+ if help_winid == 0
+ var height: number = [HELP_TEXT->len(), winheight(0) * 2 / 3]->min()
+ var longest_line: number = HELP_TEXT
+ ->copy()
+ ->map((_, line: string) => line->strcharlen())
+ ->max()
+ var width: number = [longest_line, winwidth(0) * 2 / 3]->min()
+ var pos: dict<number> = popup_getpos(menu_winid)
+ var [line: number, col: number] = [pos.line, pos.col]
+ --col
+ var zindex: number = popup_getoptions(menu_winid).zindex
+ ++zindex
+ help_winid = HELP_TEXT->popup_create({
+ line: line,
+ col: col,
+ pos: 'topright',
+ minheight: height,
+ maxheight: height,
+ minwidth: width,
+ maxwidth: width,
+ border: [],
+ borderchars: ['─', '│', '─', '│', '┌', '┐', '┘', '└'],
+ highlight: &buftype == 'terminal' ? 'Terminal' : 'Normal',
+ scrollbar: false,
+ zindex: zindex,
+ })
+
+ setwinvar(help_winid, '&cursorline', true)
+ setwinvar(help_winid, '&linebreak', true)
+ matchadd('Special', '^<\S\+\|^\S\{,2} \@=', 0, -1, {window: help_winid})
+ matchadd('Number', '\d\+', 0, -1, {window: help_winid})
+ for lnum: number in HELP_TEXT->len()->range()
+ if HELP_TEXT[lnum] =~ '^─\+$'
+ matchaddpos('Title', [lnum], 0, -1, {window: help_winid})
+ endif
+ endfor
+
+ else
+ if IsVisible(help_winid)
+ popup_hide(help_winid)
+ else
+ popup_show(help_winid)
+ endif
+ endif
+enddef
+
+def Win_execute(winid: number, cmd: any) #{{{2
+# wrapper around `win_execute()` to enforce a redraw, which might be necessary
+# whenever we change the cursor position
+ win_execute(winid, cmd)
+ redraw
+enddef
+
+def TearDown() #{{{2
+ autocmd_delete([{group: AUGROUP}])
+ cunmap <buffer> <Down>
+ cunmap <buffer> <Up>
+ cunmap <buffer> <C-N>
+ cunmap <buffer> <C-P>
+enddef
+#}}}1
+# Util {{{1
+def GetType(): string #{{{2
+ return &buftype == 'terminal' ? 'terminal' : &filetype
+enddef
+
+def GetTocEntries(): list<dict<any>> #{{{2
+ return fuzzy_entries ?? b:toc.entries
+ ->copy()
+ ->filter((_, entry: dict<any>): bool => entry.lvl <= b:toc.curlvl)
+enddef
+
+def GetBufLnum(winid: number): number #{{{2
+ var toc_lnum: number = line('.', winid)
+ return GetTocEntries()
+ ->get(toc_lnum - 1, {})
+ ->get('lnum')
+enddef
+
+def IsVisible(win: number): bool #{{{2
+ return win->popup_getpos()->get('visible')
+enddef
+
+def IsSpecialHelpLine(line: string): bool #{{{2
+ return line =~ '^[<>]\=\s*$'
+ || line =~ '^\s*\*'
+ || line =~ HELP_RULER
+ || line =~ HELP_HEADLINE
+enddef
+
+def Complete(..._): string #{{{2
+ return b:toc.entries
+ ->copy()
+ ->map((_, entry: dict<any>) => entry.text->trim(' ~')->substitute('*', '', 'g'))
+ ->filter((_, text: string): bool => text =~ '^[-a-zA-Z0-9_() ]\+$')
+ ->sort()
+ ->uniq()
+ ->join("\n")
+enddef
+
diff --git a/runtime/pack/dist/opt/helptoc/plugin/helptoc.vim b/runtime/pack/dist/opt/helptoc/plugin/helptoc.vim
new file mode 100644
index 0000000..4fb3bc3
--- /dev/null
+++ b/runtime/pack/dist/opt/helptoc/plugin/helptoc.vim
@@ -0,0 +1,5 @@
+vim9script noclear
+
+import autoload '../autoload/helptoc.vim'
+
+command -bar HelpToc helptoc.Open()