Skip to content

sakakibara/narrow.nvim

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

5 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

narrow.nvim

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:
:Widen

The buffer's content is never modified — saving the file writes the full file regardless of which mode is active. No half-state risk.

Modes

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" } })

API

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.

Configuration

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
})

Confinement model

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.

Multi-instance per buffer

  • conceal / fold modes: ONE region per source buffer. A second to_region REPLACES the existing region.
  • scratch mode: every to_region opens 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.

Composing with structural plugins

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" },
  })
end

The library never installs filetype-specific helpers; that's the caller's domain.

Collision safety

  • 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 / :Widen or to the Lua API directly.
  • Motion confinement OFF by default. When enabled, snapshots every pre-existing user mapping via maparg() and restores via mapset() on widen.
  • conceallevel / concealcursor saved on narrow; restored on widen. OptionSet guard re-asserts our values if changed during the narrow.
  • modifiable on the source buffer saved + restored when protect_source = true.

Requirements

  • Neovim 0.10+. conceal_lines extmark support (full-line hide including newline) lands in 0.11; on 0.10 we fall back to per- byte conceal marks (chars vanish, line slot remains as an empty row). Other modes work identically across versions.

About

Emacs-style buffer narrowing for Neovim — conceal / scratch / fold modes

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors