changeset 36445:961fd237bc23 draft

runtime(doc): include a TOC Vim9 plugin Commit: https://github.com/vim/vim/commit/b3ec5643cd496b59eefd3ce6854b99aea72abd0c Author: lagygoill <lacygoill@lacygoill.me> Date: Sat Nov 2 17:58:01 2024 +0100 runtime(doc): include a TOC Vim9 plugin closes: https://github.com/vim/vim/issues/10446 See :h help-TOC Signed-off-by: lagygoill <lacygoill@lacygoill.me> Signed-off-by: Christian Brabandt <cb@256bit.org>
author Christian Brabandt <cb@256bit.org>
date Sat, 02 Nov 2024 18:00:04 +0100
parents 9c8a788c96dc
children efc16615e72a
files runtime/doc/helphelp.txt runtime/doc/tags runtime/doc/version9.txt runtime/pack/dist/opt/helptoc/autoload/helptoc.vim runtime/pack/dist/opt/helptoc/plugin/helptoc.vim
diffstat 5 files changed, 1005 insertions(+), 2 deletions(-) [+]
line wrap: on
line diff
--- 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 @@ command: >
 			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*
--- a/runtime/doc/tags
+++ b/runtime/doc/tags
@@ -8062,11 +8062,13 @@ hasmapto()	builtin.txt	/*hasmapto()*
 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*
--- 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 @@ Changed~
   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 ~
new file mode 100644
--- /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
+
new file mode 100644
--- /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()