hover.nvim · Files & navigation · vimdoc

:help hover

Preview whatever the cursor is resting on

doc/hover.txt — rendered from the plugin's own vimdoc

*hover.txt*   Preview whatever the cursor is resting on           *hover.nvim*

CONTENTS *hover-contents*

  1. Introduction .................... |hover-introduction|
  2. Requirements .................... |hover-requirements|
  3. Setup ........................... |hover-setup|
  4. What is opt-in .................. |hover-opt-in|
  5. Modes ........................... |hover-modes|
  6. Commands ........................ |hover-commands|
  7. Keys ............................ |hover-keys|
  8. Bare paths ...................... |hover-bare-paths|
  9. Configuration ................... |hover-config|
 10. API ............................. |hover-api|
 11. Registry ........................ |hover-registry|
 12. Health .......................... |hover-health|

1. INTRODUCTION *hover-introduction*

Rest the cursor on something that points at a file -- a markdown link, or a
path written as plain text -- and a small float shows what it points at: a
file's first lines, a directory's entries, a picture, a PDF page, a markdown
section, a URL taken apart -- or, when the target does not exist, that.

A file whose bytes are not text (a .docx, an archive, an executable) gets a
badge naming the format rather than a float full of its bytes.

A git object id (7 to 40 hex characters) is a target too, but only via
:Hover show -- never on the automatic trigger. Asking git whether a hex
string is an object costs a git start, ~41 ms measured, the same whether it
hits or misses.

2. REQUIREMENTS *hover-requirements*

Neovim 0.10 or newer.

StefanBartl/lib.nvim is a HARD dependency -- required with no fallback. The
debounce, the notifier, the LRU cache, the autocmd helpers and the :Hover
command composer all come from it.

Four optional contributors, none required. What each one adds (+), and what
the hover falls back to without it (-):

  markdown.nvim  + link and <figure> scanning; #heading section previews
                 - only bare paths hover; file.md#frag shows the file head

  images.nvim    + draws the picture into the float, over OSC 1337
                 - an image (or a PDF page) is described as text instead

  pdfport.nvim   + rasterizes a PDF page; converts an office document
                 - a PDF shows its size; an office file shows a badge

  media.nvim     + lifts a still out of a video (ffmpeg + ffprobe)
                 - a video shows its size, not a frame from it

  gopath.nvim    + resolves truncated paths and :line:col suffixes
                 - ordinary paths still resolve, truncated ones do not
Without any of them you still get file heads, directory listings, image and
PDF metadata, the binary badge, the "does not exist" answer, and -- once
:Hover links web on (plus :Hover auto url, the second gate) -- URL
details.

3. SETUP *hover-setup*

                                                                *hover.enable()*
  require("hover").enable()
Installs the FileType trigger, attaches to buffers already open, registers
:Hover, and is idempotent.

Put it somewhere that is NOT lazy-loaded. A path in a .txt or a code comment
is a target too, so a spec that only loads on markdown leaves the feature
silently dead in every other filetype.

enable(opts) also takes the configuration table (see |hover-config|), so
switching it on and configuring it is one call.

:Hover itself is registered from plugin/hover.lua regardless, so
:Hover mode auto is reachable even from a session where nothing ran
enable().

4. WHAT IS OPT-IN *hover-opt-in*

A float that opens unasked is welcome only when both hold:

  1. The target was explicit. Link syntax is the author stating "this points
     somewhere". A bare path in prose is this plugin guessing.
  2. The preview says something the line does not. A file's first lines cannot
     be read off the link text. A URL's host and path can -- they ARE the link
     text.

Cost breaks the tie. That yields these defaults:

  ON    link to a local file, image or PDF; a bare path that resolves;
        directory listings; pictures drawn into the float
  OFF   http(s) links (the offline preview restates the link)
  OFF   fetching a link (a request to that host, per link brushed past)
  OFF   rendering a link in a browser (the page RUNS; ~0.7 s to start one,
        and 4-20 s for a page) -- and off a second time for the trigger
  OFF   office documents rendered (a LibreOffice start, per document)

Web links are off because documentation is made of links: on permanently,
reading a README becomes a slideshow and the float lands over the paragraph
being read. Fetching is off a second time on top of that, because it is a
disclosure -- :Hover links web on alone never touches the network.

Once fetching is on, the float carries the status line, the title, the
description, the content type and -- for an HTML page -- the page's own text,
trimmed to whatever room the float has left. That last part has no switch of
its own, on purpose: the body is downloaded either way, so turning it into
prose costs no request and no second disclosure. F over such a link is
therefore worth pressing; see |hover-zen|.

                                                        *hover-auto-hover*
WHAT OPENS BY ITSELF is a second axis, and it says what the list above cannot:
those are organised by where a target was FOUND, this one by what it turned out
to BE. A markdown link can point at a picture or at a text file, so
"pictures only, however they were written" needed its own setting.

  require("hover").setup({
    auto_hover = { "image", "pdf" },   -- the default
    -- auto_hover = true,              -- every type, as before 2026-09-03
    -- auto_hover = false,             -- none; same effect as mode = "manual"
  })
