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",
|
"pmacs-protocol",
|
||||||
"pollster",
|
"pollster",
|
||||||
"sys-locale",
|
"sys-locale",
|
||||||
|
"unicode-width",
|
||||||
"wgpu",
|
"wgpu",
|
||||||
"winit",
|
"winit",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,8 @@ backlog.
|
||||||
machine-local: `origin` may name this canonical URL, a release mirror,
|
machine-local: `origin` may name this canonical URL, a release mirror,
|
||||||
or something else, and therefore has no authority by name alone.
|
or something else, and therefore has no authority by name alone.
|
||||||
- Canonical base at this snapshot:
|
- 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
|
- On the transfer source, `origin/main` named a release mirror at
|
||||||
`d3fa632` and lagged badly. On the current destination, `origin` names
|
`d3fa632` and lagged badly. On the current destination, `origin` names
|
||||||
the canonical URL. This difference is why all recovery begins by
|
the canonical URL. This difference is why all recovery begins by
|
||||||
|
|
@ -48,9 +49,36 @@ git worktree list
|
||||||
git status --short --branch
|
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.
|
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
|
## Parked lane: kill-ring browser + persistence
|
||||||
|
|
||||||
- Portable branch: `githubsucks/kill-ring-browser`
|
- Portable branch: `githubsucks/kill-ring-browser`
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
# Agent handoff — cross-machine continuity
|
# Agent handoff — cross-machine continuity
|
||||||
|
|
||||||
**Last updated: 2026-07-22, after locals-query processing (#134) landed on
|
**Last updated: 2026-07-22, with tab-width rendering parity implemented and
|
||||||
`main`, following modeline language detection (#132), Vterm Stage 2 (#130),
|
open as PR #137, after locals-query processing (#134) landed on `main`.
|
||||||
and mode system wiring (#129/#131). Vterm Stage 3 is not implemented.**
|
Vterm Stage 3 remains in review as #135.**
|
||||||
This file is the
|
This file is the
|
||||||
bridge between development machines. If you are an agent reading
|
bridge between development machines. If you are an agent reading
|
||||||
this on a fresh clone: this document plus the `docs/*-framing.md`
|
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)
|
## 1. Where the project stands (2026-07-22)
|
||||||
|
|
||||||
- `main` @ `8cbb9f4` (locals-query processing #134), protocol **v18**
|
- `main` @ `40111dc` (landed-state documentation after locals-query processing
|
||||||
(`SUPPORTED=[6..18]`; v16 = `ThemeFacts`, v17 = `FontFacts`, v18 =
|
#134), protocol **v18** (`SUPPORTED=[6..18]`; v16 = `ThemeFacts`, v17 =
|
||||||
`StatuslineSegments`).
|
`FontFacts`, v18 = `StatuslineSegments`).
|
||||||
- **Config registry LANDED — #127** (`docs/config-registry-framing.md`
|
- **Config registry LANDED — #127** (`docs/config-registry-framing.md`
|
||||||
rev 3; merge `2e37c04`; two review rounds). `pmacs.config` is the
|
rev 3; merge `2e37c04`; two review rounds). `pmacs.config` is the
|
||||||
typed, introspectable options registry the backlog ranked first, and
|
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;
|
8 CRDT; M4 114 passed (3 ignored, 1 filtered); required GPU 109;
|
||||||
workspace 2,882 passed across 82 suites (19 ignored, 1 filtered);
|
workspace 2,882 passed across 82 suites (19 ignored, 1 filtered);
|
||||||
`git diff --check` clean.
|
`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
|
- **PARKED: kill-ring browser + persistence.** Revision 2 framing is
|
||||||
preserved on branch `kill-ring-browser`, but its `0efb5cd` scout is stale
|
preserved on branch `kill-ring-browser`, but its `0efb5cd` scout is stale
|
||||||
and must be repeated before implementation. No PR or implementation is
|
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
|
in per-session baselines; and any daemon-side reset needs its
|
||||||
frontend mirror audited in the same round (the GPU snapshot arm
|
frontend mirror audited in the same round (the GPU snapshot arm
|
||||||
missed search/menu/status the first time).
|
missed search/menu/status the first time).
|
||||||
- **Tab width is a rendering-parity bug, NOT a config gap** (scouted at
|
- **Tab width is a rendering semantic, NOT a config gap.** The implementation
|
||||||
`7bc0c61` while framing #127; still true). There are FIVE tab-width
|
on `tab-width-parity` fixes the width at the TUI's established 8 columns,
|
||||||
sites across TWO crates with TWO different values: `TAB_WIDTH = 8` in
|
shares that constant through `pmacs-protocol`, and expands tabs only in each
|
||||||
`src/text_view.rs`, `src/highlight.rs`, `src/diag.rs` and
|
display projection. Defining `editor.tab-width` could not have fixed the GPU:
|
||||||
`src/completion.rs`, versus `advance_minimap_col` in
|
source text and semantic spans stay byte-addressed while cosmic-text needs
|
||||||
`pmacs-gpu/src/main.rs` expanding to **4** — and the GPU's main text
|
projected spaces plus an inverse hit/caret map. A future configurable width
|
||||||
path expands tabs *not at all* (buffer bytes reach the frontend raw,
|
would require a buffer-effective frontend fact and cache invalidation; do not
|
||||||
so a literal `\t` is shaped by the font). `editor.tab-width` is
|
re-plan it as a scalar config-only change.
|
||||||
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.
|
|
||||||
- **A test that never runs passes.** Two #127 review-round tests passed
|
- **A test that never runs passes.** Two #127 review-round tests passed
|
||||||
vacuously at first: `pmacs.editor.save()` is the RAW save, while
|
vacuously at first: `pmacs.editor.save()` is the RAW save, while
|
||||||
`buffer.before-save` fires inside the `buffer.save` COMMAND
|
`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
|
language-aware indent, per-language comment padding, and per-project
|
||||||
compile commands — the last three are now ordinary work, expressed as
|
compile commands — the last three are now ordinary work, expressed as
|
||||||
a `buffer.after-load` hook calling `set_local`, not blocked work.
|
a `buffer.after-load` hook calling `set_local`, not blocked work.
|
||||||
- **Tab-width rendering parity** — was listed above as a config
|
- ~~**Tab-width rendering parity**~~ — **IMPLEMENTED, IN REVIEW AS #137.** One fixed
|
||||||
consequence; it is not. `TAB_WIDTH = 8` appears four times in the
|
8-column constant now drives the core/TUI display-column paths, GPU rich-text
|
||||||
daemon (`text_view`, `highlight`, `diag`, `completion`), the GPU
|
projection, and minimap widths. GPU expansion retains source-tab provenance,
|
||||||
minimap's `advance_minimap_col` uses **4**, and the GPU main text path
|
so caret, hit, selection, and diagnostic geometry remain byte-correct through
|
||||||
expands tabs *not at all* — raw `\t` reaches glyphon and is shaped by
|
adornments and soft wraps. Source text and protocol ranges remain raw; this
|
||||||
the font. Defining `editor.tab-width` cannot make the GPU honor it;
|
adds no config key or wire change. See `docs/tab-width-parity-framing.md`.
|
||||||
this needs frontend tab expansion plus a wire-or-frontend-local
|
|
||||||
decision. Deferred from #127 on those grounds.
|
|
||||||
- **Real `read_only` buffer flag** on both edit paths — true immutability
|
- **Real `read_only` buffer flag** on both edit paths — true immutability
|
||||||
for panels / REPL / generated buffers.
|
for panels / REPL / generated buffers.
|
||||||
- ~~**Mode system wiring**~~ — **SHIPPED as #129.** Per-buffer major modes
|
- ~~**Mode system wiring**~~ — **SHIPPED as #129.** Per-buffer major modes
|
||||||
|
|
@ -237,15 +235,12 @@ guides (visual, not color).
|
||||||
|
|
||||||
## North star (highest-leverage first)
|
## North star (highest-leverage first)
|
||||||
|
|
||||||
**The original north-star items, mode-system wiring, and locals-query
|
**The original north-star items have shipped or reached review** —
|
||||||
processing have now shipped** — multi-language injections (#122), the config
|
multi-language injections (#122), the config registry (#127), JSON + YAML
|
||||||
registry (#127), JSON + YAML (#123), mode-system wiring (#129), and locals
|
(#123), mode-system wiring (#129), locals queries (#134), and tab-width
|
||||||
queries (#134). The remaining board:
|
rendering parity (PR #137, review pending). The remaining board now
|
||||||
|
starts with the broader ranked arcs below rather than another unresolved
|
||||||
1. **Tab-width rendering parity** — five constants across two crates
|
cross-frontend rendering invariant.
|
||||||
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".
|
|
||||||
|
|
||||||
Beyond those, the cleanest remaining one-shots in the highlight family are the
|
Beyond those, the cleanest remaining one-shots in the highlight family are the
|
||||||
HTML/CSS grammars that light up more injection *consumers*; modeline detection
|
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"
|
pollster = "0.4.0"
|
||||||
wgpu = "29.0.3"
|
wgpu = "29.0.3"
|
||||||
winit = "0.30.13"
|
winit = "0.30.13"
|
||||||
|
unicode-width = "0.2"
|
||||||
|
|
|
||||||
|
|
@ -40,10 +40,11 @@ use pmacs_protocol::{
|
||||||
InstanceSignal, Key as ProtocolKey, LineNumberMode, MAX_STATUSLINE_FACE_BYTES,
|
InstanceSignal, Key as ProtocolKey, LineNumberMode, MAX_STATUSLINE_FACE_BYTES,
|
||||||
MAX_STATUSLINE_PROVIDERS, MAX_STATUSLINE_SEGMENT_BYTES, MAX_STATUSLINE_TOTAL_TEXT_BYTES,
|
MAX_STATUSLINE_PROVIDERS, MAX_STATUSLINE_SEGMENT_BYTES, MAX_STATUSLINE_TOTAL_TEXT_BYTES,
|
||||||
MenuPromptRow, Modifiers, PointerKind, SelectionSnapshot, StatuslineSegment, StyleSegment,
|
MenuPromptRow, Modifiers, PointerKind, SelectionSnapshot, StatuslineSegment, StyleSegment,
|
||||||
StyleSpan,
|
StyleSpan, TAB_STOP_COLUMNS,
|
||||||
cell::{Color as CellColor, Style as CellStyle},
|
cell::{Color as CellColor, Style as CellStyle},
|
||||||
is_builtin_pair_char, is_modeline_face_name,
|
is_builtin_pair_char, is_modeline_face_name,
|
||||||
};
|
};
|
||||||
|
use unicode_width::UnicodeWidthChar;
|
||||||
use wgpu::MultisampleState;
|
use wgpu::MultisampleState;
|
||||||
use winit::application::ApplicationHandler;
|
use winit::application::ApplicationHandler;
|
||||||
use winit::event::{ElementState, WindowEvent};
|
use winit::event::{ElementState, WindowEvent};
|
||||||
|
|
@ -679,9 +680,9 @@ struct State {
|
||||||
current_line_char_starts: Vec<u64>,
|
current_line_char_starts: Vec<u64>,
|
||||||
/// Code-shape data used to give the minimap horizontal structure
|
/// Code-shape data used to give the minimap horizontal structure
|
||||||
/// even though `FileStyleSummary` carries only one dominant style
|
/// even though `FileStyleSummary` carries only one dominant style
|
||||||
/// per line. Refreshed when a new summary lands, keeping this
|
/// per line. Summary replacement rebuilds the table; accepted text
|
||||||
/// cache in cadence with the debounced minimap data rather than
|
/// edits update the affected line immediately (or rebuild after
|
||||||
/// rebuilding it for every typed byte.
|
/// structural/batched edits).
|
||||||
current_line_shapes: Vec<MinimapLineShape>,
|
current_line_shapes: Vec<MinimapLineShape>,
|
||||||
/// Local CRDT replica seeded by `BufferSnapshot`. `None` in
|
/// Local CRDT replica seeded by `BufferSnapshot`. `None` in
|
||||||
/// hello-world mode or before the first snapshot arrives in
|
/// hello-world mode or before the first snapshot arrives in
|
||||||
|
|
@ -2606,6 +2607,7 @@ impl State {
|
||||||
if edits.is_empty() {
|
if edits.is_empty() {
|
||||||
return Ok(edits);
|
return Ok(edits);
|
||||||
}
|
}
|
||||||
|
self.refresh_minimap_shapes_after_edits(&edits, line_count_before);
|
||||||
self.translate_cached_anchors(&edits);
|
self.translate_cached_anchors(&edits);
|
||||||
// A newline edit can cross a gutter digit boundary (9 -> 10,
|
// A newline edit can cross a gutter digit boundary (9 -> 10,
|
||||||
// 99 -> 100). Synchronize the painter-derived code width
|
// 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
|
/// Drop journal entries already reflected in a producer frame
|
||||||
/// stamped `generation` — see the `unconfirmed_edits` field docs.
|
/// stamped `generation` — see the `unconfirmed_edits` field docs.
|
||||||
fn prune_unconfirmed_edits(&mut self, generation: u64) {
|
fn prune_unconfirmed_edits(&mut self, generation: u64) {
|
||||||
|
|
@ -2705,6 +2737,8 @@ impl State {
|
||||||
let (line_starts, line_char_starts) = line_offset_tables(text);
|
let (line_starts, line_char_starts) = line_offset_tables(text);
|
||||||
self.current_line_starts = line_starts;
|
self.current_line_starts = line_starts;
|
||||||
self.current_line_char_starts = line_char_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();
|
let geometry_changed = self.sync_buffer_dimensions();
|
||||||
self.reshape();
|
self.reshape();
|
||||||
if geometry_changed && caret_was_painted {
|
if geometry_changed && caret_was_painted {
|
||||||
|
|
@ -6070,46 +6104,21 @@ impl State {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Map an absolute source `byte` to `(slice line index, projected
|
/// Map an absolute source byte to `(slice line index, projected
|
||||||
/// byte offset within that shaped line)` by inverting the line's
|
/// byte offset within that shaped line)` through the same reusable
|
||||||
/// `line_chunk_cache` projection (framing Q#F6): source bytes are
|
/// chunk mapping used by decoration geometry.
|
||||||
/// not projected bytes once inline adornments inject text. An
|
/// Adornments retain left gravity, while a source tab's two byte
|
||||||
/// adornment anchor maps to the EARLIEST projected boundary — the
|
/// boundaries map to the leading and trailing edges of all of its
|
||||||
/// current left-gravity caret placement, before the injected
|
/// projected spaces.
|
||||||
/// text. `None` when the byte's source line is outside the shaped
|
|
||||||
/// slice.
|
|
||||||
fn code_byte_to_projected(&self, byte: u64) -> Option<(usize, usize)> {
|
fn code_byte_to_projected(&self, byte: u64) -> Option<(usize, usize)> {
|
||||||
let line_idx = self
|
let line_idx = self
|
||||||
.current_line_starts
|
.current_line_starts
|
||||||
.partition_point(|&s| s <= byte)
|
.partition_point(|&s| s <= byte)
|
||||||
.saturating_sub(1);
|
.saturating_sub(1);
|
||||||
let slice_i = line_idx.checked_sub(self.shaped_top)?;
|
let slice_i = line_idx.checked_sub(self.shaped_top)?;
|
||||||
if slice_i >= self.line_chunk_cache.len() {
|
let chunks = self.line_chunk_cache.get(slice_i)?;
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let rel = byte - self.current_line_starts[line_idx];
|
let rel = byte - self.current_line_starts[line_idx];
|
||||||
let mut projected = 0usize;
|
source_to_projected(chunks, rel).map(|projected| (slice_i, projected as usize))
|
||||||
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))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convert an absolute source byte to a cursor cosmic-text can
|
/// 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
|
/// Push one rect per visual line whose glyphs overlap the
|
||||||
/// buffer-absolute byte range `[lo, hi)`, spanning the matching
|
/// slice-relative source byte range `[lo, hi)`, spanning the
|
||||||
/// glyphs' horizontal extent. A range crossing visual-line
|
/// matching projected glyphs' horizontal extent. Each source-line
|
||||||
/// boundaries (wrapped or multi-line) fans out into one rect per
|
/// intersection is mapped through its cached chunks first, so a
|
||||||
/// run. `line_offsets[run.line_i]` rebases the run's line-relative
|
/// source tab covers every expanded space even when a soft wrap
|
||||||
/// glyph offsets into buffer-absolute space for the comparison.
|
/// divides those spaces between visual runs.
|
||||||
fn push_glyph_extent_rects(
|
fn push_glyph_extent_rects(
|
||||||
&self,
|
&self,
|
||||||
rects: &mut Vec<MinimapRect>,
|
rects: &mut Vec<MinimapRect>,
|
||||||
|
|
@ -6276,12 +6285,30 @@ impl State {
|
||||||
let text_left = self.text_left();
|
let text_left = self.text_left();
|
||||||
for run in self.buffer.layout_runs() {
|
for run in self.buffer.layout_runs() {
|
||||||
let line_base = line_offsets.get(run.line_i).copied().unwrap_or(0);
|
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 min_x: Option<f32> = None;
|
||||||
let mut max_x: Option<f32> = None;
|
let mut max_x: Option<f32> = None;
|
||||||
for glyph in run.glyphs {
|
for glyph in run.glyphs {
|
||||||
let g_start = line_base + glyph.start as u64;
|
let g_start = glyph.start as u64;
|
||||||
let g_end = line_base + glyph.end as u64;
|
let g_end = glyph.end as u64;
|
||||||
if g_end <= lo || g_start >= hi {
|
if g_end <= projected_lo || g_start >= projected_hi {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let x0 = glyph.x;
|
let x0 = glyph.x;
|
||||||
|
|
@ -6343,6 +6370,8 @@ struct RichChunk {
|
||||||
enum ChunkSource {
|
enum ChunkSource {
|
||||||
/// Verbatim source text starting at this slice byte offset.
|
/// Verbatim source text starting at this slice byte offset.
|
||||||
Source { start: u64 },
|
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
|
/// Injected adornment text (inlay hint) anchored at this slice
|
||||||
/// byte offset. Hits inside it snap to the anchor.
|
/// byte offset. Hits inside it snap to the anchor.
|
||||||
Adornment { anchor: u64 },
|
Adornment { anchor: u64 },
|
||||||
|
|
@ -6383,9 +6412,10 @@ fn build_hit_runs(chunks: &[RichChunk]) -> (Vec<ProjectedRun>, Vec<u64>) {
|
||||||
(runs, line_starts)
|
(runs, line_starts)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Map a projected byte offset back to a slice-relative source byte
|
/// Map a projected byte offset back to a slice-relative source byte.
|
||||||
/// (Q#M2). Hits inside an adornment run snap to its anchor; offsets
|
/// A source tab's leading boundary maps before the byte; every
|
||||||
/// past the last run clamp to its end.
|
/// 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> {
|
fn projected_to_source(runs: &[ProjectedRun], projected: u64) -> Option<u64> {
|
||||||
if runs.is_empty() {
|
if runs.is_empty() {
|
||||||
return None;
|
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);
|
let within = projected.saturating_sub(run.projected_start).min(run.len);
|
||||||
match run.source {
|
match run.source {
|
||||||
ChunkSource::Source { start } => Some(start + within),
|
ChunkSource::Source { start } => Some(start + within),
|
||||||
|
ChunkSource::SourceTab { start } => Some(start + u64::from(within > 0)),
|
||||||
ChunkSource::Adornment { anchor } => Some(anchor),
|
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> {
|
fn minimap_left(surface_width: u32) -> Option<f32> {
|
||||||
if surface_width < MINIMAP_MIN_SURFACE_WIDTH {
|
if surface_width < MINIMAP_MIN_SURFACE_WIDTH {
|
||||||
return None;
|
return None;
|
||||||
|
|
@ -6727,9 +6795,10 @@ fn minimap_line_shape(line: &str) -> MinimapLineShape {
|
||||||
|
|
||||||
fn advance_minimap_col(col: usize, ch: char) -> usize {
|
fn advance_minimap_col(col: usize, ch: char) -> usize {
|
||||||
if ch == '\t' {
|
if ch == '\t' {
|
||||||
((col / 4) + 1) * 4
|
let tab_stop = TAB_STOP_COLUMNS as usize;
|
||||||
|
col + tab_stop - col % tab_stop
|
||||||
} else {
|
} else {
|
||||||
col + 1
|
col + UnicodeWidthChar::width(ch).unwrap_or(0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -7666,7 +7735,89 @@ fn projected_rich_chunks(
|
||||||
source: ChunkSource::Source { start: 0 },
|
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> {
|
fn renderable_adornment_anchor(adornment: &InlineAdornment, text_len: u64) -> Option<u64> {
|
||||||
|
|
@ -8564,6 +8715,109 @@ mod tests {
|
||||||
assert_eq!(projected_to_source(&[], 0), None);
|
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]
|
#[test]
|
||||||
fn optimistic_delete_range_covers_single_codepoints_only() {
|
fn optimistic_delete_range_covers_single_codepoints_only() {
|
||||||
let none = Modifiers::NONE;
|
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]
|
#[test]
|
||||||
fn minimap_rects_encode_six_vertices_per_quad() {
|
fn minimap_rects_encode_six_vertices_per_quad() {
|
||||||
let rect = MinimapRect {
|
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
|
/// Acceptance 11 — the `CursorByte` arm follows into a wrapped
|
||||||
/// continuation run (the pre-existing source-line-only hole): the
|
/// continuation run (the pre-existing source-line-only hole): the
|
||||||
/// follow lands as a sub-line residual, normalized to slice-local
|
/// follow lands as a sub-line residual, normalized to slice-local
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,8 @@
|
||||||
//! - The full message envelopes: `InstanceMessage`, `FrontendEvent`,
|
//! - The full message envelopes: `InstanceMessage`, `FrontendEvent`,
|
||||||
//! `GoodbyeReason`, capability structs, `PresenceUpdate`, etc.
|
//! `GoodbyeReason`, capability structs, `PresenceUpdate`, etc.
|
||||||
//! - The optional `CrdtOp` wire variant (feature-gated on `crdt`).
|
//! - 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:
|
//! What does NOT live here:
|
||||||
//! - `crate::cell::CellGrid` and `crate::cell::diff()` (rendering
|
//! - `crate::cell::CellGrid` and `crate::cell::diff()` (rendering
|
||||||
|
|
@ -40,6 +42,12 @@ pub mod ids;
|
||||||
pub mod message;
|
pub mod message;
|
||||||
pub mod transport;
|
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::{
|
pub use cell::{
|
||||||
Attachment, Cell, CellCoord, CellSize, Color, DiffSpan, Glyph, Style, UnderlineStyle,
|
Attachment, Cell, CellCoord, CellSize, Color, DiffSpan, Glyph, Style, UnderlineStyle,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ use unicode_width::UnicodeWidthChar;
|
||||||
|
|
||||||
use crate::buffer::{Buffer, BufferId};
|
use crate::buffer::{Buffer, BufferId};
|
||||||
use crate::cell::{CellCoord, CellGrid, Color, Glyph, Style};
|
use crate::cell::{CellCoord, CellGrid, Color, Glyph, Style};
|
||||||
|
use crate::display_width::byte_to_column;
|
||||||
use crate::rope::Position;
|
use crate::rope::Position;
|
||||||
use crate::view::{View, Viewport};
|
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).
|
/// Minimum popup width in cells (glyph column + a readable label).
|
||||||
const POPUP_MIN_WIDTH: u32 = 12;
|
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
|
/// Style for the currently-selected row (reverse video so it pops on
|
||||||
/// any base palette).
|
/// any base palette).
|
||||||
fn selected_style() -> Style {
|
fn selected_style() -> Style {
|
||||||
|
|
@ -663,21 +660,9 @@ impl CompletionView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Display column of `byte_end` within `line_bytes` (tab-aware,
|
/// Display column of `byte_end` within `line_bytes`.
|
||||||
/// UTF-8-aware). The completion twin of the diagnostic underline's
|
|
||||||
/// column resolution.
|
|
||||||
fn display_col_for_byte(line_bytes: &[u8], byte_end: u32) -> u32 {
|
fn display_col_for_byte(line_bytes: &[u8], byte_end: u32) -> u32 {
|
||||||
let end = (byte_end as usize).min(line_bytes.len());
|
byte_to_column(line_bytes, byte_end as usize)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolved popup rectangle, in window-relative cells.
|
/// Resolved popup rectangle, in window-relative cells.
|
||||||
|
|
@ -811,7 +796,7 @@ fn paint_popup_row(
|
||||||
if col >= width {
|
if col >= width {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
let cw = char_display_width(ch);
|
let cw = UnicodeWidthChar::width(ch).unwrap_or(0) as u32;
|
||||||
if cw == 0 {
|
if cw == 0 {
|
||||||
continue;
|
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
|
// Tests
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
43
src/diag.rs
43
src/diag.rs
|
|
@ -32,10 +32,10 @@ use std::collections::HashMap;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use unicode_width::UnicodeWidthChar;
|
|
||||||
|
|
||||||
use crate::buffer::Buffer;
|
use crate::buffer::Buffer;
|
||||||
use crate::cell::{CellCoord, CellGrid, Color, Glyph, Style, UnderlineStyle};
|
use crate::cell::{CellCoord, CellGrid, Color, Glyph, Style, UnderlineStyle};
|
||||||
|
use crate::display_width::byte_range_to_columns;
|
||||||
use crate::overlay::merge_styles;
|
use crate::overlay::merge_styles;
|
||||||
use crate::view::{View, Viewport};
|
use crate::view::{View, Viewport};
|
||||||
|
|
||||||
|
|
@ -388,10 +388,6 @@ pub fn make_shared_store() -> SharedDiagStore {
|
||||||
// View
|
// 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.*`
|
/// 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
|
/// face's `fg` when a face is set with a concrete color, else the
|
||||||
/// built-in [`DiagnosticSeverity::underline_color`]. The diag family
|
/// 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
|
// Line lookup helpers shared with the completion overlay.
|
||||||
// cross-module coupling on internal helpers)
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
pub(crate) fn compute_line_offsets(source: &[u8]) -> Vec<u32> {
|
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) {
|
fn underline_cols_for_line(line_bytes: &[u8], byte_start: u32, byte_end: u32) -> (u32, u32) {
|
||||||
if byte_end <= byte_start {
|
if byte_end <= byte_start {
|
||||||
let (anchor, _) =
|
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)
|
(anchor, anchor + 1)
|
||||||
} else {
|
} else {
|
||||||
byte_range_to_display_cols(line_bytes, byte_start as usize, byte_end as usize)
|
byte_range_to_columns(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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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::collections::HashMap;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use unicode_width::UnicodeWidthChar;
|
|
||||||
|
|
||||||
use crate::buffer::Buffer;
|
use crate::buffer::Buffer;
|
||||||
use crate::cell::{CellCoord, CellGrid, Color, Style, UnderlineStyle};
|
use crate::cell::{CellCoord, CellGrid, Color, Style, UnderlineStyle};
|
||||||
|
use crate::display_width::byte_range_to_columns;
|
||||||
use crate::lsp::SharedLspManager;
|
use crate::lsp::SharedLspManager;
|
||||||
use crate::overlay::merge_styles;
|
use crate::overlay::merge_styles;
|
||||||
use crate::syntax::{HighlightSpan, ParseTreeBundle, ParseViewHandle, compute_highlight_spans_for};
|
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
|
/// View that renders syntax highlighting from a tree-sitter parse
|
||||||
/// tree, including its injection layers. Composes over
|
/// tree, including its injection layers. Composes over
|
||||||
/// [`crate::text_view::TextView`] per the M2.9 view-composition
|
/// [`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_start = (s_start - line_start) as usize;
|
||||||
let byte_col_end = (s_end - line_start) as usize;
|
let byte_col_end = (s_end - line_start) as usize;
|
||||||
let (start_col, end_col) =
|
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 {
|
if end_col <= start_col {
|
||||||
continue;
|
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
|
// Style equality helper
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -737,7 +698,7 @@ impl View for LspStyleView {
|
||||||
if end_b <= start_b {
|
if end_b <= start_b {
|
||||||
continue;
|
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 {
|
if end_col <= start_col {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -944,9 +905,9 @@ mod tests {
|
||||||
fn byte_range_display_cols_ascii_round_trips() {
|
fn byte_range_display_cols_ascii_round_trips() {
|
||||||
let line = b"hello world";
|
let line = b"hello world";
|
||||||
// "hello" → cols 0..5
|
// "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
|
// "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]
|
#[test]
|
||||||
|
|
@ -954,15 +915,15 @@ mod tests {
|
||||||
let line = b"\tx";
|
let line = b"\tx";
|
||||||
// The full line: tab (0..8) + 'x' (8..9). Byte cols 0..2
|
// The full line: tab (0..8) + 'x' (8..9). Byte cols 0..2
|
||||||
// map to display cols 0..9.
|
// 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.
|
// 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]
|
#[test]
|
||||||
fn byte_range_display_cols_clamps_past_end() {
|
fn byte_range_display_cols_clamps_past_end() {
|
||||||
let line = b"hi";
|
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]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,7 @@ pub mod daemon_attach;
|
||||||
pub mod definition;
|
pub mod definition;
|
||||||
pub mod desktop;
|
pub mod desktop;
|
||||||
pub mod diag;
|
pub mod diag;
|
||||||
|
pub mod display_width;
|
||||||
pub mod document_highlight;
|
pub mod document_highlight;
|
||||||
pub mod editor;
|
pub mod editor;
|
||||||
pub mod editor_core;
|
pub mod editor_core;
|
||||||
|
|
|
||||||
|
|
@ -43,10 +43,9 @@
|
||||||
|
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use unicode_width::UnicodeWidthChar;
|
|
||||||
|
|
||||||
use crate::buffer::Buffer;
|
use crate::buffer::Buffer;
|
||||||
use crate::cell::{Cell, CellCoord, CellGrid, Style};
|
use crate::cell::{Cell, CellCoord, CellGrid, Style};
|
||||||
|
use crate::display_width::byte_range_to_columns;
|
||||||
use crate::rope::Edit;
|
use crate::rope::Edit;
|
||||||
use crate::view::{View, Viewport};
|
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(
|
fn render_buffer_style_span(
|
||||||
buf: &Buffer,
|
buf: &Buffer,
|
||||||
line_offsets: &[u64],
|
line_offsets: &[u64],
|
||||||
|
|
@ -418,8 +393,14 @@ fn render_buffer_style_span(
|
||||||
if style_start >= style_end {
|
if style_start >= style_end {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let start_col = display_col_for_range(buf, line_start, style_start);
|
let mut line_prefix = vec![0; (style_end - line_start) as usize];
|
||||||
let end_col = display_col_for_range(buf, line_start, style_end);
|
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 start_col = start_col.min(viewport.cell_size.cols);
|
||||||
let end_col = end_col.min(viewport.cell_size.cols);
|
let end_col = end_col.min(viewport.cell_size.cols);
|
||||||
for col in start_col..end_col {
|
for col in start_col..end_col {
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ use std::sync::{Arc, Mutex};
|
||||||
use pmacs_protocol::ByteRange;
|
use pmacs_protocol::ByteRange;
|
||||||
|
|
||||||
use crate::buffer::BufferId;
|
use crate::buffer::BufferId;
|
||||||
|
use crate::display_width::byte_range_to_columns;
|
||||||
|
|
||||||
/// One buffer's search state: the resolved query, its matches (byte
|
/// One buffer's search state: the resolved query, its matches (byte
|
||||||
/// ranges, ascending and non-overlapping), and the active index.
|
/// 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_start = (paint_start - line_start) as usize;
|
||||||
let within_end = (paint_end - line_start) as usize;
|
let within_end = (paint_end - line_start) as usize;
|
||||||
let (start_col, end_col) =
|
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 {
|
if end_col <= start_col {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,10 +19,9 @@
|
||||||
//! Main thread only. The view is held inside a [`Buffer`], which is itself
|
//! Main thread only. The view is held inside a [`Buffer`], which is itself
|
||||||
//! main-only.
|
//! main-only.
|
||||||
|
|
||||||
use unicode_width::UnicodeWidthChar;
|
|
||||||
|
|
||||||
use crate::buffer::{Buffer, BufferError};
|
use crate::buffer::{Buffer, BufferError};
|
||||||
use crate::cell::{Cell, CellCoord, CellGrid, Glyph, Style};
|
use crate::cell::{Cell, CellCoord, CellGrid, Glyph, Style};
|
||||||
|
use crate::display_width::{advance_char, valid_prefix_width};
|
||||||
use crate::rope::{Edit, Position};
|
use crate::rope::{Edit, Position};
|
||||||
use crate::view::{DisplayCoord, View, Viewport};
|
use crate::view::{DisplayCoord, View, Viewport};
|
||||||
|
|
||||||
|
|
@ -30,28 +29,10 @@ use crate::view::{DisplayCoord, View, Viewport};
|
||||||
// Tuning
|
// 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
|
/// 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.
|
/// [`TextView::pos_to_display`]; longer prefixes fall back to a heap buffer.
|
||||||
const STACK_CAP: usize = 256;
|
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
|
// TextView
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -196,19 +177,7 @@ impl View for TextView {
|
||||||
&mut heap_buf
|
&mut heap_buf
|
||||||
};
|
};
|
||||||
buf.snapshot_rope().slice(line_start, pos, bytes);
|
buf.snapshot_rope().slice(line_start, pos, bytes);
|
||||||
// If `pos` fell inside a multi-byte codepoint, keep only the bytes up to
|
let col = valid_prefix_width(bytes);
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
Some(DisplayCoord::new(row_idx as u32, col))
|
Some(DisplayCoord::new(row_idx as u32, col))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -228,7 +197,7 @@ impl View for TextView {
|
||||||
walked_bytes = byte_idx;
|
walked_bytes = byte_idx;
|
||||||
return Some(line_start + walked_bytes as u64);
|
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();
|
walked_bytes = byte_idx + ch.len_utf8();
|
||||||
}
|
}
|
||||||
// Past the line's last codepoint: clamp to the line's visible end.
|
// Past the line's last codepoint: clamp to the line's visible end.
|
||||||
|
|
@ -265,8 +234,8 @@ impl View for TextView {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if ch == '\t' {
|
if ch == '\t' {
|
||||||
// Expand to the next TAB_WIDTH-aligned column with spaces.
|
// Expand to the next protocol-wide tab stop with spaces.
|
||||||
let pad = char_display_width(ch, col);
|
let pad = advance_char(col, ch) - col;
|
||||||
for _ in 0..pad {
|
for _ in 0..pad {
|
||||||
if col >= max_cols {
|
if col >= max_cols {
|
||||||
break;
|
break;
|
||||||
|
|
@ -279,7 +248,7 @@ impl View for TextView {
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let width = UnicodeWidthChar::width(ch).unwrap_or(0) as u32;
|
let width = advance_char(col, ch) - col;
|
||||||
if width == 0 {
|
if width == 0 {
|
||||||
// Combining mark or other zero-width control: M1.5
|
// Combining mark or other zero-width control: M1.5
|
||||||
// skips; M2+ will attach to the previous cell as
|
// skips; M2+ will attach to the previous cell as
|
||||||
|
|
@ -531,7 +500,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tab_aligned_input_advances_full_width() {
|
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");
|
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, 8), Some(DisplayCoord::new(0, 8)));
|
||||||
assert_eq!(view.pos_to_display(&buf, 9), Some(DisplayCoord::new(0, 16)));
|
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