Skip to content
lenitainPublic

About

Render .wrfm wireframe models inside neovim with braille characters.

Resources

Stars

2 stars

Watchers

0 watching

Forks

Repository files navigation

wrfm.nvim

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.

Requirements

  • 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.

Installation

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.

Configuration

Default configuration

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().

How to ...?

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 defines WrfmPreview itself still takes precedence.
  • Malformed values (e.g. "#12") raise on setup(), 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()) -- bool

While 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 angle
Adjust 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 size
Reposition 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 all

Camera, spin, and watch state survive the round trip.

Commands

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.

Inline preview

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 cursor

at 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()).

Hot reload

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 = false gives a static snapshot instead (both channels off).

Lifecycle

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.

FAQ

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.

Development

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

Test layout

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

About

Render .wrfm wireframe models inside neovim with braille characters.

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages