Merge pull request #137 from levineuwirth/tab-width-parity
feat(render): unify tab-width projection
This commit is contained in:
commit
2625ec7486
|
|
@ -2583,6 +2583,7 @@ dependencies = [
|
|||
"pmacs-protocol",
|
||||
"pollster",
|
||||
"sys-locale",
|
||||
"unicode-width",
|
||||
"wgpu",
|
||||
"winit",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
43
src/diag.rs
43
src/diag.rs
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
Loading…
Reference in New Issue