Keep the eye where the cursor is. Lines near the cursor stay legible; everything else fades out or dissolves into noise, so the section you are editing is the only thing that reads as code.
Terminals cannot blur text, so the unfocus effect is built from the two things a terminal can do: recolour a line, and paint different glyphs over it.
| Mode | Effect |
|---|---|
dim |
Foreground ramps toward the background with distance. No virtual text at all, so it is the cheapest mode. |
scramble |
Glyphs become noise. Layout is not preserved. |
shape |
Indentation, punctuation and word lengths survive; letters become noise. Still reads as code from the corner of the eye. |
blocks |
Glyphs become block characters (░▒▓█) that solidify with distance, so text dissolves into bars as it recedes. |
dimshape |
Default. Dim while close to the cursor, scramble once far from it. |
Scramble modes paint overlay virtual text. The buffer is never modified, so undo history, LSP positions and git status are all untouched. Lines inside the focus region are always left exactly as written, whatever the mode.
| Region | Behaviour |
|---|---|
fixed |
Everything within radius is lit; everything beyond sits at floor. |
core |
Default. core lines fully lit, then a falloff out to radius. |
block |
The enclosing syntax block via treesitter, with falloff beyond it. Small helpers get a tight spotlight; long functions stay fully lit. Degrades to core when no parser is available. |
{
"RedkillTech/focus.nvim",
event = "VeryLazy",
opts = {
mode = "dimshape",
region = "core",
radius = 20,
core = 6,
},
keys = {
{ "<leader>zf", "<cmd>FocusToggle<cr>", desc = "Focus: toggle" },
{ "<leader>zm", "<cmd>FocusMode<cr>", desc = "Focus: cycle mode" },
},
}To run it from a local checkout instead, swap the repo string for dir:
{
dir = vim.fn.expand "~/path/to/focus.nvim",
name = "focus.nvim",
event = "VeryLazy",
opts = { mode = "dimshape" },
}opts is passed straight to require("focus").setup(). A ready-made spec lives
in lazy.lua.
Defaults in full:
require("focus").setup({
mode = "dimshape", -- "dim" | "scramble" | "shape" | "blocks" | "dimshape"
region = "core", -- "fixed" | "core" | "block"
radius = 20, -- lines from the cursor to the outer edge
core = 6, -- lines that stay fully lit
curve = "ease", -- "linear" | "ease" | "steps"
floor = 0.18, -- dimmest level, 0.0 .. 1.0
bands = 20, -- precomputed highlight groups
scramble_threshold = 0.45, -- dimshape: falloff fraction before scrambling
keep_structure = true, -- keep indent and brackets lit while scrambling
noise_chars = "ABC...", -- character pool for scramble modes
block_chars = { "░", "▒", "▓", "█" }, -- glyphs for blocks, lightest first
base_hl = "Normal", -- highlight group the ramp is derived from
debounce_ms = 0, -- coalesce cursor bursts; 0 = same-tick defer
disable_in_insert = true,
disable_in_visual = false,
max_lines = 20000, -- skip buffers larger than this
filetype_denylist = { "help", "qf", "terminal", ... },
buftype_denylist = { "terminal", "quickfix", "prompt", "nofile", "help" },
auto_enable = true, -- start on load
})floor— how dark unfocused text gets. Raise it if the edges are too aggressive.scramble_threshold— fordimshape, how far out scrambling begins.curve—stepsquantizes to four bands and is the cheapest ramp;easeholds bright near the cursor and drops late.keep_structure— turn off for heavier obfuscation, at the cost of the code no longer reading as code peripherally.
| Command | Action |
|---|---|
:FocusToggle |
Toggle focus mode |
:FocusEnable / :FocusDisable |
Explicit on/off |
:FocusMode [mode] |
Set the mode, or cycle with no argument |
:FocusRadius {n} |
Set the radius |
:FocusMode completes the mode names.
local focus = require("focus")
focus.enable()
focus.disable()
focus.toggle()
focus.set_mode("blocks")
focus.cycle_mode()
focus.set({ radius = 12, curve = "steps" }) -- any config key, applied livefocus.set() takes effect immediately, so it is convenient for tuning:
:lua require("focus").set({ floor = 0.3, scramble_threshold = 0.6 })Measured on a 10,000-line buffer:
| Path | Cost |
|---|---|
dim / scramble / shape |
~0.15 ms per render |
dimshape |
~0.45 ms per render |
region = "block" (treesitter) |
~0.62 ms per render |
| Unchanged cursor (cache hit) | ~0.003 ms |
At 60fps the frame budget is 16.7 ms, so a render costs a few percent of it.
How it stays cheap:
- Only the visible viewport is drawn, so cost scales with window height rather than file size.
- Highlight groups for the dim ramp are built once at setup and rebuilt only on
ColorScheme; rendering allocates no highlights. - A per-window render signature skips the entire pass when nothing observable
changed, so holding
jdoes not repaint identical frames. - Scramble noise is hashed from
(line, column)rather than sampled randomly. Random noise would resample on every redraw and make unfocused text shimmer. - Each window has its own namespace, so one buffer split twice focuses each window's own cursor independently.
- Distances are measured in screen lines when
wrapor folds are active, so a long wrapped line does not consume the entire radius. - Focus is suppressed in insert mode by default, and always in operator-pending and command-line modes.
- Scramble output preserves display width exactly, including tabs and multibyte characters, so the overlay lines up with the real text.
- The noise is stable rather than random. That makes it obfuscation for peripheral vision, not concealment — a fixed pattern is learnable if you stare at it.
The effects were prototyped in mockup/index.html, a
standalone page that fakes a terminal and lets every option be toggled live. It
also contains two effects that were not shipped: full-noise scrambling without
shape preservation, and a collapse mode that hides distant lines outright.
- Neovim 0.10+ (uses
vim.uv,nvim_set_hl, extmark overlays) - treesitter parser for the buffer's language, only for
region = "block"