Pictures and pages are the default because they are the only thing this plugin
shows that cannot be read off the line the cursor is on. A text file's first
lines are a shortcut for something you could also open, and the float lands
over the paragraph you were reading to give it to you.

Names are the target types plus position for a plugin answering about the
place the cursor is in. A list is a closed set; a table is additive
({ file = true } adds one); :Hover auto [type] toggles one for the
session and :Hover auto lists them.

IT GATES THE TRIGGER, NOT THE PLUGIN. :Hover show answers for every type
regardless -- which is the difference between this and 'paths.enabled' and its
neighbours: those decide whether something is a target at all. Note that
position previews are off by default with the rest, so a plugin answering for
a place waits to be asked until :Hover auto position.

5. MODES *hover-modes*

The switch above every other switch.

  auto     the trigger opens a float, as configured. The default.
  manual   nothing opens by itself. `:Hover show`, `keymaps.show` and
           show({ force = true }) still answer IN FULL, web links included.
  off      nothing opens at all.
manual is the answer to "I am reading a document made of links right now"
without deciding class by class which noise is acceptable.

  require("hover").setup({
    mode = "manual",
    keymaps = { show = "<leader>k" },
  })
                                                          *g:hover_disable*
vim.g.hover_disable = true forces "off" and outranks anything a plugin
configured. It is where a user says "not on this machine" from a plugin spec's
init, before anything loads. |hover.set_mode()| keeps it in step, so the two
cannot disagree. An explicit request does not override it: force opens the
volume switches, never the mode. "Silent unless asked" is mode = "manual".

6. COMMANDS *hover-commands*

                                                                       *:Hover*

Every route completes with <Tab>. The state argument may be omitted, which
toggles.

  :Hover show                            one hover, here, now -- ignores
                                         every volume switch
  :Hover open                            hand what the float is showing
                                         to whatever opens it outside
                                         Neovim -- a media file to
                                         media.nvim's configured player,
                                         anything else to the system
                                         default app, as a double-click
  :Hover dashboard                       the mode, every switch and what
                                         opens by itself, as a board:
                                         <CR> toggles the row, ? lists
                                         the keys, y yanks its command
  :Hover pin                             keep this float while the cursor
                                         goes elsewhere; again releases it
  :Hover zen [on|off|toggle]             the float on almost the whole
                                         editor, and back; pins by default
  :Hover resize [bigger|smaller]         the hover bigger or smaller;
                                         omitted, bigger
  :Hover zoom [in|out|reset]             magnify a detail of the picture
                                         or PDF page; omitted, in
  :Hover nav {left|right|up|down}        move the magnified view
  :Hover next                            the next plugin with something
                                         to say about this place
  :Hover why                             why nothing hovered here: which
                                         gate refused, and what to type
  :Hover auto [<type>|all|none]          which types open by themselves;
                                         omitted, list them
  :Hover border [style]                  the frame's look; omitted,
                                         report it and list the rest
  :Hover mode [auto|manual|off]          set the mode; omitted, report it
  :Hover toggle                          off if on, back to auto if off
  :Hover all [on|off|toggle]             every switch, every type and the
                                         mode at once; on means mode auto
                                         and page screenshots included

  :Hover links [on|off|toggle]           link syntax hovers at all
  :Hover links web [on|off|toggle]       http(s) links hover
  :Hover links web fetch [on|off|toggle] fetch for status code, title and
                                         the page's own text
  :Hover links web fetch pdf [on|off|toggle]
                                         a link that answers with a PDF,
                                         shown as its first page
  :Hover links web shot [on|off|toggle]  render the page in a headless
                                         browser and draw it into the float
  :Hover links web shot eager [on|off|toggle]
                                         let the trigger do that, not only
                                         an explicit request
  :Hover paths [on|off|toggle]           bare paths in prose hover
  :Hover paths missing [on|off|toggle]   a path resolving to nothing is
                                         marked broken
  :Hover paths code [on|off|toggle]      bare paths hover inside code, not
                                         just comments and strings
  :Hover positions [on|off|toggle]       a plugin may answer for a position
                                         that points at nothing
  :Hover images [on|off|toggle]          pictures are drawn, or described
  :Hover office [on|off|toggle]          office documents render via PDF
Implication runs UPWARD only. fetch turns on web, which turns on links.
Switching links off silences web links without clearing their flag, so
turning links back on restores what you had.

links off is about how a target was FOUND, not what it is. If the same text
is also a resolvable bare path, paths decides it.

paths code is about WHERE a bare path is looked for, not what it looks like.
Off (the default), a position Treesitter identifies as executable code is
skipped: a path lives in a comment or a string, never inside an expression,
and vim.api.foo or a / b are not textually different from a path -- only
positionally. The rule is inverted from "allow only comments and strings",
because markdown, gitcommit and rst have parsers too and a path in an ordinary
paragraph must keep hovering. Anything not positively identifiable as code is
allowed: no parser, no captures, an unfamiliar capture family, a query that
throws. :Hover show ignores this switch entirely.

