Emacs-style buffer narrowing for Neovim. Hide everything outside a chosen line range OR character region — including down to a visual selection. Three implementation modes (conceal / scratch / fold), per- call options for window placement and sync policy, opt-in motion confinement that won't fight your other plugins.
" Visual-select a paragraph, then:
:'<,'>Narrow
" Line range:
:5,42 Narrow
" Subcommands:
:Narrow last " re-narrow last region
:Narrow fold " narrow to fold at cursor
:Narrow window " narrow to visible viewport
:Narrow ts " narrow to innermost tree-sitter node
" Bang variant: forces scratch mode in the current window:
:Narrow!
" Anywhere outside narrowed view:
:WidenThe buffer's content is never modified — saving the file writes the full file regardless of which mode is active. No half-state risk.
| Mode | What it does | Cost | Picked when |
|---|---|---|---|
| conceal | Visual hide via conceal_lines extmarks (Neovim 0.11+) or per-byte conceal (0.10). Same buffer, same window. |
Lowest | conceallevel ≥ 1 (auto) |
| scratch | Clones the slice into a separate buffer with bidirectional sync. Window placement (current/split/vsplit/float/tab) configurable. Source can be locked read-only for the duration. | Highest | conceallevel == 0 (auto), or explicit mode = "scratch" |
| fold | Fold-based hide with the +-- N lines folded -- placeholder. |
Cheapest | Explicit mode = "fold" |
mode = "auto" (default) picks conceal vs scratch based on the
window's conceallevel, so users who run set conceallevel=0 get
working narrow without their preference being overridden.
Switch on the fly without losing the region:
require("narrow").set_mode(0, "scratch", { window = { type = "float" } })local narrow = require("narrow")
-- Core
narrow.to_range(bufnr, start_line, end_line, opts)
narrow.to_region(bufnr, sr, sc, er, ec, opts) -- 0-based; ec exclusive
narrow.to_visual(bufnr, opts) -- from '< / '> marks
narrow.to_node(bufnr, ts_node, opts) -- tree-sitter node
narrow.widen(bufnr)
narrow.toggle(bufnr, lo, hi, opts)
-- Helpers (Emacs's narrow-to-defun family)
narrow.last(bufnr, opts) -- re-narrow last region
narrow.fold(bufnr, opts) -- fold at cursor
narrow.window(bufnr, opts) -- visible viewport
-- Sync controls (scratch mode)
narrow.break_sync(bufnr) -- pause source ↔ scratch
narrow.resume_sync(bufnr)
narrow.sync_now(bufnr, direction) -- one-shot flush
-- Mode switching
narrow.set_mode(bufnr, new_mode, opts) -- conceal/scratch/fold
-- Introspection
narrow.is_narrowed(bufnr) -- boolean
narrow.region(bufnr) -- sr, sc, er, ec | nil
narrow.status(bufnr) -- "Narrowed: L5-L42"
narrow.list() -- all instances
narrow.list_for(bufnr) -- this buffer's instances
-- Setup (optional — defaults work without it)
narrow.setup(opts)bufnr = 0 means the current buffer. Every entry function returns
nil on success or an error string on failure.
Defaults work without setup. Override what you need:
require("narrow").setup({
mode = "auto", -- "auto"|"conceal"|"scratch"|"fold"
respect_conceallevel = true, -- "auto" → scratch when conceallevel = 0
-- Source-side behavior during scratch mode.
protect_source = true, -- source `nomodifiable` while open
highlight_region = true,
highlight_group = "Visual",
-- Scratch-mode window placement.
scratch_window = {
type = "current", -- "current"|"split"|"vsplit"|"float"|"tab"
position = "center", -- float: "center"|"left"|"right"|"top"|"bottom"
width = 0.95,
height = 0.9,
},
-- Scratch-mode sync policy.
-- "live" bidirectional sync on every edit (default)
-- "manual" hooks installed but no-op until sync_now()
-- "none" no sync (frozen snapshot — read-only review)
sync = "live",
-- Outline-motion overrides (gg/G/H/M/L/[[/]]) that LAND inside
-- the active region instead of at file boundaries. OFF by default
-- because — even with maparg/mapset snapshot/restore — there are
-- edge cases where a plugin's mappings can't be perfectly round-
-- trip restored. Search keys (/?nN) are NEVER touched regardless.
motion_confinement = false,
})Per-call opts override config:
narrow.to_range(0, 5, 42, {
mode = "scratch",
window = { type = "float" },
sync = "manual", -- start paused
protect_source = false,
highlight_region = false,
motion_confinement = true, -- enable for this call only
})Two mechanisms confine the cursor to the active region:
cursor-clamp (always on) — a buffer-scoped CursorMoved autocmd
snaps the cursor back when it lands outside. Key-agnostic: works
regardless of which key triggered the motion. If you remapped / to
a, use flash.nvim chord keys, or run a tree-sitter textobject —
the clamp catches the landing and corrects. Skipped in insert / visual
modes so it never interrupts typing or selection.
motion-confinement (opt-in) — buffer-local mappings for outline
motions (gg, G, H, M, L, [[, ]]) that land at narrow
boundaries instead of file boundaries. Each pre-existing user mapping
is snapshotted via maparg() before override and bit-for-bit restored
on widen() via mapset(). Search keys (/, ?, n, N, *,
#) are NEVER touched — those are flash / leap / hop / sneak
territory.
- conceal / fold modes: ONE region per source buffer. A second
to_regionREPLACES the existing region. - scratch mode: every
to_regionopens a fresh scratch buffer. Multiple scratches can exist for the same source — each in its own window — and they share live sync with the source.narrow.list()/narrow.list_for(bufnr)enumerate them.
narrow.nvim only owns the region mechanism. Structural plugins
(org-mode subtrees, markdown sections, function bodies, etc.)
compute the line/character range using their language's parse tree,
then delegate:
local function narrow_to_subtree()
local bufnr = vim.api.nvim_get_current_buf()
local hl = require("organ.structure")._find_containing_headline(
bufnr, vim.fn.line("."))
if not hl then return end
local end_line = require("organ.structure")._subtree_end(bufnr, hl)
require("narrow").to_range(bufnr, hl.line, end_line, {
mode = "scratch",
window = { type = "float" },
})
endThe library never installs filetype-specific helpers; that's the caller's domain.
- Buffer-local state via
vim.b[bufnr]— no global tables. - Namespaced extmarks; only our own marks are touched on cleanup.
- Buffer-scoped autocmds (
buffer = bufnr) — never global pattern autocmds that fire on other plugins' buffers. - No keymaps installed by default. Map to
:Narrow/:Widenor to the Lua API directly. - Motion confinement OFF by default. When enabled, snapshots every
pre-existing user mapping via
maparg()and restores viamapset()on widen. conceallevel/concealcursorsaved on narrow; restored on widen. OptionSet guard re-asserts our values if changed during the narrow.modifiableon the source buffer saved + restored whenprotect_source = true.
- Neovim 0.10+.
conceal_linesextmark support (full-line hide including newline) lands in 0.11; on 0.10 we fall back to per- byteconcealmarks (chars vanish, line slot remains as an empty row). Other modes work identically across versions.