Virtual text columns #25

Closed
opened 2026-09-21 18:54:14 +00:00 by barrettruth · 1 comment
Owner

Original issue: barrettruth/canola.nvim#142
Original author: barrettruth
Original date: 2026-03-16T18:51:28Z

Problem

Columns (icon, size, permissions, mtime, owner, group) are rendered as inline text prepended to each buffer line. This has several concrete consequences:

Buffer content is polluted. A typical line looks like 󰈙 rwxr-xr-x 4.2k Mar 12 14:30 filename. Everything before filename is column data baked into the buffer.

Column data is editable by accident. c0, d0, visual block, and similar motions can silently corrupt column text or the concealed ID prefix. constrain_cursor only guards insert mode. The user sees a cryptic parse error on :w with no indication of what went wrong.

Yank includes column garbage. yy yanks the full line: concealed ID, icon, permissions. Pasting duplicates all of it. There is no clean way to yank just the filename.

Highlights require a workaround. Extmarks don't survive yank/paste, so a TextChanged autocmd re-parses the entire buffer on every edit just to reapply column highlights. This is an expensive workaround for a problem that wouldn't exist if column text weren't in the buffer.

Parsing is fragile. parse_line in mutator/parser.lua consumes column text segment by segment, using each column's parse() function to peel off its portion of the line. Every column must have a parse() implementation with a correct regex. Byte offset tracking (ranges) propagates through constrain_cursor, show_insert_guide, and update_insert_boundary. Any column width variation (e.g. variable-width timestamps) can introduce off-by-one bugs.

Proposed approach

Render non-name columns as virtual text via nvim_buf_set_extmark with the virt_text field, anchored inline before or after the name. Buffer lines contain only /{id} {name}.

This eliminates all four problems by design: virtual text is not editable, not yankable, not part of the buffer diff, and re-renders automatically via Neovim's extmark invalidation without a TextChanged workaround.

Column classification

Columns split into two categories:

Physical columns — text that lives in the buffer and participates in parse_line. Only the filename (and symlink target) is physical after this change. The concealed ID prefix stays physical too, since it is the anchor for all extmarks.

Virtual columns — display-only, rendered as virt_text on the same extmark that currently handles ID concealment. No parse() function needed. Includes: icon, size, permissions, mtime, owner, group, and any third-party column that opts in.

The oil.ColumnDefinition interface gains an optional virtual field. When virtual = true, columns.render_col returns a virt_text-compatible chunk list; parse_col is never called for that column.

Implementation scope

  • columns.lua — add virtual field to oil.ColumnDefinition; render_col returns virt_text chunks when virtual = true
  • mutator/parser.lua — parse_line skips virtual columns entirely; ranges no longer tracks their byte offsets
  • view.lua — format_entry_cols places virtual column chunks on the extmark instead of prepending to the line string; constrain_cursor, show_insert_guide, and update_insert_boundary no longer need to account for column widths
  • util.render_table / column alignment — alignment is handled via virt_text_win_col (fixed virtual column position) or padding in the virt_text chunk list, not by padding the buffer string
  • TextChanged highlight-reapplication autocmd — removed; extmarks handle invalidation automatically
  • Tests — parser and regression specs need updates; constrain_cursor tests no longer need column-width fixtures

Interaction notes

constrain_cursor — cursor is already constrained to ranges.name. With virtual columns gone from the buffer, ranges.name starts at position 1 after the ID prefix space. The constrain logic simplifies.

Virtual text at EOL — columns rendered after the name (e.g. size, mtime) use virt_text_pos = "eol" or "inline". EOL virtual text is not reachable by the cursor, so it doesn't affect constrain_cursor.

Performance — extmarks are cheap. The expensive part is the nvim_buf_set_extmark call per entry on re-render, which is the same cost as today's highlight extmarks. No net regression.

