# Tab-width rendering parity - side quest **Status:** Revision 2 implemented on `tab-width-parity`; all fifteen acceptance criteria pass locally. PR #137 is open for review. **Base:** `githubsucks/main` at `40111dc` (landed-state documentation for locals-query processing #134); protocol v18. ## Problem Pmacs has no single tab-rendering contract: - `src/text_view.rs`, `src/highlight.rs`, `src/diag.rs`, and `src/completion.rs` independently hard-code an 8-column tab stop. - `src/overlay.rs` repeats the same 8-column arithmetic as literals. - `pmacs-gpu/src/main.rs::advance_minimap_col` uses 4 columns and counts every non-tab character as one column. - The GPU code buffer sends raw `\t` bytes to cosmic-text. Its visible width is therefore whatever the selected font's tab glyph happens to provide, not a pmacs tab stop. The disagreement is observable. The same buffer can place text, syntax faces, diagnostic squiggles, completion popups, selections, carets, and minimap marks at different columns between the TUI and GPU frontends. Merely defining an `editor.tab-width` config key would not fix the GPU: tab expansion, source-byte mapping, styling, and hit testing all happen inside the frontend after the raw semantic frame arrives. This is the remaining top-ranked item in `docs/side-quest-backlog.md:123-130` and `:245-248`. ## Goal Define one fixed 8-column tab-stop invariant, make every shipped buffer-text renderer honor it, and preserve byte-addressed editor semantics while the GPU shapes an expanded display projection. For a tab beginning at logical display column `c`, its width is ```text 8 - (c mod 8) ``` so a tab at an already aligned column advances a full eight columns. Source text remains byte-for-byte unchanged. ## Scope ### In - One canonical tab-stop constant shared by the core and GPU crates. - One core display-column utility used by plain text, syntax styling, diagnostics, completion placement, and generic buffer-style overlays. - Tab expansion in the GPU code-buffer projection before cosmic-text shaping. - Correct projected-to-source and source-to-projected mapping for clicks, carets, selections, diagnostic geometry, wrapping, and inline adornments. - GPU minimap width/indent accounting using the same tab and Unicode-width rules as the code view. - Focused regression coverage at tab-stop boundaries, after wide Unicode, and through styled and selected tab bytes. ### Out - A user-configurable `editor.tab-width` setting. This change deliberately chooses the already-shipped TUI behavior, 8, and makes it universal. - Per-buffer or per-language tab widths, indentation policy, soft-tab insertion, tab-to-spaces conversion, or retabbing existing files. - Changing what the Tab key inserts. A literal tab remains one source byte. - Expanding tabs in protocol payloads or mutating `SemanticFrame` byte ranges. - Tabs in statusline, minibuffer, menu, hover panels, or other non-buffer UI strings. - A wire schema or protocol-version change. ## Ground truth and contracts to preserve ### Core renderers are byte-addressed but paint in display columns `TextView` already expands a tab to spaces at the next multiple of 8 and maps the one source byte to that display interval. Syntax highlighting and diagnostics independently translate byte ranges into display columns. Completion computes its popup anchor from a byte offset. Generic `BufferStyleSpan` overlays compute both ends from the line start. All five paths require the same prefix-width operation; today they implement it separately. The current TUI inverse mapping rounds every display column inside an expanded tab forward to the source boundary after the tab. That behavior is observable and remains the cross-frontend rule. ### GPU code text is already a source-preserving projection `projected_rich_chunks` interleaves source text, foreground style spans, and inline adornments. `line_from_chunks` is the only content fed to cosmic-text. `line_chunk_cache` drives source-byte-to-layout-cursor conversion, while `current_hit_runs` and `projected_line_starts` convert cosmic-text hit results back into source bytes. Incremental line reshaping and full slice rebuilds both consume the same chunk construction path. Tab expansion belongs at this projection boundary. Expanding the daemon's text would invalidate every protocol byte range; asking cosmic-text to interpret raw tabs would retain font-dependent behavior. ### GPU geometry currently assumes source bytes equal shaped bytes Foreground colors are attached to chunks and therefore naturally survive a projection when the chunk provenance is retained. Background selections, current-line washes, and diagnostic squiggles are different: `push_glyph_extent_rects` currently compares source-relative decoration bytes directly with cosmic-text glyph byte offsets. That equality already needs special handling for adornments and becomes definitively false once one tab byte projects to multiple spaces. The geometry path must use the same source/projection mapping as caret placement and hit testing. ### The protocol transports raw text and raw byte ranges `pmacs-protocol` owns the types shared by the daemon and GPU. `SemanticFrame` continues to carry unmodified text plus byte-addressed spans, decorations, and adornments. A tab-stop constant is a rendering semantic for those existing fields, not a serialized field. Adding it changes neither postcard encoding nor version negotiation. ## Decisions ### Q#TW1 - The canonical tab stop is fixed at eight columns Add a documented public constant named `TAB_STOP_COLUMNS: u32 = 8` to `pmacs-protocol` and re-export it through the crate root. Both the pmacs core and `pmacs-gpu` consume that constant. The shared protocol crate is the narrow existing dependency common to both frontends. A second rendering crate is unjustified, while two frontend-local constants would preserve the drift this work is meant to remove. The constant is normative metadata for interpreting raw text already carried by the semantic protocol; it is not serialized. Do not add a config-registry key. A future configurable width would need a buffer-effective value in every semantic frame (or another versioned frontend fact), cache invalidation when it changes, and tests across reconnects. That is a separate feature, not hidden scope in this parity fix. This work changes no `PROTOCOL_VERSION`: no message variant, field, encoding, capability, or negotiation rule changes. It adds a compiled rendering invariant for a previously unspecified raw-tab case to the protocol version present on its implementation base. ### Q#TW2 - One core module owns display-column arithmetic Add `src/display_width.rs` and export it from `src/lib.rs`. It owns: - `TAB_STOP_COLUMNS` consumption from `pmacs_protocol`; - advancing a logical column by one character, including tabs and `unicode-width` handling; - the width of a valid UTF-8 string from a specified starting column; - the display column at a byte boundary in a line; and - the display-column pair for a half-open byte range. Byte helpers clamp to the input length and use the longest valid UTF-8 prefix when a stale/asynchronous range lands inside a code point. They do not allocate. Tabs are always evaluated from the line's logical column zero, not from the viewport edge or a range's start. Migrate `text_view`, `highlight`, `diag`, `completion`, and `overlay` to this module. Delete their constants and private copies rather than leaving aliases or wrapper functions. `TextView` may still special-case tab painting, but its pad count comes from the shared column advance. ### Q#TW3 - GPU expands tabs in the rich-chunk projection After source/style/adornment boundaries have produced `RichChunk`s, run one projection pass before either full-slice or per-line shaping. The pass walks chunks and code points in display order while tracking a logical display column: - ordinary characters retain their text and provenance and advance by `unicode-width`; - newline resets the logical column to zero; - a source tab becomes `TAB_STOP_COLUMNS - (column % TAB_STOP_COLUMNS)` ASCII spaces carrying explicit provenance for that one source byte; - a tab inside an adornment also becomes spaces but retains the adornment's anchor provenance; and - zero-width characters do not advance the logical column. A chunk with no tab is retained rather than copied again. Chunks containing one or more tabs are split only at those tab boundaries. The existing visible slice and per-line caches therefore bound both allocations and work; the frontend never expands the whole file merely to draw one viewport. `line_from_chunks`, `build_hit_runs`, incremental line replacement, and the full rebuild all consume the expanded chunks. No alternate shaping path may feed raw buffer tabs to cosmic-text. ### Q#TW4 - A projected tab run has first-class source provenance Extend `ChunkSource` with a source-tab form containing the tab's slice-relative byte offset. The derived `ProjectedRun` then represents three semantics: 1. source text is byte-linear; 2. adornment text snaps to its anchor; and 3. all projected spaces for a tab correspond to one source byte. Boundary rules are explicit: - source offset at the tab byte maps to the first projected space; - source offset immediately after the tab maps after the final projected space; - a projected hit exactly at the tab's leading boundary maps before the tab; - any hit inside its expanded interval maps after the tab, matching `TextView::display_to_pos`; and - a hit at the following projected boundary maps to the following source boundary without crossing an adornment's established left-gravity rule. Factor the per-line source-to-projected conversion out of `State::code_byte_to_projected` so caret placement, decoration geometry, and unit tests use the same boundary implementation. Keep projected-to-source in the run map built from those exact chunks. Do not infer positions from counts of spaces after shaping. ### Q#TW5 - Tab stops use the final visible logical column The projection pass counts all visible content before a tab, including wide Unicode and inline-adornment text. This makes the expanded tab end on a visible 8-column boundary instead of overlapping or drifting when an inlay hint occurs before it. Cosmic-text remains responsible for glyph shaping and pixel geometry. The tab rule controls how many monospace spaces are supplied; it does not replace shaping with manual pixel placement. Code font fallback may vary in pixels, but logical columns remain deterministic. Add `unicode-width = "0.2"` as a direct `pmacs-gpu` dependency. Do not reach through another crate's transitive dependency. ### Q#TW6 - Styles and decorations cover the full projected tab Foreground styling is preserved by assigning every expanded source-tab chunk the color of the source chunk that contained the tab. A style span covering `[tab, tab + 1)` therefore colors every projected space; a span ending at the tab colors none of them. For background selections and diagnostic squiggles, convert each source-range intersection on a shaped line to projected byte boundaries before comparing it with `LayoutGlyph::{start,end}`. The conversion uses that line's cached chunks and the Q#TW4 boundary rules. Do not rewrite protocol ranges, and do not use source line offsets as if they were projected byte offsets. This same conversion covers own selections, peer selections, current-line geometry where applicable, and diagnostic ranges. Gutter diagnostic signs are line-presence indicators and remain source-line based; they need no horizontal projection. ### Q#TW7 - The minimap uses the same logical-width rule Replace the hard-coded 4-column `advance_minimap_col` branch with the shared `TAB_STOP_COLUMNS` value. Ordinary characters advance by `unicode-width` instead of unconditionally by one; zero-width characters advance by zero and wide characters by two. The minimap remains a density abstraction rather than shaped text, but its indent and content extents now agree with the code view's logical columns. Clipping and pixel compression are unchanged. ### Q#TW8 - Projection invalidation follows existing text/chunk invalidation Tab width is fixed at compile time, so it introduces no runtime invalidation source. Text edits, style/adornment updates, font changes, resizes, scrolling, and buffer switches already rebuild or replace the affected chunk cache. Tab projection runs inside those existing paths. `try_reshape_line` must regenerate the expanded chunks for the edited line; `rebuild_lines_reusing_scroll` may retain an unchanged line and its already expanded cache. `hit_map_dirty` continues to mark when the whole-slice reverse map must be rebuilt. No new generation counter or whole-file cache is needed. ## Data flow ```text daemon / TUI core source bytes ───────────────────────────────────────────────┐ │ │ ├─ display_width helpers ──> TUI glyph/style columns │ │ │ └─ SemanticFrame { raw text, byte ranges } ─────────────┤ v pmacs-gpu │ style + adornment boundaries ─────────┤ v source-rich chunks │ expand tabs to spaces + preserve provenance │ ┌────────────────────────────────┼─────────────┐ v v v cosmic-text hit/caret map decoration map shaping/render ↕ source source → glyph │ │ │ └────────────────────────────────┴─────────────┘ ``` The invariant is that only the display projection expands a tab. Every editor, protocol, edit, selection, syntax, diagnostic, and adornment coordinate remains a source-byte coordinate. ## Bets 1. **Eight is the correct parity target.** It is the established TUI behavior and existing tests already encode it. This work removes divergence rather than introducing a new preference. 2. **ASCII spaces are the stable shaping input.** The code font is measured as monospace and spaces participate in wrapping, hit testing, and glyph ranges that the existing GPU architecture already understands. 3. **Visible-slice expansion is cheap enough.** The GPU already allocates owned rich chunks for the shaped viewport. Scanning them once and allocating only around actual tabs is below shaping cost and avoids a whole-file projection. 4. **Forward rounding inside a tab is acceptable.** It matches the shipped TUI inverse mapping and avoids inventing fractional positions inside one source byte. 5. **The fixed semantic constant needs no protocol-version change.** This work changes no message representation or negotiation rule. Existing compatible clients remain decodable but must adopt the documented invariant to obtain visual parity. ## Acceptance criteria 1. **Canonical rule:** one exported `TAB_STOP_COLUMNS = 8` definition is shared by the pmacs core and GPU; no renderer-local tab-width literals remain in the touched buffer-rendering paths. 2. **Source preservation:** inserting/opening `"\t"` leaves one tab byte in the buffer, semantic frame, edits, undo history, and saved file. Rendering never replaces source text. 3. **TUI boundary behavior:** tabs beginning at columns 0, 7, and 8 end at columns 8, 8, and 16 respectively in plain rendering and position mapping. 4. **Core overlay parity:** syntax foreground spans, diagnostic underlines, completion popup anchors, and generic buffer-style spans all resolve the same byte boundary after tabs and wide Unicode to the same display column. 5. **GPU shaping input:** code-buffer chunks presented to cosmic-text contain no raw tab from source text or text adornments. The equivalent expanded spaces end at the next logical 8-column boundary. 6. **GPU visual geometry:** for `"\tx"`, `"1234567\tx"`, and `"12345678\tx"`, the GPU lays out `x` at logical columns 8, 8, and 16. A case with a width-2 Unicode character before the tab also lands on the mathematically correct stop. 7. **Caret mapping:** source carets immediately before and after a tab render at the leading and trailing edges of the expanded interval, including when the line wraps near that interval. 8. **Hit testing:** clicking the tab's leading boundary resolves before the tab; clicking within its expanded spaces resolves after it; clicking following text resolves to its original source byte. No click returns a synthetic space offset. 9. **Styled tab:** a source foreground span exactly covering a tab colors every expanded space and does not color the following source character. 10. **Selected/diagnostic tab:** own and peer selections and diagnostic squiggles whose source range covers a tab span the full projected interval and remain aligned with following text. The GPU layout case includes a soft wrap whose boundary falls inside the expanded tab, proving that one source byte produces correct geometry on both visual lines. 11. **Adornment interaction:** an inline text adornment before a source tab contributes to the visible logical column, the tab still ends at the next 8-column stop, and source/adornment hit gravity remains deterministic. 12. **Minimap parity:** leading and interior tabs use 8-column stops; width-2 and zero-width Unicode affect minimap logical columns by 2 and 0 rather than 1. 13. **Edit freshness:** inserting or deleting a tab on a visible line updates shaping, caret position, hit testing, styles/decorations, and minimap shape on the next normal refresh without switching buffers or forcing a full rebuild. 14. **No scope creep:** `pmacs.config` gains no tab-width key, and this work changes no `PROTOCOL_VERSION`, wire message shape, or negotiation rule. 15. **Quality gates:** focused default/Lua 5.4 tests, the touched acceptance suite, both GPU unit and required hardware-backed tests, the standard project gates, workspace sweep, and `git diff --check` pass. ## Verification plan Focused checks should exercise the shared arithmetic and the two real render paths rather than inspecting source text: - core unit tests for valid-prefix handling, Unicode widths, and tab starts at 0/7/8; - existing and extended `TextView`, highlight, diagnostic, completion, and overlay tests using byte ranges that cross tabs; - GPU projection-map tests for tab expansion and both mapping directions; - GPU layout/offscreen tests for caret, selection/diagnostic geometry, a soft-wrap boundary inside an expanded tab, adornments, and edit freshness; - minimap shape tests for tabs and Unicode; and - `tests/tab_width_acceptance.rs` rendering one tabbed fixture through the core-facing path while the GPU suite proves the frontend projection. Required commands before a PR: ```text cargo fmt --check cargo clippy --workspace --all-targets -- -D warnings cargo test --lib cargo test --lib --features crdt cargo test --no-default-features --features lua54 --lib cargo test --test tab_width_acceptance cargo test --test m4_acceptance -- --skip basedpyright PMACS_REQUIRE_GPU=1 cargo test -p pmacs-gpu cargo test --workspace -- --skip basedpyright git diff --check ``` Run strict Clippy as its own command. Any known timing-only failure must be rerun isolated per `docs/agent-handoff.md`; a rerun is evidence only when the failure matches a documented flaky test. ## Expected files - `pmacs-protocol/src/lib.rs` - canonical tab-stop rendering constant and protocol-level documentation. - `src/display_width.rs` and `src/lib.rs` - shared core display-column logic and module export. - `src/text_view.rs`, `src/highlight.rs`, `src/diag.rs`, `src/completion.rs`, and `src/overlay.rs` - remove duplicated arithmetic and consume the helper. - `pmacs-gpu/Cargo.toml`, `Cargo.lock`, and `pmacs-gpu/src/main.rs` - direct Unicode-width dependency, tab projection/provenance, geometry mapping, minimap parity, and focused tests. - `tests/tab_width_acceptance.rs` - focused observable TUI/core parity across plain text and byte-addressed overlays. - `docs/agent-handoff.md`, `docs/active-work.md`, `docs/side-quest-backlog.md`, and this framing document - updated only after implementation is proven and published according to their protocols. No Lua runtime, config-registry, syntax-query, theme, or serialized protocol file should need a behavior change.