Merge pull request #137 from levineuwirth/tab-width-parity

feat(render): unify tab-width projection
This commit is contained in:
Levi Neuwirth 2026-07-22 21:05:07 +00:00 committed by GitHub
commit 2625ec7486
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 1201 additions and 265 deletions

1
Cargo.lock generated
View File

@ -2583,6 +2583,7 @@ dependencies = [
"pmacs-protocol",
"pollster",
"sys-locale",
"unicode-width",
"wgpu",
"winit",
]

View File

@ -14,7 +14,8 @@ backlog.
machine-local: `origin` may name this canonical URL, a release mirror,
or something else, and therefore has no authority by name alone.
- Canonical base at this snapshot:
`githubsucks/main` @ `8cbb9f4` (locals-query processing #134; protocol v18).
`githubsucks/main` @ `40111dc` (landed-state docs after locals-query #134;
protocol v18).
- On the transfer source, `origin/main` named a release mirror at
`d3fa632` and lagged badly. On the current destination, `origin` names
the canonical URL. This difference is why all recovery begins by
@ -48,9 +49,36 @@ git worktree list
git status --short --branch
```
The first command must expose `8cbb9f4` or a newer intentional main.
The first command must expose `40111dc` or a newer intentional main.
If it does not, stop and repair the remote/fetch configuration.
## Tab-width rendering parity lane
- Portable branch: `githubsucks/tab-width-parity`.
- Base: canonical `main` @ `40111dc`; protocol v18.
- Approved framing: `docs/tab-width-parity-framing.md` revision 2; framing
branch head `9f2f0d5`.
- Implementation head: `9f7bc77`.
- State: implementation complete; PR #137 open:
<https://github.com/levineuwirth/pmacs/pull/137>. One fixed 8-column constant now
drives core/TUI columns, GPU code projection, and minimap width. Source bytes
and protocol ranges remain unchanged.
- Verification: `cargo fmt --check`; strict workspace Clippy; 1,763 default,
1,939 CRDT, and 1,763 Lua 5.4 library tests; 2 tab-width acceptance tests;
M4 121 passed (3 ignored, 1 filtered); required GPU 119; workspace 2,911
passed across 83 suites (19 ignored, 1 filtered); `git diff --check`.
- Concurrent PR #135 owns overlapping `Cargo.lock`, `pmacs-protocol/src/lib.rs`,
and `pmacs-gpu/src/main.rs`. This branch deliberately remains based on
canonical `main`; rebase and rerun gates if #135 lands first.
- Recovery:
```sh
git worktree add --track \
-b tab-width-parity \
../pmacs-tab-width-parity \
githubsucks/tab-width-parity
```
## Parked lane: kill-ring browser + persistence
- Portable branch: `githubsucks/kill-ring-browser`

View File

@ -1,8 +1,8 @@
# Agent handoff — cross-machine continuity
**Last updated: 2026-07-22, after locals-query processing (#134) landed on
`main`, following modeline language detection (#132), Vterm Stage 2 (#130),
and mode system wiring (#129/#131). Vterm Stage 3 is not implemented.**
**Last updated: 2026-07-22, with tab-width rendering parity implemented and
open as PR #137, after locals-query processing (#134) landed on `main`.
Vterm Stage 3 remains in review as #135.**
This file is the
bridge between development machines. If you are an agent reading
this on a fresh clone: this document plus the `docs/*-framing.md`
@ -16,9 +16,9 @@ commands, read `docs/active-work.md` immediately after this file.
## 1. Where the project stands (2026-07-22)
- `main` @ `8cbb9f4` (locals-query processing #134), protocol **v18**
(`SUPPORTED=[6..18]`; v16 = `ThemeFacts`, v17 = `FontFacts`, v18 =
`StatuslineSegments`).
- `main` @ `40111dc` (landed-state documentation after locals-query processing
#134), protocol **v18** (`SUPPORTED=[6..18]`; v16 = `ThemeFacts`, v17 =
`FontFacts`, v18 = `StatuslineSegments`).
- **Config registry LANDED — #127** (`docs/config-registry-framing.md`
rev 3; merge `2e37c04`; two review rounds). `pmacs.config` is the
typed, introspectable options registry the backlog ranked first, and
@ -315,6 +315,24 @@ commands, read `docs/active-work.md` immediately after this file.
8 CRDT; M4 114 passed (3 ignored, 1 filtered); required GPU 109;
workspace 2,882 passed across 82 suites (19 ignored, 1 filtered);
`git diff --check` clean.
- **Tab-width rendering parity IMPLEMENTED — PR #137 OPEN**
(`docs/tab-width-parity-framing.md` rev 2; branch `tab-width-parity`;
implementation `9f7bc77`; <https://github.com/levineuwirth/pmacs/pull/137>).
Source tabs remain one byte while every buffer
renderer follows the shared fixed `pmacs_protocol::TAB_STOP_COLUMNS = 8`.
- `src/display_width.rs` owns allocation-free Unicode/tab-aware byte-to-column
accounting for plain text, syntax, diagnostics, completion anchors,
buffer-style overlays, and search washes.
- The GPU rich-chunk projection expands source/adornment tabs before
cosmic-text shaping and retains first-class source-tab provenance.
Carets, hits, selections, peer washes, and diagnostic geometry share the
same source/projected boundary rules, including a soft wrap inside one
expanded tab.
- GPU minimap widths use the same tab/Unicode rule and refresh in the accepted
text-edit transaction. No config, wire shape, negotiation, or protocol
version changed. Local gates: 1,763 default + 1,939 CRDT + 1,763 Lua 5.4
library tests; 2 focused acceptance; M4 121; required GPU 119; workspace
2,911 across 83 suites; strict Clippy and diff check clean.
- **PARKED: kill-ring browser + persistence.** Revision 2 framing is
preserved on branch `kill-ring-browser`, but its `0efb5cd` scout is stale
and must be repeated before implementation. No PR or implementation is
@ -501,19 +519,14 @@ acceptance.
in per-session baselines; and any daemon-side reset needs its
frontend mirror audited in the same round (the GPU snapshot arm
missed search/menu/status the first time).
- **Tab width is a rendering-parity bug, NOT a config gap** (scouted at
`7bc0c61` while framing #127; still true). There are FIVE tab-width
sites across TWO crates with TWO different values: `TAB_WIDTH = 8` in
`src/text_view.rs`, `src/highlight.rs`, `src/diag.rs` and
`src/completion.rs`, versus `advance_minimap_col` in
`pmacs-gpu/src/main.rs` expanding to **4** — and the GPU's main text
path expands tabs *not at all* (buffer bytes reach the frontend raw,
so a literal `\t` is shaped by the font). `editor.tab-width` is
therefore the obvious-looking first config adopter and is not one:
defining the setting cannot make the GPU honor it. Doing it properly
needs frontend tab expansion plus a wire-or-frontend-local decision.
Deferred from #127 on exactly these grounds; don't re-plan it as a
config task.
- **Tab width is a rendering semantic, NOT a config gap.** The implementation
on `tab-width-parity` fixes the width at the TUI's established 8 columns,
shares that constant through `pmacs-protocol`, and expands tabs only in each
display projection. Defining `editor.tab-width` could not have fixed the GPU:
source text and semantic spans stay byte-addressed while cosmic-text needs
projected spaces plus an inverse hit/caret map. A future configurable width
would require a buffer-effective frontend fact and cache invalidation; do not
re-plan it as a scalar config-only change.
- **A test that never runs passes.** Two #127 review-round tests passed
vacuously at first: `pmacs.editor.save()` is the RAW save, while
`buffer.before-save` fires inside the `buffer.save` COMMAND

View File

@ -120,14 +120,12 @@ The direct continuation of the #114#118 grammar/detection stack.
language-aware indent, per-language comment padding, and per-project
compile commands — the last three are now ordinary work, expressed as
a `buffer.after-load` hook calling `set_local`, not blocked work.
- **Tab-width rendering parity** — was listed above as a config
consequence; it is not. `TAB_WIDTH = 8` appears four times in the
daemon (`text_view`, `highlight`, `diag`, `completion`), the GPU
minimap's `advance_minimap_col` uses **4**, and the GPU main text path
expands tabs *not at all* — raw `\t` reaches glyphon and is shaped by
the font. Defining `editor.tab-width` cannot make the GPU honor it;
this needs frontend tab expansion plus a wire-or-frontend-local
decision. Deferred from #127 on those grounds.
- ~~**Tab-width rendering parity**~~**IMPLEMENTED, IN REVIEW AS #137.** One fixed
8-column constant now drives the core/TUI display-column paths, GPU rich-text
projection, and minimap widths. GPU expansion retains source-tab provenance,
so caret, hit, selection, and diagnostic geometry remain byte-correct through
adornments and soft wraps. Source text and protocol ranges remain raw; this
adds no config key or wire change. See `docs/tab-width-parity-framing.md`.
- **Real `read_only` buffer flag** on both edit paths — true immutability
for panels / REPL / generated buffers.
- ~~**Mode system wiring**~~**SHIPPED as #129.** Per-buffer major modes
@ -237,15 +235,12 @@ guides (visual, not color).
## North star (highest-leverage first)
**The original north-star items, mode-system wiring, and locals-query
processing have now shipped** — multi-language injections (#122), the config
registry (#127), JSON + YAML (#123), mode-system wiring (#129), and locals
queries (#134). The remaining board:
1. **Tab-width rendering parity** — five constants across two crates
with two different values, and no tab expansion at all on the GPU
main text path. Explicitly NOT a config-registry task; see the entry
under "Cross-cutting substrate".
**The original north-star items have shipped or reached review** —
multi-language injections (#122), the config registry (#127), JSON + YAML
(#123), mode-system wiring (#129), locals queries (#134), and tab-width
rendering parity (PR #137, review pending). The remaining board now
starts with the broader ranked arcs below rather than another unresolved
cross-frontend rendering invariant.
Beyond those, the cleanest remaining one-shots in the highlight family are the
HTML/CSS grammars that light up more injection *consumers*; modeline detection

View File

@ -0,0 +1,428 @@
# 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.

View File

@ -61,3 +61,4 @@ pmacs-protocol = { version = "1.0.0", path = "../pmacs-protocol" }
pollster = "0.4.0"
wgpu = "29.0.3"
winit = "0.30.13"
unicode-width = "0.2"

View File

@ -40,10 +40,11 @@ use pmacs_protocol::{
InstanceSignal, Key as ProtocolKey, LineNumberMode, MAX_STATUSLINE_FACE_BYTES,
MAX_STATUSLINE_PROVIDERS, MAX_STATUSLINE_SEGMENT_BYTES, MAX_STATUSLINE_TOTAL_TEXT_BYTES,
MenuPromptRow, Modifiers, PointerKind, SelectionSnapshot, StatuslineSegment, StyleSegment,
StyleSpan,
StyleSpan, TAB_STOP_COLUMNS,
cell::{Color as CellColor, Style as CellStyle},
is_builtin_pair_char, is_modeline_face_name,
};
use unicode_width::UnicodeWidthChar;
use wgpu::MultisampleState;
use winit::application::ApplicationHandler;
use winit::event::{ElementState, WindowEvent};
@ -679,9 +680,9 @@ struct State {
current_line_char_starts: Vec<u64>,
/// Code-shape data used to give the minimap horizontal structure
/// even though `FileStyleSummary` carries only one dominant style
/// per line. Refreshed when a new summary lands, keeping this
/// cache in cadence with the debounced minimap data rather than
/// rebuilding it for every typed byte.
/// per line. Summary replacement rebuilds the table; accepted text
/// edits update the affected line immediately (or rebuild after
/// structural/batched edits).
current_line_shapes: Vec<MinimapLineShape>,
/// Local CRDT replica seeded by `BufferSnapshot`. `None` in
/// hello-world mode or before the first snapshot arrives in
@ -2606,6 +2607,7 @@ impl State {
if edits.is_empty() {
return Ok(edits);
}
self.refresh_minimap_shapes_after_edits(&edits, line_count_before);
self.translate_cached_anchors(&edits);
// A newline edit can cross a gutter digit boundary (9 -> 10,
// 99 -> 100). Synchronize the painter-derived code width
@ -2636,6 +2638,36 @@ impl State {
}
}
/// Keep minimap horizontal geometry in lock-step with accepted text
/// edits instead of waiting for the next debounced style summary.
/// The common one-line edit updates one cached shape; line-structure
/// or batched edits rebuild the table because their intermediate
/// coordinates need not describe the final line partition.
fn refresh_minimap_shapes_after_edits(
&mut self,
edits: &[TextProjectionEdit],
line_count_before: usize,
) {
if edits.len() == 1
&& self.current_line_starts.len() == line_count_before
&& self.current_line_shapes.len() == self.current_line_starts.len()
{
let line = self
.current_line_starts
.partition_point(|&start| start <= edits[0].start)
.saturating_sub(1);
let start = self.current_line_starts[line] as usize;
let end = self
.current_line_starts
.get(line + 1)
.map_or(self.current_text.len(), |next| *next as usize - 1);
self.current_line_shapes[line] = minimap_line_shape(&self.current_text[start..end]);
} else {
self.current_line_shapes = minimap_line_shapes(&self.current_text);
}
self.minimap_cache = None;
}
/// Drop journal entries already reflected in a producer frame
/// stamped `generation` — see the `unconfirmed_edits` field docs.
fn prune_unconfirmed_edits(&mut self, generation: u64) {
@ -2705,6 +2737,8 @@ impl State {
let (line_starts, line_char_starts) = line_offset_tables(text);
self.current_line_starts = line_starts;
self.current_line_char_starts = line_char_starts;
self.current_line_shapes = minimap_line_shapes(text);
self.minimap_cache = None;
let geometry_changed = self.sync_buffer_dimensions();
self.reshape();
if geometry_changed && caret_was_painted {
@ -6070,46 +6104,21 @@ impl State {
})
}
/// Map an absolute source `byte` to `(slice line index, projected
/// byte offset within that shaped line)` by inverting the line's
/// `line_chunk_cache` projection (framing Q#F6): source bytes are
/// not projected bytes once inline adornments inject text. An
/// adornment anchor maps to the EARLIEST projected boundary — the
/// current left-gravity caret placement, before the injected
/// text. `None` when the byte's source line is outside the shaped
/// slice.
/// Map an absolute source byte to `(slice line index, projected
/// byte offset within that shaped line)` through the same reusable
/// chunk mapping used by decoration geometry.
/// Adornments retain left gravity, while a source tab's two byte
/// boundaries map to the leading and trailing edges of all of its
/// projected spaces.
fn code_byte_to_projected(&self, byte: u64) -> Option<(usize, usize)> {
let line_idx = self
.current_line_starts
.partition_point(|&s| s <= byte)
.saturating_sub(1);
let slice_i = line_idx.checked_sub(self.shaped_top)?;
if slice_i >= self.line_chunk_cache.len() {
return None;
}
let chunks = self.line_chunk_cache.get(slice_i)?;
let rel = byte - self.current_line_starts[line_idx];
let mut projected = 0usize;
for chunk in &self.line_chunk_cache[slice_i] {
match chunk.source {
ChunkSource::Source { start } => {
let len = chunk.text.len() as u64;
if rel >= start && rel < start + len {
return Some((slice_i, projected + (rel - start) as usize));
}
}
ChunkSource::Adornment { anchor } => {
// Source chunks tile the line, so reaching an
// adornment chunk unmatched means the byte sits at
// its anchor boundary (or past line end).
if rel <= anchor {
return Some((slice_i, projected));
}
}
}
projected += chunk.text.len();
}
// Line end (the `\n` position, or EOF).
Some((slice_i, projected))
source_to_projected(chunks, rel).map(|projected| (slice_i, projected as usize))
}
/// Convert an absolute source byte to a cursor cosmic-text can
@ -6255,11 +6264,11 @@ impl State {
}
/// Push one rect per visual line whose glyphs overlap the
/// buffer-absolute byte range `[lo, hi)`, spanning the matching
/// glyphs' horizontal extent. A range crossing visual-line
/// boundaries (wrapped or multi-line) fans out into one rect per
/// run. `line_offsets[run.line_i]` rebases the run's line-relative
/// glyph offsets into buffer-absolute space for the comparison.
/// slice-relative source byte range `[lo, hi)`, spanning the
/// matching projected glyphs' horizontal extent. Each source-line
/// intersection is mapped through its cached chunks first, so a
/// source tab covers every expanded space even when a soft wrap
/// divides those spaces between visual runs.
fn push_glyph_extent_rects(
&self,
rects: &mut Vec<MinimapRect>,
@ -6276,12 +6285,30 @@ impl State {
let text_left = self.text_left();
for run in self.buffer.layout_runs() {
let line_base = line_offsets.get(run.line_i).copied().unwrap_or(0);
let line_end = line_offsets
.get(run.line_i + 1)
.copied()
.unwrap_or(self.view_range.1 - self.view_range.0);
let source_lo = lo.max(line_base);
let source_hi = hi.min(line_end);
if source_hi <= source_lo {
continue;
}
let Some(chunks) = self.line_chunk_cache.get(run.line_i) else {
continue;
};
let Some(projected_lo) = source_to_projected(chunks, source_lo - line_base) else {
continue;
};
let Some(projected_hi) = source_to_projected(chunks, source_hi - line_base) else {
continue;
};
let mut min_x: Option<f32> = None;
let mut max_x: Option<f32> = None;
for glyph in run.glyphs {
let g_start = line_base + glyph.start as u64;
let g_end = line_base + glyph.end as u64;
if g_end <= lo || g_start >= hi {
let g_start = glyph.start as u64;
let g_end = glyph.end as u64;
if g_end <= projected_lo || g_start >= projected_hi {
continue;
}
let x0 = glyph.x;
@ -6343,6 +6370,8 @@ struct RichChunk {
enum ChunkSource {
/// Verbatim source text starting at this slice byte offset.
Source { start: u64 },
/// One source tab byte expanded into one or more projected spaces.
SourceTab { start: u64 },
/// Injected adornment text (inlay hint) anchored at this slice
/// byte offset. Hits inside it snap to the anchor.
Adornment { anchor: u64 },
@ -6383,9 +6412,10 @@ fn build_hit_runs(chunks: &[RichChunk]) -> (Vec<ProjectedRun>, Vec<u64>) {
(runs, line_starts)
}
/// Map a projected byte offset back to a slice-relative source byte
/// (Q#M2). Hits inside an adornment run snap to its anchor; offsets
/// past the last run clamp to its end.
/// Map a projected byte offset back to a slice-relative source byte.
/// A source tab's leading boundary maps before the byte; every
/// boundary inside its expanded spaces (including the trailing edge)
/// maps after it. Adornments snap to their left-gravity anchor.
fn projected_to_source(runs: &[ProjectedRun], projected: u64) -> Option<u64> {
if runs.is_empty() {
return None;
@ -6397,10 +6427,48 @@ fn projected_to_source(runs: &[ProjectedRun], projected: u64) -> Option<u64> {
let within = projected.saturating_sub(run.projected_start).min(run.len);
match run.source {
ChunkSource::Source { start } => Some(start + within),
ChunkSource::SourceTab { start } => Some(start + u64::from(within > 0)),
ChunkSource::Adornment { anchor } => Some(anchor),
}
}
/// Map a slice-relative source boundary into projected byte space.
/// This is the inverse boundary policy shared by caret placement and
/// horizontal decoration geometry. At an adornment anchor the earliest
/// projected boundary wins, preserving left gravity.
fn source_to_projected(chunks: &[RichChunk], source: u64) -> Option<u64> {
let mut projected = 0u64;
for chunk in chunks {
let len = chunk.text.len() as u64;
match chunk.source {
ChunkSource::Source { start } => {
if source <= start {
return Some(projected);
}
let end = start + len;
if source <= end {
return Some(projected + source - start);
}
}
ChunkSource::SourceTab { start } => {
if source <= start {
return Some(projected);
}
if source <= start + 1 {
return Some(projected + len);
}
}
ChunkSource::Adornment { anchor } => {
if source <= anchor {
return Some(projected);
}
}
}
projected += len;
}
(!chunks.is_empty()).then_some(projected)
}
fn minimap_left(surface_width: u32) -> Option<f32> {
if surface_width < MINIMAP_MIN_SURFACE_WIDTH {
return None;
@ -6727,9 +6795,10 @@ fn minimap_line_shape(line: &str) -> MinimapLineShape {
fn advance_minimap_col(col: usize, ch: char) -> usize {
if ch == '\t' {
((col / 4) + 1) * 4
let tab_stop = TAB_STOP_COLUMNS as usize;
col + tab_stop - col % tab_stop
} else {
col + 1
col + UnicodeWidthChar::width(ch).unwrap_or(0)
}
}
@ -7666,7 +7735,89 @@ fn projected_rich_chunks(
source: ChunkSource::Source { start: 0 },
});
}
chunks
expand_chunk_tabs(chunks)
}
/// Expand display tabs after source styling and adornment insertion.
/// Chunks without tabs are moved through unchanged. A chunk containing
/// tabs is split only at those bytes; every emitted space keeps the
/// original color, while source tabs gain explicit provenance.
fn expand_chunk_tabs(chunks: Vec<RichChunk>) -> Vec<RichChunk> {
let mut expanded = Vec::with_capacity(chunks.len());
let mut column = 0usize;
for chunk in chunks {
if !chunk.text.contains('\t') {
advance_display_column(&mut column, &chunk.text);
expanded.push(chunk);
continue;
}
let RichChunk {
text,
color,
source,
} = chunk;
let mut segment_start = 0usize;
for (byte, ch) in text.char_indices() {
if ch != '\t' {
continue;
}
if segment_start < byte {
let segment = &text[segment_start..byte];
advance_display_column(&mut column, segment);
expanded.push(RichChunk {
text: segment.to_owned(),
color,
source: offset_chunk_source(source, segment_start as u64),
});
}
let tab_stop = TAB_STOP_COLUMNS as usize;
let tab_width = tab_stop - column % tab_stop;
expanded.push(RichChunk {
text: " ".repeat(tab_width),
color,
source: match source {
ChunkSource::Source { start } => ChunkSource::SourceTab {
start: start + byte as u64,
},
ChunkSource::Adornment { anchor } => ChunkSource::Adornment { anchor },
ChunkSource::SourceTab { start } => ChunkSource::SourceTab { start },
},
});
column += tab_width;
segment_start = byte + 1;
}
if segment_start < text.len() {
let segment = &text[segment_start..];
advance_display_column(&mut column, segment);
expanded.push(RichChunk {
text: segment.to_owned(),
color,
source: offset_chunk_source(source, segment_start as u64),
});
}
}
expanded
}
fn offset_chunk_source(source: ChunkSource, byte_offset: u64) -> ChunkSource {
match source {
ChunkSource::Source { start } => ChunkSource::Source {
start: start + byte_offset,
},
ChunkSource::SourceTab { start } => ChunkSource::SourceTab { start },
ChunkSource::Adornment { anchor } => ChunkSource::Adornment { anchor },
}
}
fn advance_display_column(column: &mut usize, text: &str) {
for ch in text.chars() {
if ch == '\n' {
*column = 0;
} else {
*column += UnicodeWidthChar::width(ch).unwrap_or(0);
}
}
}
fn renderable_adornment_anchor(adornment: &InlineAdornment, text_len: u64) -> Option<u64> {
@ -8564,6 +8715,109 @@ mod tests {
assert_eq!(projected_to_source(&[], 0), None);
}
#[test]
fn tab_projection_uses_shared_stops_and_unicode_columns() {
let projected = |text: &str| {
projected_rich_chunks(text, &[], &[])
.into_iter()
.map(|chunk| chunk.text)
.collect::<String>()
};
assert_eq!(projected("\t"), " ", "column 0 advances to 8");
assert_eq!(projected("1234567\t"), "1234567 ", "column 7 advances to 8");
assert_eq!(
projected("12345678\t"),
"12345678 ",
"column 8 advances to 16"
);
assert_eq!(
projected("\t\n\u{301}\t"),
"\n\u{301} ",
"wide scalars count as two, zero-width scalars as zero, and newline resets"
);
}
#[test]
fn tab_projection_preserves_source_and_adornment_provenance_and_style() {
let red = CellColor::Rgb(255, 0, 0);
let chunks = projected_rich_chunks(
"1234567\tX",
&[span(7, 8, red)],
&[adornment(0, AdornmentPlacement::AtOffset, "\t")],
);
assert_eq!(
chunks
.iter()
.map(|chunk| chunk.text.as_str())
.collect::<String>(),
" 1234567 X",
"the adornment tab participates in the same logical column stream"
);
let source_tab = chunks
.iter()
.find(|chunk| matches!(chunk.source, ChunkSource::SourceTab { start: 7 }))
.expect("source tab has a first-class projected run");
assert_eq!(source_tab.text, " ");
assert_eq!(source_tab.color, cell_color_to_glyphon(red));
assert!(
chunks.iter().any(
|chunk| matches!(chunk.source, ChunkSource::Adornment { anchor: 0 })
&& chunk.text == " "
),
"adornment tabs expand without pretending to be source bytes"
);
}
#[test]
fn tab_projection_moves_chunks_without_tabs_unchanged() {
let text = String::from("wide 界 and plain");
let allocation = text.as_ptr();
let chunks = expand_chunk_tabs(vec![RichChunk {
text,
color: None,
source: ChunkSource::Source { start: 0 },
}]);
assert_eq!(chunks.len(), 1);
assert_eq!(chunks[0].text.as_ptr(), allocation);
}
#[test]
fn source_tab_projection_boundaries_are_bidirectional() {
let chunks = projected_rich_chunks("\tX", &[], &[]);
let (runs, _) = build_hit_runs(&chunks);
assert_eq!(source_to_projected(&chunks, 0), Some(0));
assert_eq!(source_to_projected(&chunks, 1), Some(8));
assert_eq!(source_to_projected(&chunks, 2), Some(9));
assert_eq!(projected_to_source(&runs, 0), Some(0));
for projected in 1..=8 {
assert_eq!(
projected_to_source(&runs, projected),
Some(1),
"projected boundary {projected} inside the tab maps after its source byte"
);
}
assert_eq!(projected_to_source(&runs, 9), Some(2));
}
#[test]
fn adornment_tab_keeps_left_gravity_in_source_mapping() {
let chunks = projected_rich_chunks(
"X",
&[],
&[adornment(0, AdornmentPlacement::AtOffset, "\t")],
);
let (runs, _) = build_hit_runs(&chunks);
assert_eq!(source_to_projected(&chunks, 0), Some(0));
assert_eq!(source_to_projected(&chunks, 1), Some(9));
for projected in 0..8 {
assert_eq!(projected_to_source(&runs, projected), Some(0));
}
}
#[test]
fn optimistic_delete_range_covers_single_codepoints_only() {
let none = Modifiers::NONE;
@ -9125,6 +9379,27 @@ mod tests {
);
}
#[test]
fn minimap_columns_match_code_tab_and_unicode_widths() {
assert_eq!(
minimap_line_shapes("\tX\n1234567\tX\n\u{301}\tX"),
vec![
MinimapLineShape {
indent_cols: 8,
content_cols: 1,
},
MinimapLineShape {
indent_cols: 0,
content_cols: 9,
},
MinimapLineShape {
indent_cols: 0,
content_cols: 9,
},
]
);
}
#[test]
fn minimap_rects_encode_six_vertices_per_quad() {
let rect = MinimapRect {
@ -11581,6 +11856,141 @@ mod tests {
);
}
#[test]
fn source_tab_caret_uses_projected_leading_and_trailing_boundaries() {
let Some(mut state) = headless_or_skip(320, 240, "\tX") else {
return;
};
let bid = BufferId::next();
state.current_buffer_id = Some(bid);
state.reshape();
state.own_cursor = Some(OwnCursor {
buffer_id: bid,
byte: 0,
});
let before = state.caret_rect().expect("caret before tab").x;
state.own_cursor = Some(OwnCursor {
buffer_id: bid,
byte: 1,
});
let after = state.caret_rect().expect("caret after tab").x;
assert!(
after - before > 7.0 * state.mono_advance(),
"one source byte must span all eight projected spaces"
);
}
#[test]
fn source_tab_hit_testing_uses_projected_space_boundaries() {
let Some(mut state) = headless_or_skip(320, 240, "\tX") else {
return;
};
state.current_buffer_id = Some(BufferId::next());
state.reshape();
let advance = state.mono_advance();
let y = f64::from(TEXT_TOP + state.fm.code_line_height() / 2.0);
assert_eq!(
state.hit_test_source_byte(f64::from(state.text_left()), y),
Some(0),
"the projected leading edge maps before the source tab"
);
assert_eq!(
state.hit_test_source_byte(f64::from(state.text_left() + advance * 2.5), y,),
Some(1),
"a hit inside the expanded spaces maps after the source tab"
);
}
#[test]
fn tab_decoration_geometry_covers_spaces_split_by_soft_wrap() {
let Some(mut state) = headless_or_skip(64, 240, "\tX") else {
return;
};
let bid = BufferId::next();
state.current_buffer_id = Some(bid);
state.current_decorations = vec![Decoration {
range: ByteRange { start: 0, end: 1 },
kind: DecorationKind::Selection,
}];
state.reshape();
assert!(
state
.buffer
.layout_runs()
.filter(|run| run.line_i == 0)
.count()
> 1,
"precondition: the eight projected spaces wrap"
);
let line_offsets = line_byte_offsets(&state.current_text);
let mut rects = Vec::new();
state.collect_own_decoration_rects(
&mut rects,
&line_offsets,
state.view_range.0,
state.view_range.1,
);
assert!(
rects.len() > 1,
"the source tab selection must fan out across wrapped visual runs"
);
assert!(
rects.iter().all(|rect| rect.w > 0.0),
"every wrapped piece must retain horizontal geometry"
);
}
#[test]
fn visible_line_tab_edit_refreshes_cached_projection() {
let Some(mut state) = headless_or_skip(320, 240, "aX") else {
return;
};
state.minimap_cache = Some(((0, 0, 0, 0), vec![1]));
let edits = state
.apply_loro_text_delta_batches(&[vec![
loro::TextDelta::Retain {
retain: 1,
attributes: None,
},
loro::TextDelta::Insert {
insert: "\t".to_owned(),
attributes: None,
},
]])
.expect("visible edit applies");
assert_eq!(
edits,
vec![TextProjectionEdit {
start: 1,
old_end: 1,
inserted_len: 1,
}]
);
assert_eq!(state.buffer.lines[0].text(), "a X");
assert_eq!(
state.current_line_shapes[0],
MinimapLineShape {
indent_cols: 0,
content_cols: 9,
},
"the minimap shape must refresh in the same edit transaction"
);
assert!(
state.minimap_cache.is_none(),
"text geometry changes must invalidate cached minimap vertices"
);
assert!(
state.line_chunk_cache[0]
.iter()
.any(|chunk| matches!(chunk.source, ChunkSource::SourceTab { start: 1 })),
"the incremental code-line cache must immediately carry tab provenance"
);
}
/// Acceptance 11 — the `CursorByte` arm follows into a wrapped
/// continuation run (the pre-existing source-line-only hole): the
/// follow lands as a sub-line residual, normalized to slice-local

View File

@ -17,6 +17,8 @@
//! - The full message envelopes: `InstanceMessage`, `FrontendEvent`,
//! `GoodbyeReason`, capability structs, `PresenceUpdate`, etc.
//! - The optional `CrdtOp` wire variant (feature-gated on `crdt`).
//! - [`TAB_STOP_COLUMNS`], the shared logical width used when frontends
//! project raw buffer tabs for display.
//!
//! What does NOT live here:
//! - `crate::cell::CellGrid` and `crate::cell::diff()` (rendering
@ -40,6 +42,12 @@ pub mod ids;
pub mod message;
pub mod transport;
/// Logical display columns between fixed buffer-text tab stops.
///
/// Semantic frames keep tabs as source bytes; every frontend expands them
/// only in its display projection so protocol byte ranges remain unchanged.
pub const TAB_STOP_COLUMNS: u32 = 8;
pub use cell::{
Attachment, Cell, CellCoord, CellSize, Color, DiffSpan, Glyph, Style, UnderlineStyle,
};

View File

@ -32,6 +32,7 @@ use unicode_width::UnicodeWidthChar;
use crate::buffer::{Buffer, BufferId};
use crate::cell::{CellCoord, CellGrid, Color, Glyph, Style};
use crate::display_width::byte_to_column;
use crate::rope::Position;
use crate::view::{View, Viewport};
@ -589,10 +590,6 @@ pub(crate) const POPUP_MAX_ROWS: u32 = 10;
/// Minimum popup width in cells (glyph column + a readable label).
const POPUP_MIN_WIDTH: u32 = 12;
/// Tab-stop width in display columns, matching [`crate::diag`] /
/// [`crate::text_view`].
const TAB_WIDTH: u32 = 8;
/// Style for the currently-selected row (reverse video so it pops on
/// any base palette).
fn selected_style() -> Style {
@ -663,21 +660,9 @@ impl CompletionView {
}
}
/// Display column of `byte_end` within `line_bytes` (tab-aware,
/// UTF-8-aware). The completion twin of the diagnostic underline's
/// column resolution.
/// Display column of `byte_end` within `line_bytes`.
fn display_col_for_byte(line_bytes: &[u8], byte_end: u32) -> u32 {
let end = (byte_end as usize).min(line_bytes.len());
let text = String::from_utf8_lossy(&line_bytes[..end]);
let mut col = 0u32;
for ch in text.chars() {
if ch == '\t' {
col += TAB_WIDTH - (col % TAB_WIDTH);
} else {
col += char_display_width(ch);
}
}
col
byte_to_column(line_bytes, byte_end as usize)
}
/// Resolved popup rectangle, in window-relative cells.
@ -811,7 +796,7 @@ fn paint_popup_row(
if col >= width {
break;
}
let cw = char_display_width(ch);
let cw = UnicodeWidthChar::width(ch).unwrap_or(0) as u32;
if cw == 0 {
continue;
}
@ -879,10 +864,6 @@ impl View for CompletionView {
}
}
fn char_display_width(ch: char) -> u32 {
UnicodeWidthChar::width(ch).unwrap_or(0) as u32
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

View File

@ -32,10 +32,10 @@ use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use serde_json::Value;
use unicode_width::UnicodeWidthChar;
use crate::buffer::Buffer;
use crate::cell::{CellCoord, CellGrid, Color, Glyph, Style, UnderlineStyle};
use crate::display_width::byte_range_to_columns;
use crate::overlay::merge_styles;
use crate::view::{View, Viewport};
@ -388,10 +388,6 @@ pub fn make_shared_store() -> SharedDiagStore {
// View
// ---------------------------------------------------------------------------
/// Tab-stop width in display columns, matching
/// [`crate::text_view`] and [`crate::highlight`].
const TAB_WIDTH: u32 = 8;
/// The RESOLVED severity color (themes arc Q#TH5): the `ui.diag.*`
/// face's `fg` when a face is set with a concrete color, else the
/// built-in [`DiagnosticSeverity::underline_color`]. The diag family
@ -665,8 +661,7 @@ fn paint_line_markers(
}
// ---------------------------------------------------------------------------
// Shared helpers (mirror highlight.rs; kept private here to avoid
// cross-module coupling on internal helpers)
// Line lookup helpers shared with the completion overlay.
// ---------------------------------------------------------------------------
pub(crate) fn compute_line_offsets(source: &[u8]) -> Vec<u32> {
@ -699,40 +694,10 @@ pub(crate) fn line_at_offset(line_offsets: &[u32], offset: u32) -> u32 {
fn underline_cols_for_line(line_bytes: &[u8], byte_start: u32, byte_end: u32) -> (u32, u32) {
if byte_end <= byte_start {
let (anchor, _) =
byte_range_to_display_cols(line_bytes, byte_start as usize, byte_start as usize);
byte_range_to_columns(line_bytes, byte_start as usize, byte_start as usize);
(anchor, anchor + 1)
} else {
byte_range_to_display_cols(line_bytes, byte_start as usize, byte_end as usize)
}
}
pub(crate) fn byte_range_to_display_cols(
line_bytes: &[u8],
byte_start: usize,
byte_end: usize,
) -> (u32, u32) {
let bs = byte_start.min(line_bytes.len());
let be = byte_end.min(line_bytes.len());
let display_to = |upto: usize| -> u32 {
let mut take = upto.min(line_bytes.len());
while take > 0 && std::str::from_utf8(&line_bytes[..take]).is_err() {
take -= 1;
}
let s = std::str::from_utf8(&line_bytes[..take]).unwrap_or("");
let mut col: u32 = 0;
for ch in s.chars() {
col += char_display_width(ch, col);
}
col
};
(display_to(bs), display_to(be))
}
fn char_display_width(ch: char, current_col: u32) -> u32 {
if ch == '\t' {
TAB_WIDTH - (current_col % TAB_WIDTH)
} else {
UnicodeWidthChar::width(ch).unwrap_or(0) as u32
byte_range_to_columns(line_bytes, byte_start as usize, byte_end as usize)
}
}

106
src/display_width.rs Normal file
View File

@ -0,0 +1,106 @@
// display_width.rs --- Shared byte-to-display-column accounting.
//! Allocation-free display-column helpers shared by text renderers.
//!
//! Source positions remain byte-addressed. Tabs are expanded only while
//! projecting those bytes into display columns, using the protocol-wide tab
//! stop. Offsets are clamped to the supplied slice and offsets inside a UTF-8
//! code point resolve to the preceding complete-code-point boundary.
use unicode_width::UnicodeWidthChar;
/// Advance `column` past one character.
///
/// A tab reaches the next protocol tab stop; all other characters use their
/// Unicode terminal width. Control and zero-width characters do not advance.
#[must_use]
pub fn advance_char(column: u32, ch: char) -> u32 {
let width = if ch == '\t' {
pmacs_protocol::TAB_STOP_COLUMNS - (column % pmacs_protocol::TAB_STOP_COLUMNS)
} else {
UnicodeWidthChar::width(ch).unwrap_or(0) as u32
};
column.saturating_add(width)
}
/// Display width of the valid UTF-8 prefix of `bytes`.
///
/// Invalid input is conservatively truncated at the first invalid byte. This
/// also floors a trailing partial code point without allocating or replacing
/// source bytes.
#[must_use]
pub fn valid_prefix_width(bytes: &[u8]) -> u32 {
let valid_len = match std::str::from_utf8(bytes) {
Ok(_) => bytes.len(),
Err(error) => error.valid_up_to(),
};
let text = std::str::from_utf8(&bytes[..valid_len]).expect("valid_up_to is a UTF-8 boundary");
text.chars().fold(0, advance_char)
}
/// Display column at the clamped byte boundary `offset`.
///
/// If `offset` splits a code point, the result is the column at that code
/// point's leading boundary.
#[must_use]
pub fn byte_to_column(bytes: &[u8], offset: usize) -> u32 {
valid_prefix_width(&bytes[..offset.min(bytes.len())])
}
/// Display-column endpoints for the half-open byte range `[start, end)`.
///
/// Each endpoint is independently clamped and conservatively floored to a
/// complete UTF-8 boundary.
#[must_use]
pub fn byte_range_to_columns(bytes: &[u8], start: usize, end: usize) -> (u32, u32) {
(byte_to_column(bytes, start), byte_to_column(bytes, end))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tabs_advance_at_zero_before_stop_and_on_stop() {
assert_eq!(advance_char(0, '\t'), 8);
assert_eq!(advance_char(7, '\t'), 8);
assert_eq!(advance_char(8, '\t'), 16);
}
#[test]
fn unicode_widths_include_wide_and_zero_width_characters() {
assert_eq!(advance_char(3, '中'), 5);
assert_eq!(advance_char(3, '\u{301}'), 3);
assert_eq!(valid_prefix_width("a中\u{301}b".as_bytes()), 4);
}
#[test]
fn byte_columns_clamp_and_floor_partial_or_invalid_utf8() {
let text = "a中b".as_bytes();
assert_eq!(byte_to_column(text, 0), 0);
assert_eq!(byte_to_column(text, 1), 1);
assert_eq!(byte_to_column(text, 2), 1);
assert_eq!(byte_to_column(text, 3), 1);
assert_eq!(byte_to_column(text, 4), 3);
assert_eq!(byte_to_column(text, usize::MAX), 4);
assert_eq!(valid_prefix_width(b"ab\xffcd"), 2);
assert_eq!(byte_to_column(b"ab\xe2\x82", 4), 2);
}
#[test]
fn byte_ranges_map_half_open_endpoints_with_tab_expansion() {
let text = b"a\tb";
assert_eq!(byte_range_to_columns(text, 0, 1), (0, 1));
assert_eq!(byte_range_to_columns(text, 1, 2), (1, 8));
assert_eq!(byte_range_to_columns(text, 2, 3), (8, 9));
assert_eq!(byte_range_to_columns(text, 99, 99), (9, 9));
}
#[test]
fn range_boundaries_inside_codepoints_are_floored() {
let text = "a中b".as_bytes();
assert_eq!(byte_range_to_columns(text, 2, 3), (1, 1));
assert_eq!(byte_range_to_columns(text, 2, 4), (1, 3));
}
}

View File

@ -33,10 +33,9 @@
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use unicode_width::UnicodeWidthChar;
use crate::buffer::Buffer;
use crate::cell::{CellCoord, CellGrid, Color, Style, UnderlineStyle};
use crate::display_width::byte_range_to_columns;
use crate::lsp::SharedLspManager;
use crate::overlay::merge_styles;
use crate::syntax::{HighlightSpan, ParseTreeBundle, ParseViewHandle, compute_highlight_spans_for};
@ -316,10 +315,6 @@ impl HighlightCache {
}
}
/// Tab-stop width in display columns (must match
/// [`crate::text_view`]; both views write into the same cell grid).
const TAB_WIDTH: u32 = 8;
/// View that renders syntax highlighting from a tree-sitter parse
/// tree, including its injection layers. Composes over
/// [`crate::text_view::TextView`] per the M2.9 view-composition
@ -482,7 +477,7 @@ impl View for SyntaxHighlightView {
let byte_col_start = (s_start - line_start) as usize;
let byte_col_end = (s_end - line_start) as usize;
let (start_col, end_col) =
byte_range_to_display_cols(line_bytes, byte_col_start, byte_col_end);
byte_range_to_columns(line_bytes, byte_col_start, byte_col_end);
if end_col <= start_col {
continue;
}
@ -526,40 +521,6 @@ fn line_at_offset(line_offsets: &[u32], offset: u32) -> u32 {
}
}
/// Convert a half-open byte-column range `[byte_start, byte_end)`
/// inside `line_bytes` to a display-column range. UTF-8 aware; tabs
/// expand to the next [`TAB_WIDTH`]-aligned column. Bytes that don't
/// form complete codepoints (because the byte range falls inside a
/// multi-byte char) are skipped, matching
/// [`crate::text_view::TextView::pos_to_display`]'s conservative
/// rounding.
fn byte_range_to_display_cols(line_bytes: &[u8], byte_start: usize, byte_end: usize) -> (u32, u32) {
let bs = byte_start.min(line_bytes.len());
let be = byte_end.min(line_bytes.len());
let display_to = |upto: usize| -> u32 {
// Drop trailing bytes that don't form complete codepoints.
let mut take = upto.min(line_bytes.len());
while take > 0 && std::str::from_utf8(&line_bytes[..take]).is_err() {
take -= 1;
}
let s = std::str::from_utf8(&line_bytes[..take]).unwrap_or("");
let mut col: u32 = 0;
for ch in s.chars() {
col += char_display_width(ch, col);
}
col
};
(display_to(bs), display_to(be))
}
fn char_display_width(ch: char, current_col: u32) -> u32 {
if ch == '\t' {
TAB_WIDTH - (current_col % TAB_WIDTH)
} else {
UnicodeWidthChar::width(ch).unwrap_or(0) as u32
}
}
// ---------------------------------------------------------------------------
// Style equality helper
// ---------------------------------------------------------------------------
@ -737,7 +698,7 @@ impl View for LspStyleView {
if end_b <= start_b {
continue;
}
let (start_col, end_col) = byte_range_to_display_cols(line_bytes, start_b, end_b);
let (start_col, end_col) = byte_range_to_columns(line_bytes, start_b, end_b);
if end_col <= start_col {
continue;
}
@ -944,9 +905,9 @@ mod tests {
fn byte_range_display_cols_ascii_round_trips() {
let line = b"hello world";
// "hello" → cols 0..5
assert_eq!(byte_range_to_display_cols(line, 0, 5), (0, 5));
assert_eq!(byte_range_to_columns(line, 0, 5), (0, 5));
// "world" → cols 6..11
assert_eq!(byte_range_to_display_cols(line, 6, 11), (6, 11));
assert_eq!(byte_range_to_columns(line, 6, 11), (6, 11));
}
#[test]
@ -954,15 +915,15 @@ mod tests {
let line = b"\tx";
// The full line: tab (0..8) + 'x' (8..9). Byte cols 0..2
// map to display cols 0..9.
assert_eq!(byte_range_to_display_cols(line, 0, 2), (0, 9));
assert_eq!(byte_range_to_columns(line, 0, 2), (0, 9));
// Just the tab.
assert_eq!(byte_range_to_display_cols(line, 0, 1), (0, 8));
assert_eq!(byte_range_to_columns(line, 0, 1), (0, 8));
}
#[test]
fn byte_range_display_cols_clamps_past_end() {
let line = b"hi";
assert_eq!(byte_range_to_display_cols(line, 0, 999), (0, 2));
assert_eq!(byte_range_to_columns(line, 0, 999), (0, 2));
}
#[test]

View File

@ -70,6 +70,7 @@ pub mod daemon_attach;
pub mod definition;
pub mod desktop;
pub mod diag;
pub mod display_width;
pub mod document_highlight;
pub mod editor;
pub mod editor_core;

View File

@ -43,10 +43,9 @@
use std::sync::{Arc, Mutex};
use unicode_width::UnicodeWidthChar;
use crate::buffer::Buffer;
use crate::cell::{Cell, CellCoord, CellGrid, Style};
use crate::display_width::byte_range_to_columns;
use crate::rope::Edit;
use crate::view::{View, Viewport};
@ -366,30 +365,6 @@ fn line_end(buf: &Buffer, line_offsets: &[u64], line: usize) -> u64 {
}
}
fn display_col_for_range(buf: &Buffer, start: u64, end: u64) -> u32 {
if end <= start {
return 0;
}
let mut bytes = vec![0u8; (end - start) as usize];
buf.snapshot_rope().slice(start, end, &mut bytes);
while !bytes.is_empty() && std::str::from_utf8(&bytes).is_err() {
bytes.pop();
}
let Ok(s) = std::str::from_utf8(&bytes) else {
return 0;
};
let mut col = 0;
for ch in s.chars() {
let width = if ch == '\t' {
8 - (col % 8)
} else {
UnicodeWidthChar::width(ch).unwrap_or(0) as u32
};
col += width;
}
col
}
fn render_buffer_style_span(
buf: &Buffer,
line_offsets: &[u64],
@ -418,8 +393,14 @@ fn render_buffer_style_span(
if style_start >= style_end {
continue;
}
let start_col = display_col_for_range(buf, line_start, style_start);
let end_col = display_col_for_range(buf, line_start, style_end);
let mut line_prefix = vec![0; (style_end - line_start) as usize];
buf.snapshot_rope()
.slice(line_start, style_end, &mut line_prefix);
let (start_col, end_col) = byte_range_to_columns(
&line_prefix,
(style_start - line_start) as usize,
line_prefix.len(),
);
let start_col = start_col.min(viewport.cell_size.cols);
let end_col = end_col.min(viewport.cell_size.cols);
for col in start_col..end_col {

View File

@ -21,6 +21,7 @@ use std::sync::{Arc, Mutex};
use pmacs_protocol::ByteRange;
use crate::buffer::BufferId;
use crate::display_width::byte_range_to_columns;
/// One buffer's search state: the resolved query, its matches (byte
/// ranges, ascending and non-overlapping), and the active index.
@ -516,7 +517,7 @@ impl View for SearchView {
let within_start = (paint_start - line_start) as usize;
let within_end = (paint_end - line_start) as usize;
let (start_col, end_col) =
crate::diag::byte_range_to_display_cols(line_bytes, within_start, within_end);
byte_range_to_columns(line_bytes, within_start, within_end);
if end_col <= start_col {
continue;
}

View File

@ -19,10 +19,9 @@
//! Main thread only. The view is held inside a [`Buffer`], which is itself
//! main-only.
use unicode_width::UnicodeWidthChar;
use crate::buffer::{Buffer, BufferError};
use crate::cell::{Cell, CellCoord, CellGrid, Glyph, Style};
use crate::display_width::{advance_char, valid_prefix_width};
use crate::rope::{Edit, Position};
use crate::view::{DisplayCoord, View, Viewport};
@ -30,28 +29,10 @@ use crate::view::{DisplayCoord, View, Viewport};
// Tuning
// ---------------------------------------------------------------------------
/// Tab stop width in display columns. A `\t` advances to the next column
/// that is a multiple of this value.
const TAB_WIDTH: u32 = 8;
/// Line-prefix lengths up to this many bytes are decoded on the stack in
/// [`TextView::pos_to_display`]; longer prefixes fall back to a heap buffer.
const STACK_CAP: usize = 256;
/// Display width of `ch` when drawn starting at column `current_col`.
///
/// Tabs expand to the next [`TAB_WIDTH`]-aligned column, so they need the
/// running column to compute width. Everything else delegates to
/// [`UnicodeWidthChar`]: control characters return 0 (skipped by the
/// caller), printable characters return 1, wide characters return 2.
fn char_display_width(ch: char, current_col: u32) -> u32 {
if ch == '\t' {
TAB_WIDTH - (current_col % TAB_WIDTH)
} else {
UnicodeWidthChar::width(ch).unwrap_or(0) as u32
}
}
// ---------------------------------------------------------------------------
// TextView
// ---------------------------------------------------------------------------
@ -196,19 +177,7 @@ impl View for TextView {
&mut heap_buf
};
buf.snapshot_rope().slice(line_start, pos, bytes);
// If `pos` fell inside a multi-byte codepoint, keep only the bytes up to
// the last complete codepoint. `valid_up_to()` gives that boundary in
// one step, replacing the old pop-one-byte-and-revalidate loop. (Only
// trailing bytes can be invalid here, since the slice is a prefix of
// valid UTF-8 cut at `pos`.)
let s = match std::str::from_utf8(bytes) {
Ok(valid) => valid,
Err(e) => std::str::from_utf8(&bytes[..e.valid_up_to()]).unwrap(),
};
let mut col: u32 = 0;
for ch in s.chars() {
col += char_display_width(ch, col);
}
let col = valid_prefix_width(bytes);
Some(DisplayCoord::new(row_idx as u32, col))
}
@ -228,7 +197,7 @@ impl View for TextView {
walked_bytes = byte_idx;
return Some(line_start + walked_bytes as u64);
}
walked_cols += char_display_width(ch, walked_cols);
walked_cols = advance_char(walked_cols, ch);
walked_bytes = byte_idx + ch.len_utf8();
}
// Past the line's last codepoint: clamp to the line's visible end.
@ -265,8 +234,8 @@ impl View for TextView {
break;
}
if ch == '\t' {
// Expand to the next TAB_WIDTH-aligned column with spaces.
let pad = char_display_width(ch, col);
// Expand to the next protocol-wide tab stop with spaces.
let pad = advance_char(col, ch) - col;
for _ in 0..pad {
if col >= max_cols {
break;
@ -279,7 +248,7 @@ impl View for TextView {
}
continue;
}
let width = UnicodeWidthChar::width(ch).unwrap_or(0) as u32;
let width = advance_char(col, ch) - col;
if width == 0 {
// Combining mark or other zero-width control: M1.5
// skips; M2+ will attach to the previous cell as
@ -531,7 +500,7 @@ mod tests {
#[test]
fn tab_aligned_input_advances_full_width() {
// 8 chars then tab: tab pads from col 8 to col 16 (a full TAB_WIDTH).
// 8 chars then tab: the protocol tab stop advances col 8 to col 16.
let (buf, view) = attached(b"01234567\tx");
assert_eq!(view.pos_to_display(&buf, 8), Some(DisplayCoord::new(0, 8)));
assert_eq!(view.pos_to_display(&buf, 9), Some(DisplayCoord::new(0, 16)));

View File

@ -0,0 +1,87 @@
//! Cross-frontend tab-stop acceptance for core/TUI rendering.
use std::sync::{Arc, Mutex};
use pmacs::buffer::{Buffer, BufferId};
use pmacs::cell::{Cell, CellCoord, CellGrid, CellSize, Glyph, Style};
use pmacs::overlay::{BufferStyleOverlay, BufferStyleSpan, SharedBufferStyleSpans};
use pmacs::text_view::TextView;
use pmacs::view::{DisplayCoord, View, Viewport};
fn viewport(rows: u32, cols: u32, buffer_end: u64) -> Viewport {
Viewport {
buffer_start: 0,
buffer_end,
cell_origin: CellCoord::new(0, 0),
cell_size: CellSize::new(rows, cols),
gutter_w: 0,
}
}
fn render_text(buf: &Buffer, rows: u32, cols: u32) -> Vec<Cell> {
let mut cells = vec![Cell::default(); (rows * cols) as usize];
let mut grid = CellGrid {
cells: &mut cells,
stride: cols,
size: CellSize::new(rows, cols),
};
TextView::new(buf).render(buf, viewport(rows, cols, buf.len()), &mut grid);
cells
}
#[test]
fn plain_text_projects_tabs_without_changing_source_bytes() {
let source = b"\tx\n1234567\ty\n12345678\tz";
let buf = Buffer::from_bytes(BufferId::next(), "tabs", source);
let cells = render_text(&buf, 3, 20);
let view = TextView::new(&buf);
assert_eq!(view.pos_to_display(&buf, 1), Some(DisplayCoord::new(0, 8)));
assert_eq!(view.pos_to_display(&buf, 11), Some(DisplayCoord::new(1, 8)));
assert_eq!(
view.pos_to_display(&buf, 22),
Some(DisplayCoord::new(2, 16))
);
for cell in cells.iter().take(8) {
assert_eq!(cell.glyph, Glyph::Char(' '));
}
assert_eq!(cells[8].glyph, Glyph::Char('x'));
assert_eq!(cells[20 + 8].glyph, Glyph::Char('y'));
assert_eq!(cells[40 + 16].glyph, Glyph::Char('z'));
let mut retained = vec![0; buf.len() as usize];
buf.snapshot_rope().slice(0, buf.len(), &mut retained);
assert_eq!(retained, source, "rendering must not replace source tabs");
}
#[test]
fn buffer_style_overlay_covers_the_same_expanded_tab_columns_as_plain_text() {
let source = b"a\tb";
let buf = Buffer::from_bytes(BufferId::next(), "styled-tab", source);
let mut cells = render_text(&buf, 1, 12);
let spans: SharedBufferStyleSpans = Arc::new(Mutex::new(vec![BufferStyleSpan {
start: 1,
end: 2,
style: Style {
bold: true,
..Style::default()
},
}]));
let mut overlay = BufferStyleOverlay::new(spans);
let mut grid = CellGrid {
cells: &mut cells,
stride: 12,
size: CellSize::new(1, 12),
};
overlay.render(&buf, viewport(1, 12, buf.len()), &mut grid);
assert_eq!(grid.get(CellCoord::new(0, 0)).glyph, Glyph::Char('a'));
assert!(!grid.get(CellCoord::new(0, 0)).style.bold);
for col in 1..8 {
let cell = grid.get(CellCoord::new(0, col));
assert_eq!(cell.glyph, Glyph::Char(' '), "expanded tab column {col}");
assert!(cell.style.bold, "overlay missed expanded tab column {col}");
}
assert_eq!(grid.get(CellCoord::new(0, 8)).glyph, Glyph::Char('b'));
assert!(!grid.get(CellCoord::new(0, 8)).style.bold);
}