Backwards compatibility — the virtual field is optional. Existing columns without it remain physical and continue to work. The public register_column API (see #192) exposes virtual as a first-class option.

Supersedes

Closes #136 (inline editing guard via on_bytes) — that was a weaker mitigation for the same root cause.

Consolidates

> Original issue: barrettruth/canola.nvim#142 > Original author: `barrettruth` > Original date: 2026-03-16T18:51:28Z ## Problem Columns (icon, size, permissions, mtime, owner, group) are rendered as inline text prepended to each buffer line. This has several concrete consequences: **Buffer content is polluted.** A typical line looks like `󰈙 rwxr-xr-x 4.2k Mar 12 14:30 filename`. Everything before `filename` is column data baked into the buffer. **Column data is editable by accident.** `c0`, `d0`, visual block, and similar motions can silently corrupt column text or the concealed ID prefix. `constrain_cursor` only guards insert mode. The user sees a cryptic parse error on `:w` with no indication of what went wrong. **Yank includes column garbage.** `yy` yanks the full line: concealed ID, icon, permissions. Pasting duplicates all of it. There is no clean way to yank just the filename. **Highlights require a workaround.** Extmarks don't survive yank/paste, so a `TextChanged` autocmd re-parses the entire buffer on every edit just to reapply column highlights. This is an expensive workaround for a problem that wouldn't exist if column text weren't in the buffer. **Parsing is fragile.** `parse_line` in `mutator/parser.lua` consumes column text segment by segment, using each column's `parse()` function to peel off its portion of the line. Every column must have a `parse()` implementation with a correct regex. Byte offset tracking (`ranges`) propagates through `constrain_cursor`, `show_insert_guide`, and `update_insert_boundary`. Any column width variation (e.g. variable-width timestamps) can introduce off-by-one bugs. ## Proposed approach Render non-name columns as virtual text via `nvim_buf_set_extmark` with the `virt_text` field, anchored inline before or after the name. Buffer lines contain only `/{id} {name}`. This eliminates all four problems by design: virtual text is not editable, not yankable, not part of the buffer diff, and re-renders automatically via Neovim's extmark invalidation without a `TextChanged` workaround. ## Column classification Columns split into two categories: **Physical columns** — text that lives in the buffer and participates in `parse_line`. Only the filename (and symlink target) is physical after this change. The concealed ID prefix stays physical too, since it is the anchor for all extmarks. **Virtual columns** — display-only, rendered as `virt_text` on the same extmark that currently handles ID concealment. No `parse()` function needed. Includes: icon, size, permissions, mtime, owner, group, and any third-party column that opts in. The `oil.ColumnDefinition` interface gains an optional `virtual` field. When `virtual = true`, `columns.render_col` returns a `virt_text`-compatible chunk list; `parse_col` is never called for that column. ## Implementation scope - `columns.lua` — add `virtual` field to `oil.ColumnDefinition`; `render_col` returns virt_text chunks when `virtual = true` - `mutator/parser.lua` — `parse_line` skips virtual columns entirely; `ranges` no longer tracks their byte offsets - `view.lua` — `format_entry_cols` places virtual column chunks on the extmark instead of prepending to the line string; `constrain_cursor`, `show_insert_guide`, and `update_insert_boundary` no longer need to account for column widths - `util.render_table` / column alignment — alignment is handled via `virt_text_win_col` (fixed virtual column position) or padding in the virt_text chunk list, not by padding the buffer string - `TextChanged` highlight-reapplication autocmd — removed; extmarks handle invalidation automatically - Tests — parser and regression specs need updates; constrain_cursor tests no longer need column-width fixtures ## Interaction notes **`constrain_cursor`** — cursor is already constrained to `ranges.name`. With virtual columns gone from the buffer, `ranges.name` starts at position 1 after the ID prefix space. The constrain logic simplifies. **Virtual text at EOL** — columns rendered after the name (e.g. size, mtime) use `virt_text_pos = "eol"` or `"inline"`. EOL virtual text is not reachable by the cursor, so it doesn't affect `constrain_cursor`. **Performance** — extmarks are cheap. The expensive part is the `nvim_buf_set_extmark` call per entry on re-render, which is the same cost as today's highlight extmarks. No net regression. **Backwards compatibility** — the `virtual` field is optional. Existing columns without it remain physical and continue to work. The public `register_column` API (see #192) exposes `virtual` as a first-class option. ## Supersedes Closes #136 (inline editing guard via `on_bytes`) — that was a weaker mitigation for the same root cause. ## Consolidates - stevearc/oil.nvim#655 — file statistics as virtual text - stevearc/oil.nvim#736 — icons as virtual text - stevearc/oil.nvim#667 — all columns as virtual text (WIP PR, deferred upstream)
barrettruth added this to the v1.1 milestone 2026-09-21 18:54:14 +00:00
Author
Owner

Original comment: barrettruth/canola.nvim#142, comment 4101543865
Original author: barrettruth
Original date: 2026-03-21T00:08:25Z

Implemented in #224.

> Original comment: barrettruth/canola.nvim#142, comment 4101543865 > Original author: `barrettruth` > Original date: 2026-03-21T00:08:25Z Implemented in #224.
Sign in to join this conversation.
No description provided.