Braille wireframe viewer for Neovim — render .wrfm 3D models as Unicode braille art.
wrfm.nvim_cut.mp4
We provide:
- Zero-dependency braille wireframe renderer
- Floating window and inline preview modes
- Live hot-reload and auto-spin animation
For creating and editing .wrfm models — the flip side of viewing
— see wrfm-skill, an agent skill
that teaches coding agents to build, fix, and review wireframes image-first.
- Neovim >= 0.11 (uses modern APIs)
- Terminal with Unicode braille support — virtually all modern terminals (iTerm2, Alacritty, Kitty, WezTerm, Ghostty, foot, Windows Terminal, GNOME Terminal, Konsole, ...). Braille is plain Unicode text, so it renders over ssh, tmux, and tty — no image protocols, no passthrough, no special configuration.
lazy.nvim
{
"lenitain/wrfm.nvim",
opts = {},
}Other package managers
pckr.nvim
use { "lenitain/wrfm.nvim", config = function() require("wrfm").setup({}) end }mini.deps
add({ source = "lenitain/wrfm.nvim" })
later(function() require("wrfm").setup({}) end)Manual: copy lua/, plugin/, doc/, and ftdetect/ to your Neovim
runtimepath, then run :helptags ALL.
setup() is optional; every option has a default.
require("wrfm").setup({
-- Canvas size (braille character cells)
-- nil = auto-calculate from window dimensions
default_width = nil,
default_height = nil,
-- Size caps
max_width = nil, -- hard cap in columns (nil = unlimited)
max_height = nil, -- hard cap in rows (nil = unlimited)
max_width_window_percentage = 80, -- % cap relative to host window
max_height_window_percentage = 60, -- % cap relative to host window
-- Camera
default_pitch = 30, -- initial pitch in degrees (0 = front, 90 = top)
default_distance = nil, -- camera distance (nil = auto-fit model)
-- Animation
default_auto_spin = true, -- start spinning after render()
default_spin_speed = 0.02, -- radians per frame
fps = 30, -- animation frame rate
pause_spin_when_unfocused = true, -- pause repaints when host window unfocused
-- Hot reload
default_watch = true, -- auto-update when .wrfm file changes
-- Appearance
highlight = "Yellow", -- wireframe color: hex "#RRGGBB" or a theme
-- highlight group to link (e.g. "Function")
-- Inline preview
integrations = {
wrfm = {
enabled = false, -- opt-in auto-attach (see below)
at = nil, -- REQUIRED to paint: "cursor" or
-- { line = <int>, col = <int> }
mode = "overlay", -- "overlay" (extmark) or "popup"
clear_in_insert_mode = false, -- hide during insert mode
filetypes = { "wrfm" }, -- host filetypes to auto-attach to
},
},
})Unknown keys raise an error, so typos surface immediately. The percentage
and absolute caps clamp derived and explicit sizes; per-model overrides
(max_width_window_percentage, ...) and ignore_max_size are available on
from_file().
Change the wireframe color
Set highlight in setup() to either a hex color or a theme group:
require("wrfm").setup({ highlight = "#ff8800" }) -- fixed orange
require("wrfm").setup({ highlight = "Function" }) -- follow the colorscheme- A hex value (
#RRGGBB) paints the art in exactly that color and always wins, even across colorscheme switches. - A group name links the wireframes to that theme group, so the color follows
your colorscheme and updates when you switch themes. Like the default
(
"Yellow"), an unset link is a fallback: a colorscheme that definesWrfmPreviewitself still takes precedence. - Malformed values (e.g.
"#12") raise onsetup(), like unknown keys.
Every live view recolors instantly — highlight groups resolve by name at draw time, so no re-render is needed. To change the color at runtime:
require("wrfm").set_highlight("#00ff88")Enable / disable / get plugin status
you can enable/disable the plugin and check its status on demand.
require("wrfm").enable() -- re-render everything registered
require("wrfm").disable() -- hide views; registry intact; render() becomes no-op
print(require("wrfm").is_enabled()) -- boolWhile disabled, from_file() still works (construction is legal); only
drawing is suppressed, and enable() rebuilds every registered view.
Load a model with options
from_file() registers a model and assigns an id ("model-N" unless
options.id is given); a live options.id is reused, so repeating the call
returns the same model.
local model = require("wrfm").from_file("anvil.wrfm", {
window = winid, -- anchor the float to a specific window
buffer = bufnr, -- draw into this buffer instead of a float
width = 40, height = 20, -- canvas size
x = 0, y = 0, -- offset from the centered float placement
distance = nil, -- pin camera distance (nil = auto-fit)
pitch = 30, yaw = 0, -- degrees
auto_spin = true,
spin_speed = 0.02,
watch = true, -- hot-reload this file
border = true, -- false = frameless seamless overlay (image.nvim look)
fov = 60, -- field of view (independent of distance)
overflow = "clip", -- inline: "clip" or "visible"
z_order = "model", -- inline compositing: "model" or "text"
namespace = "panel", -- registry tag for get_models() filtering
id = "anvil-preview", -- stable registry identity
})Attach inline preview to a buffer
-- `at` is required: no position, no preview
local model = require("wrfm").attach(bufnr, { path = "model.wrfm", at = { line = 0, col = 0 } })
local follows = require("wrfm").attach(bufnr, { path = "model.wrfm", at = "cursor" })
require("wrfm").detach(bufnr):WrfmHere / :WrfmDetach do the same from the command line (:WrfmHere
places the preview at the cursor).
Control spinning animation
local model = require("wrfm").current
model:set_spin(false) -- stop spinning
model:set_spin(true) -- resume spinning
model:set_spin() -- toggle current state
model:set_pitch(0) -- front view
model:set_pitch(90) -- top-down view
model:set_pitch(30) -- default angleAdjust camera distance
local model = require("wrfm").current
model:set_distance(2) -- closer (smaller value = closer)
model:set_distance(10) -- farther away
model:set_distance(nil) -- auto-fit to model sizeReposition and resize the viewer
local model = require("wrfm").current
-- Resize canvas (persists across renders)
model:render({ width = 60, height = 30 })
-- Shift float relative to center
model:render({ x = -4, y = 2 })
-- Move to absolute editor coordinates
model:move(10, 5)Use as a dashboard logo
A spinning wireframe makes a live start-screen logo:
-- pattern per plugin: "dashboard" (dashboard-nvim),
-- "alpha" (alpha-nvim), "snacks_dashboard" (snacks.nvim)
vim.api.nvim_create_autocmd({ "BufNewFile", "BufReadPost" }, {
pattern = "dashboard",
once = true,
callback = function(args)
local wrfm = require("wrfm")
local model = wrfm.from_file(vim.fn.stdpath("config") .. "/logo.wrfm", {
id = "dashboard-logo",
width = 44,
height = 13,
spin_speed = 0.015,
})
model:render()
model:move(math.floor((vim.o.columns - 44) / 2), 3)
vim.api.nvim_create_autocmd("BufUnload", {
buffer = args.buf,
once = true,
callback = function() wrfm.clear("dashboard-logo") end,
})
end,
})Work with multiple models
local wrfm = require("wrfm")
-- Load multiple models
local anvil = wrfm.from_file("anvil.wrfm", { id = "anvil", namespace = "preview" })
local cube = wrfm.from_file("cube.wrfm", { id = "cube", namespace = "preview" })
-- List all live models (filters combine conjunctively)
local all = wrfm.get_models()
local previews = wrfm.get_models({ namespace = "preview" })
local per_buffer = wrfm.get_models({ buffer = bufnr })
local per_window = wrfm.get_models({ window = winid })
-- Destroy a model (instance method or registry id)
anvil:clear()
wrfm.clear("cube")
-- Destroy all
wrfm.clear()Hide and show models without destroying them
local wrfm = require("wrfm")
wrfm.hide() -- hide every view
wrfm.hide("anvil") -- hide one
wrfm.show("anvil") -- restore one
wrfm.show() -- restore allCamera, spin, and watch state survive the round trip.
| Command | Effect |
|---|---|
:Wrfm [file] |
View a .wrfm file (defaults to the current buffer's file) in a floating window; repeating it re-renders the existing viewer for that file |
:WrfmClear [id] |
Close viewers: with id, exactly that one (every match); without, all of them |
:WrfmList |
List live viewers: id, mode, spin state, source path |
:WrfmHere |
Attach an inline preview at the cursor (idempotent per buffer) |
:WrfmDetach |
Detach inline preview from the current buffer |
:WrfmReport |
Floating diagnostic report: system info + live snapshot of every model |
:WrfmList shows ids for targeted :WrfmClear <id>.
:WrfmReport shows Neovim version and platform info, the active
configuration, a live snapshot of every registered model (id, mode,
namespace, canvas size, camera, spin/watch state), and resource counts
(timers, watchers). Run :checkhealth wrfm for a quick health check.
The inline preview renders the wireframe as braille text inside the buffer itself using a virt_text overlay extmark — the artwork is composited on top of the buffer's real text cells, so the source text scrolls with the buffer and is never pushed apart.
A preview never appears on its own. Rendering is the conjunction of two preconditions — rendering is on and a position was specified — and neither alone paints anything:
local wrfm = require("wrfm").attach(bufnr, { at = { line = 0, col = 0 } }) -- fixed
local at_cursor = require("wrfm").attach(bufnr, { at = "cursor" }) -- follows the cursorat is the placement: "cursor", or a table pinning the canvas' top-left
cell to a 0-based buffer (line, col). Without one, attach() raises and
never guesses — an inline preview writes into cells the buffer already owns, so
"draw it wherever" is not a thing this plugin can mean. :WrfmHere is the
one-liner form and states its position itself: the cursor.
setup() merges its options, so a key can be given a value but not un-set from
config: to return to "no placement" at runtime, clear
require("wrfm").config.integrations.wrfm.at directly (or restart Neovim).
The same rule governs the integrations.wrfm auto-attach hook, which is
opt-in (enabled = false by default) and unplaced by default (at = nil):
turning it on without naming a place attaches nothing, and says so once. This is
the image.nvim rule — render where the document asks for it, never because a
buffer happened to be opened.
mode picks the channel: "overlay" (default) composites into the buffer's
own cells, "popup" opens a floating window at the cursor (so it requires
at = "cursor"). A cursor placement follows the cursor as it moves, in both
modes; a fixed placement stays exactly where it was put.
Because the overlay shares cells with the buffer, two options control how it composites with whatever is already there:
overflow—"clip"(default) crops the artwork to the canvas;"visible"bleeds it into the surrounding text, dropping cells with no buffer line/column to land on.z_order—"model"(default) paints over a colliding cell;"text"yields the cell to the buffer's content.
Both are two-level (a default_* setup default, overridable per model via
from_file() / attach()).
With watch = true (the default), each viewer follows its source file:
change and save the .wrfm in another editor and the
view updates, keeping your pitch/yaw/distance/spin state.
Inline previews also follow unsaved edits: an on_lines watcher re-parses
the buffer content as you type, so editing a .wrfm file previews live even
before :write. The disk and buffer channels dedup against the last parsed
text, so saving what is already shown does not repaint.
- Directory-level fs_event watching survives editors that replace files atomically by rename; filesystems without fs_event support fall back to a 2 s poll automatically.
- A state that is momentarily unparseable (mid-edit, saved or unsaved) keeps its last good frame; a single warning is shown until it becomes valid again.
- Deleting the file keeps the last frame and stops the watcher.
watch = falsegives a static snapshot instead (both channels off).
Spin timers pause automatically when Neovim loses focus (FocusLost) or is
suspended (VimSuspend), and resume on FocusGained / VimResume. Views
stay visible — braille frames cost nothing to keep on screen. Manually
stopped spins are never resurrected by focus events.
By default (pause_spin_when_unfocused = true) a model whose host window is
not the focused window stops repainting: a dashboard logo hidden behind a
fullscreen terminal file-manager float, or a preview in a split you stopped
looking at, no longer burns a frame every tick. The timer keeps running, so
the spin resumes the moment you focus the host window again. Disable the
option to keep background models spinning unconditionally.
Bursts of relayout/reload events within one event-loop turn coalesce into a single repaint.
Stale contexts are swept automatically: after TabEnter, BufEnter or
WinClosed every model whose window was closed, whose buffer was deleted, or
whose anchor window switched content is torn down — including non-spinning
models that no tick would ever inspect. Use wrfm.hide() when a view should
survive such transitions instead.
Rendering pauses while the command-line window is open (opening floats under
it would raise), and a programmatic first render issued before the UI exists
(e.g. from init.lua) is queued until VimEnter so terminal dimensions are
known before derived canvas sizes clamp against them.
Viewer floats are decorations: they open non-focusable, so your keys always
keep going to the focused window. Close them with :WrfmClear.
Why braille instead of the Kitty/6el graphics protocols? Because braille output is plain text (see Requirements); the trade-off is resolution, which is usually plenty for wireframes.
Performance? Rendering is a few hundred microseconds per frame for typical models.
What .wrfm files can I view?
Any valid .wrfm file. The format supports vertices, edges, and
optional group sections. See the wireforge project for format
details and model generators — or let the companion
wrfm-skill generate one for you.
wrfm.nvim uses mise for task running and tool version
pinning (.tool-versions).
curl https://mise.run | sh && mise install # install mise + pinned tools
mise run test # full test suite (busted via lazy.minit)
mise run format # stylua-format lua/, plugin/, tests/mise run help lists all tasks (test output variants, coverage,
golden-fixture regeneration, local CI via act). Tests are hermetic: golden
fixtures are committed, so the oracle comparison never needs the wrfm CLI at
runtime (WRFM_SKIP_ORACLE=1 skips it anyway).
Each suite lives in a flat spec file next to shared fixtures and helpers:
tests/
├── *_spec.lua # api / e2e / inline / oracle / parser / renderer suites
├── busted.lua # entry point: nvim -l tests/busted.lua
├── helpers.lua
└── fixtures/ # golden files, .wrfm models
Interactive testing scenarios (Chinese): docs/user-test-guide.md