Every change is announced, because "off" is otherwise invisible.

7. KEYS *hover-keys*

Most keys here are BORROWED, not owned: they exist for as long as one float is
on screen and are handed back the moment it closes, restoring whatever mapping
they displaced rather than deleting it. The float is not focusable, so it can
never hold a mapping of its own.
  q, <Esc>                    dismiss this hover until the cursor reaches
                              another target. Bound for EVERY hover.
  F                           full screen, and back. Bound for every hover
                              that has a target
  <M-PageDown>, <C-Down>      next screenful of lines, or next PDF page
  <M-PageUp>, <C-Up>          back
  +, -                        the picture, larger or smaller
  <M-ScrollWheelUp/Down>      any hover, larger or smaller -- but only
                              where the pointer is
  >, |, =                     zoom a detail in, out, or back to the whole
                              picture. Bound whenever the picture CAN be
                              zoomed
  h, j, k, l                  move the magnified view. Bound ONLY while a
                              hover is zoomed in
Both scroll pairs are bound, because a key that is not on the keyboard cannot
be pressed: laptop and 60% layouts often reach PageUp/PageDown only through an
Fn chord. Ctrl rather than Alt on the arrows: <M-Up>/<M-Down> is a widespread
"move this line" binding.

Scroll keys are bound ONLY when there is something to scroll. An image, or a
file that already fits, leaves them alone entirely.

                                                                 *hover-resize*
+ and - are bound ONLY for a hover with a picture in it -- an image, or a
PDF/office page, which is a PNG by the time it is on screen. In normal mode
both are motions, and displacing a motion for every text float costs more than
the feature is worth there. The wheel and the command carry no such price and
work for EVERY hover.

One key per direction, not two: + and - are on every keyboard, so the
argument that doubles the scroll pairs does not apply here.

The wheel obeys a different rule. <M-ScrollWheelUp> / <M-ScrollWheelDown>
resize only while the POINTER is over the float, its border ring included -- a
wheel acts on what it is aimed at, where + acts on the one float there is.
The ring counts as inside on purpose: the float sits one row below the cursor,
so its top border is on the cursor's own row, which is where the pointer
already is under trigger = { "mouse" }.

Alt rather than Ctrl: <C-ScrollWheel> is the terminal emulator's own zoom
nearly everywhere. The wheel also needs 'mouse' to include the mode -- with it
empty no wheel event reaches Neovim and the mapping is inert rather than
broken. :checkhealth hover reports that, because the two look the same from
the outside.

:Hover resize [bigger|smaller] does the same step with no key at all, and is
the keyboard way in for a text hover.

RESIZE, NOT ZOOM. One step multiplies the box the previewer is given --
max_width and max_lines -- by 1.25, and nothing else. For a picture that
is magnification: the same picture drawn into a larger cell area. For text it
is not, because the font size belongs to the terminal emulator: a bigger box
shows MORE lines rather than larger ones. One operation, two honest answers,
and only one of them is zoom. A real zoom -- a cropped detail that can be
moved around -- is a different feature and is built; see |hover-zoom|.

The ceiling is the terminal, and it is found rather than declared: a step that
changes nothing is stepped back off, which is why holding a key does not run
the level off somewhere it has to be pressed back from. A 210x55 terminal has
room for five steps at the default 80x20; an 80x24 terminal has room for none,
because 20 rows is already 'lines' minus four.

A PDF page is not re-rasterized, so making one bigger is free and
correspondingly unsharp.

                                                                 *hover-webpdf*
A LINK THAT ANSWERS WITH A PDF can be shown as its first page rather than as
its size. :Hover links web fetch pdf, and from there it is a PDF like any
other -- the same paging keys, the same sharp zoom, the same pipeline a local
one uses. Nothing is converted; the bytes already are a PDF.

It implies fetch, and that implication is a MECHANISM rather than a policy:
the content type is what identifies the link, and only a fetch produces one.
The server's content type decides, never the URL's extension -- a .pdf in a
path is the author's word, and a .pdf that 404s to an HTML error page is
common enough to matter.

IT IS A SECOND REQUEST, and the reason is worth knowing. lib.nvim.net.curl
runs vim.system(..., { text = true }), which replaces CRLF with LF in the
output -- invisible for HTML, fatal for a binary body. A PDF rewritten that way
is a file pdftoppm will not open, and the failure would look like a broken
renderer. So the document is downloaded again with curl -o, where no text
handling touches it.

That second request is also what makes the size cap answerable. A fetch is
capped at 2 MB, right for a page and far too small for a document -- and the
content type is not known until the first response returns, so ONE request
cannot carry both numbers. links.pdf.max_bytes (25 MB) is the second one, and
a document over it is refused with both numbers named rather than truncated:
half a PDF is not a smaller PDF, it is a file that will not open.

                                                                   *hover-shot*
