doc/hover.txt — rendered from the plugin's own vimdoc
*hover.txt* Preview whatever the cursor is resting on *hover.nvim*
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
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
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.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.:Hoveritself is registered from plugin/hover.lua regardless, so:Hover mode autois reachable even from a session where nothing ranenable().
4. WHAT IS 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 pluspositionfor 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 autolists them. IT GATES THE TRIGGER, NOT THE PLUGIN.:Hover showanswers 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
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 = trueforces "off" and outranks anything a plugin configured. It is where a user says "not on this machine" from a plugin spec'sinit, before anything loads. |hover.set_mode()| keeps it in step, so the two cannot disagree. An explicit request does not override it:forceopens the volume switches, never the mode. "Silent unless asked" ismode = "manual".
6. 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.fetchturns onweb, which turns onlinks. Switchinglinksoff silences web links without clearing their flag, so turninglinksback on restores what you had.links offis about how a target was FOUND, not what it is. If the same text is also a resolvable bare path,pathsdecides it.paths codeis 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, andvim.api.fooora / bare 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 showignores this switch entirely. Every change is announced, because "off" is otherwise invisible.
7. 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
A path in prose, a code comment or a:messagesdump 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.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 addsdelay_mson top. "cursor" is CursorMoved plus this plugin's own debounce, sodelay_msis 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 = falsereads asmode = "off",bare_pathsaspaths.enabled, andurl = { hover, fetch, timeout_ms }as thelinksfields.contributeis 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.show()* hover.show({opts}) Show the hover for whatever is under the cursor.opts.forceignores 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 = falseturns 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 carriesenabled(the implication chain folded in),flag(its own value) androute(the words to type at it). *hover.target_under_cursor()* hover.target_under_cursor({bufnr}, {opts}) The target under the cursor, or nil.opts.forceopens every volume gate.
PLAYING A VIDEO
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
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,
},
},
})
Asourcesorpositionsentry 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 throughcontributeshares the single name "user", so two callers would silently delete each other.
12. 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 saidon_requestand are therefore correct, registered and silent until an explicit:Hover show.