docs/internal/virtual-space-scoping.md
Status: IMPLEMENTED. The feature shipped behind the
editor.virtual_spacesetting (off|block|on, defaultoff). The implementation follows the design below with one notable deviation: instead of a newvirtual_columnfield onCursor, the derivation reusessticky_column— a cursor is virtual iff it is collapsed, sits exactly at its line's content end, and its sticky (goal) column exceeds the line's visual width (model::virtual_space::cursor_virtual_columnsis the single source of truth). That works because the sticky column already flows through everyMoveCursorevent, undo, and session persistence; making it trustworthy required fixing several producers that stored byte columns in it and resetting it on edits (see the commit history of this branch). Beyond the original scope, the implementation also supports vertical virtual space: clicking below the last line — or pressing ArrowDown at the bottom of the buffer — parks the cursor on a virtual line at its column (transientCursor::virtual_lines_belowstate, meaningful only while the cursor sits at the buffer end; vertical movement queues the count viaEditorState::pending_virtual_lines, consumed when the move event applies); typing there materializes the missing newlines plus column padding in one undo step. ArrowUp steps back through the virtual lines. A per-buffer "Toggle Virtual Space (Current Buffer)" command overrides the global setting and persists in the workspace session. Known limits, as scoped: linear selections stay byte-clamped (Shift+arrows collapse a virtual cursor to the content end), soft-wrapped lines don't get virtual columns past the wrap point, block-selection geometry remains byte-column based (exact for the spaces padding materializes), and vertical scroll doesn't follow virtual lines past the viewport bottom (the floating cursor clamps to the last visible row until real lines exist).
Scoping analysis for adding virtual space (cursor movement and placement beyond the
end of a line) to Fresh, prompted by VSCode's long-stalled implementation
(microsoft/vscode#228680, for issue
#13960) and the fact that today only
traditional editors/IDEs (Visual Studio, JetBrains, Vim's virtualedit, Emacs) offer it.
With virtual space enabled, the cursor may occupy any column, including columns past the last character of a line:
The VSCode PR deliberately did not change the text model: model positions remain
clipped to real text, and the cursor state carries a leftoverVisualColumns field — the
number of visual columns the cursor sits past the line end. Virtual space is a
cursor/view-layer concept; the buffer only changes when typing materializes spaces. The
PR stalled for ~2 years not because the approach was wrong but on polish issues: cursor
behavior inconsistencies, scroll-width not accounting for virtual positions, and
view/model layering concerns. Those are exactly the risk areas we should budget for.
Fresh's editing core (crates/fresh-editor) is built on byte offsets: a cursor
position is always a valid byte index into the buffer
(model/cursor.rs, docs/internal/text-model.md). There is currently no way to
represent "column 20 on a 5-character line."
What already exists in our favor:
Cursor.sticky_column: Option<usize>
(model/cursor.rs:31-36) pins the desired visual column for vertical navigation,
and handle_vertical_up/down (input/actions.rs:1497/1547) already preserve the
visual goal across short lines. This is the same "column hint" machinery VSCode's PR
leans on — the desire to be past EOL already survives movement; only the byte
position collapses to line end.Event::MoveCursor;
clamping happens in a small number of known sites (see §4), not scattered through the
code.byte_offset_at_visual_column
(primitives/display_width.rs:48-57) is where an over-long goal column collapses onto
the line end. It is #[inline], wide-char aware, and trivially extendable to a
variant that reports overflow (how many visual columns past EOL the request was).What works against us:
view/ui/split_rendering/orchestration/render_line/cells.rs:631-664). There is no
code path that places a cursor at line_end + N.SelectionMode::Block + Position2D), every column is clamped to the line's byte
length (primitives/buffer_position.rs:26-36, input/actions.rs:398), rectangles are
truncated on short lines (selection_sweep.rs:89-105 only tests cells that exist),
and block copy yields ragged lines with no padding (clipboard.rs:221-300). Block
selection is a beneficiary of this work, not a foundation for it.screen_to_buffer_position
(app/click_geometry.rs:54, closure at 109-133) snaps clicks past EOL to
line_end_byte.Cursors::normalize
(model/cursor.rs:339-361) sorts and dedups by (position, anchor); two cursors at
the same byte with different virtual columns would incorrectly merge.Option A — carry a virtual column on the cursor (recommended). Add
virtual_column: Option<usize> (visual columns past EOL) to Cursor, alongside
sticky_column. The buffer stays untouched by movement; spaces materialize only when an
edit happens at a virtual position. This matches VSCode's design, Vim's virtualedit,
and Visual Studio. The invariant "cursor byte is always valid" is preserved; the
virtual column is view-layer state that edits consume.
Option B — eagerly insert real spaces as the cursor moves. Simpler (movement + buffer only, no rendering or mouse changes), but it mutates files on mere navigation, creates trailing whitespace, dirties undo history, and fights line-trimming logic. This is not what users mean by virtual space; rejected.
The rest of this document scopes Option A.
model/cursor.rs (small, but subtle)virtual_column: Option<usize> to Cursor (visual columns past line end;
None = not in virtual space).Cursors::normalize (339): include virtual_column in the sort/dedup key so
same-byte cursors with different virtual columns don't collapse.adjust_for_edit (165-188): decide reset semantics — an edit on the cursor's line
should generally clear other cursors' virtual columns on that line (their gap width
changed); edits elsewhere leave them alone.SerializedCursor (app/window/mod.rs:2510-2522) already
serializes sticky_column; extend it if virtual columns should survive
session restore (they probably should, for consistency).input/actions.rs (largest single chunk)All the clamp sites need to become virtual-space aware, gated on the setting:
| Site | Today | With virtual space |
|---|---|---|
handle_vertical_up/down (1497/1547) | goal column collapses to line end via byte_offset_at_visual_column | set virtual_column = goal - line_width when goal exceeds line width |
MoveRight / MoveRightInLine (2378/2458) | clamps at line end / buffer end | increment virtual_column past EOL instead of wrapping/stopping |
MoveLeft (2365) | moves to previous byte | decrement virtual_column first; only move bytes when it reaches 0 |
MoveLineEnd (2410) | lands at content end | unchanged (End goes to real EOL; clears virtual column) |
handle_page_up/down (1599/1650) | goal_column.min(line_len) | same treatment as vertical up/down |
Supporting primitive: add a byte_offset_at_visual_column_with_overflow variant in
primitives/display_width.rs returning (byte_offset, leftover_visual_columns).
Word movement, MoveLineStart, buffer start/end, go-to-line etc. should clear
virtual_column — that's the cheap, safe default for every movement not explicitly
taught about virtual space.
input/actions.rs + clipboard.rs (localized, must be exhaustive)Every edit entry point that inserts at the cursor must first materialize padding when
virtual_column > 0: emit Event::Insert { position: line_end, text: " ".repeat(n) }
before the real insert, then clear the virtual column.
insert_char_events (917) / collect_insert_cursor_data — typing.handle_insert_newline (1098) — Enter in virtual space should just insert \n at
real line end (no padding), matching VS/Vim behavior.clipboard.rs) — both linear and block paste.Because padding is emitted as ordinary Event::Inserts, undo/redo and multi-cursor
offset adjustment come for free — one undo entry removes both the typed char and its
padding if grouped, which we should verify in tests.
view/ui/split_rendering/orchestration/render_line/ (the risky part)cells.rs place_cell_cursor (631-664) / cell_screen_x (707): when the primary
cursor has a virtual column, screen X must be computed as
line_content_width + virtual_column rather than found by byte-matching a cell.trailing.rs: the current post-content handling (trailing-space indicator, implicit
EOF line at 61/147-151) is the natural place to plug in "cursor is N columns past
content."overlays.rs selection_context,
selection_sweep.rs): linear selections ending in virtual space and block-selection
rectangles over short lines need synthesized "phantom cells" (or a post-content
highlight rect) since the sweep only visits cells that exist.app/click_geometry.rs (small)position_from_mapping (109-133): instead of returning line_end_byte for clicks past
content, return (line_end_byte, clicked_col - content_width) as byte + virtual column.
Drag-selection inherits this. Double/triple-click should keep snapping to real text.
With virtual columns in place, block selection can become true-rectangle:
block_select_action (input/actions.rs:356-454) and pos_2d_to_byte
(primitives/buffer_position.rs:26-36): stop clamping columns to line length.copy_block_selection_text (clipboard.rs:221-300): pad short lines with spaces so
copied rectangles are rectangular.config.rs (~1290, "Editing" section): pub virtual_space: bool with
#[serde(default)] (default off, matching every editor that has this feature),
plus partial_config.rs override plumbing.BufferSettings (state.rs:92-130) so
movement/insert/mouse/render code can read it; expose in the settings UI
(view/settings/items.rs).virtualedit:
off | block (block selection only) | on (everywhere). block is a low-risk,
high-value middle tier and a good candidate for the default-visible option.char_width/display_width.rs already gives us the machinery.The feature ships as one effort: a single branch delivering the complete behavior — movement, typing, mouse, rendering, and block selection all virtual-space aware, behind the setting. No intermediate releases with partial behavior; a half-implemented virtual space (e.g. movement without airtight typing/deletion/scroll semantics) is exactly the inconsistency trap that killed the VSCode PR.
Within the branch, the work splits naturally into reviewable commits, each keeping the build green and tests passing:
virtual_column on Cursor, the config
flag/enum threaded through BufferSettings, normalize/dedup/adjust_for_edit
semantics, and the overflow-reporting variant of byte_offset_at_visual_column.
Pure plumbing, fully unit-testable, no behavior change yet.Commits 2–6 each gate their behavior on the setting, so master remains shippable at
every point even though the feature only "counts" when all of them are in. Rough total:
2–3 weeks of focused work, dominated by rendering (§4.4) and by making the edit-path
coverage exhaustive (§4.3). Fresh's centralized event-based editing and existing
sticky_column machinery make the scope meaningfully smaller than VSCode's.
off | block | on enum? (Recommend the
enum, default off; consider block as the recommended setting in docs.)sticky_column
already does.)