A LINK CAN BE SHOWN AS A PICTURE OF THE PAGE instead of as text.
:Hover links web shot renders it in a headless Chromium at 1280x900 and
hands the PNG to the same pipeline a photograph goes through -- the canvas
sizing, the drawing, the > crop, the h/j/k/l panning.

IT IS A DIFFERENT CATEGORY FROM FETCHING, not a louder setting of it, which is
why it implies web and never fetch. A fetch is one curl GET with a 2 MB
cap and no JavaScript. A render EXECUTES the page: the site's own scripts run,
and every subresource it names is fetched from whatever host it is on. A
reader who turned fetching on to ask "is this link still alive" must not
thereby have turned on a browser.

A SECOND SWITCH FOR THE TRIGGER, and it is not belt and braces. shot says a link may be
rendered; shot eager says the TRIGGER may do it rather than only
:Hover show. Measured 2026-09-04: a browser start alone is 710-735 ms with
no network at all, and a real documentation page 3.9 s to 19.6 s -- the same
URL, on different runs. A page of fifty links, scrolled through, is fifty of
those. auto_hover.url cannot say this: the text preview and the screenshot
are the same target type.

The browser gets a THROWAWAY PROFILE. Without --user-data-dir pointing
somewhere disposable, a headless Chrome can open the real one -- the reader's
cookies would go to the hovered host, and whatever they are logged into would
be rendered into the picture. There is deliberately no --no-sandbox: the
page is untrusted by construction, and the sandbox is what stands between it
and the machine.

The default capture is 1280x900, the viewport, rather than a whole page. A
picture is letterboxed into the float, so what decides legibility is the fit
factor: on a 210x55 terminal a zen float is roughly 1850x970 px, where a
1280x900 capture fits at about 1.0 and 16 px text stays 16 px, while 1280x4000
is height-limited to 0.24 and the same text becomes 4 px. Raise height for a
whole page and read it with >, which crops.

:checkhealth hover names the browser it would run. It asks the previewer
rather than PATH, because on Windows the Chrome installer does not extend PATH
and a PATH-only answer is wrong on a machine with Chrome plainly installed.

                                                                    *hover-zen*
ZEN IS THE SAME OPERATION WITH A DESTINATION. F, or :Hover zen, replaces
the BASE of that box with the editor's own size -- 'columns' and 'lines' minus
four each, which is exactly the ceiling the float clamps against anyway -- and
builds the preview again against it. The resize level still multiplies on top,
so + in zen is refused and stepped back off like any other step at the edge,
and - shrinks from full screen without leaving zen. F again is what leaves
it.

It is NOT a larger window. Every previewer renders against max_lines and
max_width: they decide how many lines are read, at what DPI a page is
rasterized, how large a picture is drawn. A float that merely opened larger
would show the same twenty lines with a great deal of margin.

The float is centred rather than anchored at the cursor -- the one place in
this plugin where that is true, because a float filling the screen annotates
no particular line.

IT PINS BY DEFAULT, and that follows from a mechanism rather than from taste:
the float is not focusable and its dismissal hangs on CursorMoved, so every
key that is not borrowed takes it away. Correct for a twenty-line annotation,
absurd for one filling the screen, which would close on the first j. Leaving
zen releases only a pin zen itself took. zen = { pin = false } gives the
transient reading back.

z was the mnemonic and could not be taken: it is a PREFIX, so the borrow
would swallow zz, zt, zb and every fold command -- and unlike a
displaced key, a broken prefix does not announce itself. F is
find-character-backwards: on its own it waits for a second character and
completes nothing, so the borrow displaces no finished operation.

Declines for a position preview, and the key is not bound there: there is no
target to ask again, only the content one plugin produced for this place once.

                                                                   *hover-zoom*
ZOOM IS THE OTHER OPERATION, and the difference is the framing. resize
letterboxes the WHOLE picture into a bigger box. A zoom keeps the box and
narrows the view, so what is on screen is a SMALLER PART of the source,
larger. Only the second one is magnification.

Two sources, two mechanisms, one gesture. A PICTURE is cropped: the file the
target names already holds every pixel there will be. A PDF PAGE is
RE-RASTERIZED at a higher DPI, because what is on screen for a PDF is a
rendering in this plugin's cache rather than the file -- cropping that gives
you bigger, never sharper.

:Hover zoom [in|out|reset] is the route. The keys are > in,
| out and = back to the whole picture or page. A step writes a file and
costs about a quarter of a second, so it is a deliberate press rather than a
dial -- which is why the keys are not +/-: an operation this slow is not
worth displacing a motion already spoken for by resize_keys.

They were <M-z>, <M-Z> and <M-R> until 2026-09-03, on the argument that
an Alt chord displaces nothing -- worth exactly what the terminal's
willingness to send the chord is worth, which on the development machine is
nothing at all. Set zoom_keys back to the
chords wherever they do arrive.

> and = are OPERATORS: over a float they move no cursor and complete
nothing, so the borrow is free for as long as the float lasts. The key that
steps out is a MOTION, and that is the case for taking it -- unbound it jumps
the cursor to column one, the dismissal hangs on CursorMoved, and the press
takes the picture away. < was rejected (which-key normalizes it to <lt>
while the mapping stays <, and reports "Recursion detected"), and so was
-, which resize_keys.smaller already holds: resize is bound first, so it
would resize and never zoom. :checkhealth hover reports that overlap.

They are borrowed whenever the picture on screen CAN be zoomed, not only while
it already is.

Measured on Windows, 2026-09-02 -- a magick start alone is 71 ms, a
1920x1080 screenshot cropped and fitted 258 ms, a dense image of that size
502 ms, a 4K source ~900 ms. No format or compression setting brought it
under ~150 ms. It runs behind the same placeholder as a PDF page, which it
beats: one page cost 1150 ms the same day.

h, j, k, l move the view and are borrowed ONLY while the hover is
zoomed in. They are motions, like + and -, but with one difference that
settles it: what h would otherwise do is move the cursor, and the dismissal
hangs on CursorMoved -- so the unbound key takes the picture away. Nobody
presses h at a magnified picture meaning that. :Hover nav is the same move
without a key.

                                                            *hover-next-answer*
More than one plugin can answer for one place: on a dotted name, "what is
this module" and "who imports it" are both true, and only the first
registered one used to be seen. <M-n> steps to the next and wraps past the
last; :Hover next is the same step with no borrow at all.

Stepped rather than merged, because two answers in one float would mean two
titles for one border and two filetypes for one highlight -- and a picture
cannot be merged with text at all. There is no "2 of 3" counter on purpose:
knowing how many would answer means asking every contribution on every
hover, which is the cost 'on_request' exists to avoid.

A zoom step divides the visible rectangle by 1.5 and keeps its centre, so
going deeper keeps looking at the same place. A move step is a quarter of what
is visible. Stepping back to a view already seen is instant -- the crop is
cached for the session and swept at exit.

The ceiling is the SOURCE, not the terminal -- the opposite of the resize
ceiling, and it is answered rather than discovered so a refused step costs no
process at all. For a picture it is the picture's own pixels: zoom stops when
the rectangle would fall below 32 of them. For a page there is nothing to run
out of, since a vector page is sharp at any resolution, so the limit is chosen
instead: 2400 dpi, about eleven times the base and five steps.

A PAGE IS SHARPER, NOT MERELY LARGER, and the number that kept this to
pictures measured the wrong operation. Re-rendering a WHOLE page at a higher
dpi grows with the square of it -- 176 ms at 216 dpi, 2653 ms at 1094 on a
dense A4 text page, and 3.3 s was that measurement. But a zoom shows a window,
not a page: asking pdftoppm for only the window keeps the pixel count
constant, and the cost with it -- 118 to 140 ms at every one of those dpi
values, measured 2026-09-03. The same window re-rendered carries roughly four
times the edge detail of one cropped from the plain render and scaled up.
scripts/pdfzoom_probe.lua prints that comparison for any PDF.

Needs, for a picture, images.nvim carrying images.convert.crop and
ImageMagick on 'path'; for a page, pdfport.nvim new enough to rasterize a
window of one (pdfport.can_render_page_crop) and its pdftoppm. Without them
:Hover zoom says so instead of doing nothing.

The dismissal is a suppression, not a close: under CursorHold the event fires
again after any keystroke followed by 'updatetime' of quiet, so a key bound to
hide() would make the float vanish and bring it straight back. It ends by
itself at the next target the cursor resolves.

The cost is that q records no macro for as long as one float is up.

A configured key list REPLACES the default rather than extending it; an empty
list binds nothing.

No key is owned by default. keymaps.show is the one available, and it is the
one worth setting in mode = "manual".

8. BARE PATHS *hover-bare-paths*

A path in prose, a code comment or a :messages dump is a target too. Two
rules keep it from firing constantly:

It must look like a path -- a separator, an extension, or a ... truncation,
AND at least one alphanumeric character somewhere.

A missing path is reported only when it cannot have been anything else:

  a truncation                          ...nvim/init.lua
  a drive or UNC prefix                 C:\Users\x   \\server\share
  an extension on the LAST component    docs/gone.md   ./src/app.ts
Everything else stays silent -- including text that really is a path
(~/notes, /etc/hosts, lua/lib/nvim). That is a deliberate loss: this is
the only preview class whose value goes NEGATIVE when it is wrong. The cases
it exists to prevent, each of which used to open a confident "no such file"
float over ordinary prose:

  and/or   input/output   sortiert/        a separator is not evidence
  2026/09/01   TODO/FIXME/DONE             nor is a component count
  github.com/user/repo                     nor an extension mid-path
  ./components/Button                      every extension-less JS import
None of this touches a target that EXISTS: and/or hovers normally the moment
something of that name is on disk. The rules only decide whether ABSENCE is
worth asserting.

9. CONFIGURATION *hover-config*

                                                                *hover.setup()*
  require("hover").setup({
    mode = "auto",                  -- "auto" | "manual" | "off"
    -- Write mode, auto_hover and every switch back over this configuration
    -- on the next enable() -- so a runtime change (`:Hover dashboard`, `:Hover
    -- links web on`, ...) outlives the session it was made in. On by
    -- default; `false` for a session-only override instead. `border`, key
    -- tables and every layout option are never carried -- only what
    -- `:Hover dashboard` itself reports.
    persist = true,
    -- Which target types the automatic trigger opens a float for. A list of
    -- type names, `true` for every type, or `false` for none. Gates the
    -- trigger only: `:Hover show` answers for every type regardless.
    auto_hover = { "image", "pdf" },
    trigger = { "CursorHold" },     -- or { "cursor" }, or { "mouse" }
    delay_ms = 250,
    placeholder_grace_ms = 250,
    max_lines = 20,
    max_width = 80,
    border = "rounded",
    inline_images = true,
    filetypes = "*",
    -- `shot` renders the page in a headless browser and draws it into the
    -- float. It implies `web` and never `fetch`: a fetch is one curl GET, a
    -- render EXECUTES the page. `eager` is the second half -- whether the
    -- trigger may do it, or only `:Hover show`. `height` is the viewport and
    -- 900 rather than a whole page, because a picture is letterboxed into
    -- the float and a 4000-pixel capture fits at 0.24.
    links = {
      enabled = true, web = false, fetch = false, timeout_ms = 2000,
      -- A link that answers with a PDF, shown as its first page. Implies
      -- `fetch` as a mechanism: the content type is what identifies it.
      pdf = {
        enabled = false, max_bytes = 25000000,
        timeout_ms = 30000, cache_days = 7,
      },
      shot = {
        enabled = false, eager = false, timeout_ms = 20000,
        width = 1280, height = 900, cache_days = 7, delay_ms = 1000,
        -- Unset means "find one": the usual names on PATH, then the usual
        -- install locations. Not a convenience -- on Windows the Chrome
        -- installer does not extend PATH at all.
        command = nil,
      },
    },
    -- `scope` teaches the code/prose gate a capture family for one grammar;
    -- empty is almost always right, since the gate falls open on anything it
    -- does not recognise.
    paths = {
      enabled = true, missing = true, code = false,
      scope = { prose = {}, code = {} },
    },
    positions = true,
    -- `cache_days` is how long a converted PDF may sit in the cache before
    -- the next session sweeps it; 0 keeps nothing between sessions.
    office = { convert = false, timeout_ms = 60000, cache_days = 7 },
    -- `at` and `step` take seconds, a percentage of the running time, or
    -- an ffmpeg timestamp. Percentages mean ten presses of the paging key
    -- walk any file end to end, whatever its length.
    video = { at = "10%", step = "10%", width = nil, sound = true,
              play_at = 0, play_scale = 2.5, fps = 12, run = 24,
              run_width = nil },
    -- `pin` is on because the float is not focusable and its dismissal hangs
    -- on CursorMoved: unpinned, a full-screen hover closes on the first `j`.
    zen = { pin = true },
    -- Full screen and back, for every hover with a target. `F` waits for a
    -- second character and completes nothing, so the borrow costs nothing.
    zen_keys = { toggle = { "F" } },
    -- Step to the next plugin answering for this place. Borrowed only for a
    -- position hover with more than one contribution registered.
    position_keys = { next = { "<M-n>" } },
    scroll_keys = {
      down = { "<M-PageDown>", "<C-Down>" },
      up = { "<M-PageUp>", "<C-Up>" },
    },
    nav_keys = { left = { "h" }, right = { "l" }, up = { "k" }, down = { "j" } },
    -- Plain characters since 2026-09-03: an Alt chord displaces nothing,
    -- which is worth nothing in a terminal that never sends it. Bound
    -- whenever the picture on screen can be zoomed at all.
    zoom_keys = { into = { ">" }, out = { "|" }, reset = { "=" } },
    resize_keys = {
      larger = { "+" },
      smaller = { "-" },
      -- Only while the pointer is over the float; needs 'mouse'.
      wheel_larger = { "<M-ScrollWheelUp>" },
      wheel_smaller = { "<M-ScrollWheelDown>" },
    },
    dismiss_keys = { "q", "<Esc>" },
    open_keys = { "gf" },
    keymaps = { show = false },
  })
trigger: "CursorHold" follows 'updatetime' and adds delay_ms on top.
"cursor" is CursorMoved plus this plugin's own debounce, so delay_ms is
absolute and nothing fires while the cursor stands still. "mouse" also needs
:set mousemoveevent, which is never set for you.

The pre-1.0 spelling of three options is still accepted and normalized:
enabled = false reads as mode = "off", bare_paths as paths.enabled,
and url = { hover, fetch, timeout_ms } as the links fields.

contribute is the one field that is not a setting: your own sources,
previews and position previews, registered under the name "user". It is
handed to the registry and never stored in the options. See
|hover-contribute|.

10. API *hover-api*

                                                                 *hover.show()*
hover.show({opts})                        Show the hover for whatever is under
                                          the cursor. opts.force ignores
                                          every volume switch. Returns whether
                                          a float was opened.
                                                                 *hover.hide()*
hover.hide()                              Close any open hover.
                                                              *hover.dismiss()*
hover.dismiss()                           Close it and keep it closed until
                                          the cursor reaches another target.
                                          Returns false when none was open.
                                               *hover.scroll()*
hover.scroll({delta})                     Scroll the open hover. Positive is
                                          forward. Returns false when there is
                                          nothing to scroll.
                                                               *hover.resize()*
hover.resize({delta})                     Make the open hover bigger or
                                          smaller by {delta} steps of 1.25.
                                          Positive is bigger. A picture is
                                          drawn larger; a text preview shows
                                          more lines. Returns false when
                                          there is no hover to resize; true
                                          says the re-render started, not
                                          that the float grew -- the terminal
                                          may have no room left.
                                          See |hover-resize|.
                                                                  *hover.zen()*
hover.zen({on})                           The same box, set to the editor's
                                          own size, and the float centred;
                                          omitted, it toggles. Not a larger
                                          window: the previewer builds its
                                          answer again against the screen, so
                                          a text hover shows more lines rather
                                          than the same twenty with margin.
                                          Pins by default -- zen.pin = false
                                          turns that off. Returns whether it
                                          asked, plus the reason where a
                                          refusal is worth naming.
                                          See |hover-zen|.
                                                               *hover.zenned()*
hover.zenned()                            Whether the hover on screen is full
                                          screen.
                                                                 *hover.zoom()*
hover.zoom({delta})                       Magnify a detail of the picture on
                                          screen by {delta} steps of 1.5, or
                                          step back out. Returns false when
                                          there is no picture to zoom, or no
                                          detail left. See |hover-zoom|.
                                                                  *hover.nav()*
hover.nav({dx}, {dy})                     Move the magnified view. {dx} is -1
                                          left, 1 right; {dy} is -1 up, 1
                                          down. Returns false when nothing is
                                          zoomed.
                                                                  *hover.pin()*
hover.pin({on})                           Keep this float on screen while the
                                          cursor goes elsewhere; omitted, it
                                          toggles. While pinned the trigger
                                          opens nothing. Returns the state.
                                                             *hover.set_mode()*
hover.set_mode({mode})                    "auto" | "manual" | "off". Returns
                                          the mode, or nil plus a message.
                                                                 *hover.mode()*
hover.mode()                              The mode in effect right now.
                                                               *hover.toggle()*
hover.toggle({on})                        Off, or back to "auto".
                                                                  *hover.set()*
hover.set({name}, {on})                   Turn one switch on, off, or over.
                                          Names: links, web, fetch, pdf,
                                          shot, eager, paths, missing, code,
                                          positions, images, office --
                                          hover.switches.names() is the list.
                                          Returns the state, or nil plus a
                                          message.
                                                              *hover.enabled()*
hover.enabled({name})                     Whether one switch is in effect,
                                          implications included.
                                                               *hover.status()*
hover.status()                            { mode, switches, auto } -- for a
                                          statusline or a report. Each switch
                                          carries enabled (the implication
                                          chain folded in), flag (its own
                                          value) and route (the words to
                                          type at it).
                                                   *hover.target_under_cursor()*
hover.target_under_cursor({bufnr}, {opts})
                                          The target under the cursor, or nil.
                                          opts.force opens every volume gate.

PLAYING A VIDEO *hover-video-play*

A video hover opens as a still, because a hover appears when the cursor rested
somewhere -- a glance, not a request for motion.

    <CR>      open a real mpv window (video.playback = "window", the
              default) -- press again, or close the hover, to stop it
    .         one frame forward (pausing first) -- "inline" playback only
    ,         one frame back -- "inline" playback only

video.playback = "window" (default) sends <CR> to a real mpv window
instead of painting into the float: video and sound, decoded and drawn by mpv
with no editor redraw in the loop. The float shows a short "playing" panel,
not a picture. It also asks which monitor the terminal is on right now and
tells mpv to centre there, not always on screen 0.

video.use_mpv = false is separate from playback = "inline": "I have mpv,
do not use it." <CR> skips straight to the tier below -- real video and
sound, just not through mpv -- and silences inline's optional sound too,
where playback = "inline" would give up that fallback's real player
entirely for silent block graphics.

Without mpv (or with use_mpv = false), "window" still beats a muted run of
block graphics: <CR> hands the file to media.play() instead -- a
configured player, or whatever this machine already opens a video with, the
same call gf makes. Nothing extra to install, at the cost of nothing here
being able to stop it again: closing the hover only drops the float back to
the still, and the player itself keeps running until its own window is
closed by hand.

video.experimental.system_player_align (default false, Windows/macOS/
Linux) is a best-effort attempt to centre whatever window that system player
opens, on the same detected monitor the mpv tier targets. It can genuinely do
nothing -- a UWP default handler on Windows, a terminal without Accessibility
permission on macOS, Wayland on Linux, or a fullscreen window (reported
against VLC), which none of the above ever reaches -- and never reports when
it does not.

video.experimental.system_player_prefer_classic (default true, only
consulted when system_player_align is true) is the fix for that last case: try a known,
scriptable player by name first (vlc --no-fullscreen, today), and only fall
to the system's own handler when none of them is on PATH.

PATH alone can miss a player that is plainly installed -- a Windows
installer routinely does not extend it. `video.experimental.
system_player_search_installs` (default true, same rule) is the fix: a name
that misses on PATH is tried again against its known Windows install
locations before this falls back further.

Set video.playback = "inline" for the block-graphics transport described
below instead of either of the above, which is genuinely smooth on a terminal
fast enough for it and needs no separate window; each tier falls back
further where its own ingredient is missing (mpv, then a working
media.play(), then ImageMagick, then the still).

The rest of this section is the "inline" route.

What moves is text: every cell is a coloured block, which collides with no
terminal graphics protocol and survives every redraw. A real picture cannot be
animated from inside Neovim -- docs/FEATURES/VIDEO.md carries the three
measurements behind that.

Sharpness is two settings. video.play_scale decides how many cells the
picture gets; images.nvim's display.ascii_fallback.cells decides how finely
each cell is divided -- 2x3 sub-pixels by default (sextants), against the 1x2
of a half block. :checkhealth images prints a row of each geometry.

Sound joins by itself when the file has an audio track and mpv is on PATH. The
picture then follows mpv's clock rather than counting its own frames, so it
cannot drift -- but it does not wait for it either: the frame to draw comes
from a local clock that mpv corrects four times a second. Asking once per frame
made the IPC round trip a ceiling on the frame rate (300 ms of latency gave
3 fps), which is fixed. Without mpv, or with video.sound = false, the run plays exactly as it
does otherwise, silently.

Playback decodes two seconds at a time and asks for the next window a second
before it needs it, so a film plays on instead of stopping at the end of the
first one. A step is a position in the file rather than an index into that
window: stepping past its edge fetches the window holding the target, and a
held key coalesces into one decode at a time.

Playing starts at video.play_at (default 0, the beginning of the file), not
at video.at -- that is where the *still* is taken, ten percent in so a
thumbnail is not a fade-in. A still the reader has scrubbed keeps its position.

The bar under the picture measures the film, matching the clock beside it.

video.play_scale (default 2.5) is the sharpness knob: a cell carries two
pixel rows, so the still's budget is a 38-pixel-tall picture. The playing
canvas is that budget scaled, capped to the editor's own rows and columns.

The keys are configurable through transport_keys.

11. REGISTRY *hover-registry*

  require("hover.registry").register("your.nvim", {
    sources = {
      function(bufnr, row, col)
        return find_target(bufnr, row, col)  -- , { kind = "yours" }
      end,
    },
    previews = {
      anchor = function(target, opts, bufnr)
        return section_of(target, bufnr)
      end,
    },
  })
Sources are tried in registration order, before the built-in bare-path source.
Re-registering under the same name REPLACES that plugin's contribution, so a
setup() running twice does not leave two copies firing on every hover. A
source that throws is skipped and the next one still runs.

Target types a preview can claim: image, pdf, office, video, markdown,
file, directory, url, anchor, missing, git. Returning nil declines, and the
built-in preview runs instead.

                                                             *hover-contribute*
The same table can be handed to |hover.setup()| as contribute, which
registers it under the name "user". That is the door for a hover of your own,
with no plugin around it:
  require("hover").enable({
    contribute = {
      positions = {
        function(bufnr, row, col)
          return something_about(bufnr, row)  -- or nil to stay silent
        end,
      },
    },
  })
A sources or positions entry may be a table rather than a function --
{ fn = ..., on_request = true } -- and is then asked only for an explicit
:Hover show. That is for an answer costing a process start.

A plugin should call register() under its own name instead of using this
field: everything registered through contribute shares the single name
"user", so two callers would silently delete each other.

12. HEALTH *hover-health*

  :checkhealth hover
Reports the hard dependency, one check per soft dependency (asking for the
entry point actually called, not just whether the module loads), every switch
state, and the two configurations that silently do nothing: manual mode with
no key bound, and every preview class switched off.

It also reads |hover-registry| back: every name that registered, with a count
per kind.
  registry: markdown.nvim -- 2 sources, 2 previews
  registry: user -- 1 position preview (1 asked only on `:Hover show`)
That is the answer to "is my own hover registered at all?" after handing a
function to |hover-contribute| -- everything from that field appears under the
single name "user", and its absence from the list is the answer. The count in
parentheses is how many entries said on_request and are therefore correct,
registered and silent until an explicit :Hover show.