mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-29 20:29:33 +01:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
538fcae708 |
No files matched your search
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'aicodeman': patch
|
||||
---
|
||||
|
||||
Persian, Arabic and Hebrew now read right to left in the Response Viewer and in the File Viewer's markdown view (#715). Each paragraph, heading, list, quote and table takes its direction from its own first letter, so a right-to-left answer is right-aligned with its punctuation, list bullets and quote bar on the right, while an English paragraph beside it stays left to right. Code blocks, inline code and file paths stay left to right and no longer decide a sentence's direction, so a Persian sentence that starts with `npm install` still reads right to left. The phone's Compose dialog lays a right-to-left prompt out the same way. The terminal pane is unchanged: xterm.js has no right-to-left support yet (xtermjs/xterm.js#701).
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
"aicodeman": patch
|
||||
"xterm-zerolag-input": patch
|
||||
---
|
||||
|
||||
**Typing stays smooth in a long prompt.** The local echo overlay (the instant preview of what you type before the session echoes it back) used to throw away and rebuild every character of the prompt on each keystroke, so the longer the prompt, the slower each key. On a phone-class CPU it dropped below 60 fps at about 300 characters, and at about 4,000 characters a single keystroke took most of a second to appear. It now rebuilds only the line that changed, and each line is one paint layer instead of one layer per character, so a keystroke costs the same however long the prompt is. What you see is unchanged: every character still sits on its terminal cell, within 1/8 px. This ships in `xterm-zerolag-input` as well: the README's rendering section describes the new layout.
|
||||
@@ -1227,7 +1227,7 @@ Full details: [`docs/archive/code-structure-findings.md`](docs/archive/code-stru
|
||||
|
||||
[](https://www.npmjs.com/package/xterm-zerolag-input)
|
||||
|
||||
Instant keystroke feedback overlay for xterm.js. Eliminates perceived input latency over high-RTT connections by rendering typed characters immediately as a pixel-perfect DOM overlay. Zero dependencies, 5.7 kB minified and gzipped, configurable prompt detection, CJK/emoji wide-character support, full state machine with 276 tests. Typing cost does not grow with prompt length.
|
||||
Instant keystroke feedback overlay for xterm.js. Eliminates perceived input latency over high-RTT connections by rendering typed characters immediately as a pixel-perfect DOM overlay. Zero dependencies, 6.1 kB gzipped, configurable prompt detection, CJK/emoji wide-character support, full state machine with 238 tests.
|
||||
|
||||
```bash
|
||||
npm install xterm-zerolag-input
|
||||
|
||||
+1
-1
@@ -1221,7 +1221,7 @@ npm test # 运行测试(与 CI 相同;浏览器/移动端
|
||||
|
||||
[](https://www.npmjs.com/package/xterm-zerolag-input)
|
||||
|
||||
为 xterm.js 提供即时按键反馈的叠加层。通过把输入的字符立即渲染为像素级精准的 DOM 叠加层,消除高 RTT 连接下的感知输入延迟。零依赖、压缩并 gzip 后 5.7 kB、可配置的提示符检测、CJK/emoji 宽字符支持、带 276 个测试的完整状态机。输入开销不随提示词长度增长。
|
||||
为 xterm.js 提供即时按键反馈的叠加层。通过把输入的字符立即渲染为像素级精准的 DOM 叠加层,消除高 RTT 连接下的感知输入延迟。零依赖、gzip 后 6.1 kB、可配置的提示符检测、CJK/emoji 宽字符支持、带 238 个测试的完整状态机。
|
||||
|
||||
```bash
|
||||
npm install xterm-zerolag-input
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
<a href="https://www.npmjs.com/package/xterm-zerolag-input"><img src="https://img.shields.io/npm/v/xterm-zerolag-input?style=flat-square&color=22c55e" alt="npm"></a>
|
||||
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-1e3a5f?style=flat-square" alt="MIT"></a>
|
||||
<img src="https://img.shields.io/badge/Dependencies-0-22c55e?style=flat-square" alt="Zero dependencies">
|
||||
<img src="https://img.shields.io/badge/Size-5.7%20kB%20gzip-22c55e?style=flat-square" alt="5.7 kB minified and gzipped">
|
||||
<img src="https://img.shields.io/badge/Tests-276-22c55e?style=flat-square" alt="276 tests">
|
||||
<img src="https://img.shields.io/badge/Size-6.1%20kB%20gzip-22c55e?style=flat-square" alt="6.1 kB gzipped">
|
||||
<img src="https://img.shields.io/badge/Tests-227-22c55e?style=flat-square" alt="175 tests">
|
||||
<img src="https://img.shields.io/badge/xterm.js-v5%20%7C%20v7+-3b82f6?style=flat-square" alt="xterm.js v5 and v7+">
|
||||
</p>
|
||||
</p>
|
||||
@@ -60,13 +60,12 @@ Since 0.2.0 the package ships **two addons for two kinds of TUIs**:
|
||||
| | |
|
||||
|---|---|
|
||||
| **Survives full-screen TUIs** | Ink, blessed, and friends repaint the whole screen constantly. The overlay is a separate DOM layer they cannot reach, so it does not get clobbered. |
|
||||
| **Pixel-matched to the canvas** | Each character is its own `<span>`, exactly as wide as its cells and snapped to the grid, so it does not drift out of the grid like normal DOM text flow. |
|
||||
| **Cheap per keystroke** | Only the line that changed is rebuilt, and each line is one paint layer, so a keystroke costs the same in a 4,000-character prompt as in a short one. |
|
||||
| **Pixel-matched to the canvas** | Each character is its own absolutely-positioned `<span>` at exact cell coordinates, so it does not drift out of the grid like normal DOM text flow. |
|
||||
| **Wide characters included** | CJK, fullwidth forms and emoji render double-width and position by visual column, using the terminal's Unicode addon when one is loaded. |
|
||||
| **Backspace that actually works** | A three-layer cascade (unsent, in-flight, already on screen) tells you exactly what to forward to the PTY, so editing works through any mix of typed, flushed and tab-completed text. |
|
||||
| **You keep control of input** | The addon never hooks `onData` for you. You decide what gets echoed and what gets forwarded, which is what makes char-at-a-time, buffered, and multi-session tab switching all possible. |
|
||||
| **Small and self-contained** | 5.7 kB minified and gzipped, zero runtime dependencies, dual CJS/ESM with full type declarations. |
|
||||
| **Proven under load** | Extracted from [Codeman](https://getcodeman.com), hardened over thousands of hours of real remote and mobile usage, 276 tests over every state transition. |
|
||||
| **Small and self-contained** | 6.1 kB gzipped, zero runtime dependencies, dual CJS/ESM with full type declarations. |
|
||||
| **Proven under load** | Extracted from [Codeman](https://getcodeman.com), hardened over thousands of hours of real remote and mobile usage, 175 tests over every state transition. |
|
||||
|
||||
Built for anything that puts a terminal behind a network hop: SSH web clients, cloud IDEs, mobile terminals, Kubernetes and container consoles, remote agent dashboards, browser-based dev environments.
|
||||
|
||||
@@ -129,7 +128,7 @@ This library answers all three:
|
||||
|
||||
- a **DOM overlay** on its own z-index layer, which Ink cannot touch
|
||||
- **bottom-up buffer scanning** for the prompt instead of trusting the cursor
|
||||
- **one `<span>` per character**, sized to its cells and snapped to the cell grid
|
||||
- **one absolutely-positioned `<span>` per character** at exact cell-grid coordinates
|
||||
|
||||
---
|
||||
|
||||
@@ -509,23 +508,15 @@ div.xterm-screen (position: relative)
|
||||
|
||||
### Per-character grid alignment
|
||||
|
||||
Each line is an absolutely-positioned `<div>` at its row, and each character is a `<span>` in a flex row inside it, as wide as the cells it covers:
|
||||
Each character is an absolutely-positioned `<span>`:
|
||||
|
||||
```
|
||||
line top = lineIndex * cellHeight (CSS pixels)
|
||||
edge(col) = round(col * cellWidth * 4) / 4 (snapped to a quarter pixel)
|
||||
span width = edge(col + charCellWidth) - edge(col) (1 cell, or 2 for wide characters)
|
||||
left = visualColumn * cellWidth (CSS pixels)
|
||||
top = lineIndex * cellHeight (CSS pixels)
|
||||
width = cellWidth * charCellWidth (1 cell, or 2 for wide characters)
|
||||
```
|
||||
|
||||
A span starts where the widths before it add up to. Normal DOM text drifts because glyph advances that the layout engine cannot store exactly carry their rounding into every later cell. A quarter pixel is stored exactly by Blink and WebKit (1/64 px layout units) and Gecko (1/60 px app units), so the snapped widths always add up to the snapped edge: no drift at any column, and every edge stays within 1/8 px of `col * cellWidth`.
|
||||
|
||||
The spans are deliberately not absolutely positioned. A positioned element gets its own paint layer, and with one per character the browser had to re-layerize and repaint thousands of them on every keystroke of a long prompt. The font, colour, line height, centring and ligature settings are set once per line and inherited.
|
||||
|
||||
### Rendering cost
|
||||
|
||||
Each render compares every line with what the previous render left in the overlay and rebuilds only the lines whose text or position changed. Typing a character changes only the last line, so a keystroke costs one line of DOM work, layout and paint, however long the prompt. A font, colour, cell size or Unicode version change rebuilds everything, and so does anything else emptying the overlay element.
|
||||
|
||||
Measured in headless Chromium with a 6x CPU slowdown standing in for a phone, typing into a prompt of about 4,100 characters: 0.4.x took a median 813 ms per frame (513 ms of script, style and layout per keystroke), this version 17 ms (8.6 ms). At about 300 characters, 0.4.x already missed 60 fps (54 ms frames).
|
||||
Positioning by visual column instead of letting the browser lay out text is what removes sub-pixel drift.
|
||||
|
||||
### Font matching
|
||||
|
||||
@@ -552,7 +543,7 @@ The overlay hides while the cursor row is scrolled out of the viewport and re-re
|
||||
|
||||
## Known limitations
|
||||
|
||||
- **Canvas and WebGL font mismatch**: minor sub-pixel differences are still possible. Per-character cells snapped to a quarter pixel keep them under 1/8 px.
|
||||
- **Canvas and WebGL font mismatch**: minor sub-pixel differences are still possible. Per-character absolute positioning keeps them small.
|
||||
- **Grapheme clusters**: widths are summed per code point, so ZWJ emoji sequences (for example 👨👩👧) and combining marks can be over-counted. Single-code-point emoji and CJK are correct.
|
||||
- **Password prompts**: the overlay will happily show characters the server is not echoing. Call `clear()` when you detect a no-echo prompt.
|
||||
- **Prompt characters in output**: if your prompt marker also appears in command output, detection can latch onto the wrong line. Use a regex or a custom finder.
|
||||
|
||||
@@ -46,61 +46,14 @@ export function stringCellWidth(terminal: XtermTerminal | null | undefined, str:
|
||||
|
||||
// ─── Overlay rendering ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Cell edges are snapped to this fraction of a CSS pixel.
|
||||
*
|
||||
* The glyph spans sit in normal flow, so each one starts where the widths
|
||||
* before it add up to, and a width the layout engine cannot store exactly
|
||||
* would carry its rounding error into every later cell (the drift that plain
|
||||
* DOM text shows against the canvas grid). A quarter pixel is exact in
|
||||
* Blink and WebKit (1/64 px layout units) and in Gecko (1/60 px app units),
|
||||
* so the widths always add up to the snapped edge: no drift at any column,
|
||||
* and every edge is within 1/8 px of `col * cellW`.
|
||||
*/
|
||||
const EDGE_SNAP = 4;
|
||||
|
||||
function cellEdge(col: number, cellW: number): number {
|
||||
return Math.round(col * cellW * EDGE_SNAP) / EDGE_SNAP;
|
||||
}
|
||||
|
||||
/** What the last render left in a container, so the next one rebuilds only the lines that changed. */
|
||||
interface RenderedLines {
|
||||
/** Everything a line's markup depends on besides its own text and position. */
|
||||
styleKey: string;
|
||||
lines: HTMLDivElement[];
|
||||
lineKeys: string[];
|
||||
cursor: HTMLSpanElement | null;
|
||||
}
|
||||
|
||||
const rendered = new WeakMap<HTMLDivElement, RenderedLines>();
|
||||
|
||||
/**
|
||||
* False once anything else has emptied or edited the container (the addon's
|
||||
* hide path clears it): the children must be exactly the recorded lines, in
|
||||
* order, then the cursor.
|
||||
*/
|
||||
function holdsOnlyRendered(container: HTMLDivElement, state: RenderedLines): boolean {
|
||||
const children = container.children;
|
||||
if (children.length !== state.lines.length + (state.cursor ? 1 : 0)) return false;
|
||||
for (let i = 0; i < state.lines.length; i++) {
|
||||
if (children[i] !== state.lines[i]) return false;
|
||||
}
|
||||
return !state.cursor || children[state.lines.length] === state.cursor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the overlay content into the container element.
|
||||
*
|
||||
* Each line is a `<div>` holding one `<span>` per character on the cell grid
|
||||
* of xterm.js's canvas renderer (see makeLine). CJK wide characters get
|
||||
* double-width spans.
|
||||
* Creates per-character `<span>` elements positioned on an exact grid
|
||||
* matching xterm.js's canvas renderer. This avoids sub-pixel drift that
|
||||
* occurs with normal DOM text flow.
|
||||
*
|
||||
* Only lines whose content or position changed since the previous render
|
||||
* into the same container are rebuilt. Typing a character changes just the
|
||||
* last line, so a keystroke costs one line of DOM work however long the
|
||||
* prompt is; rebuilding everything made each keystroke O(prompt length) in
|
||||
* DOM work, layout and paint, which a long prompt on a phone paid for in
|
||||
* dropped frames.
|
||||
* CJK wide characters are rendered with double-width spans.
|
||||
*/
|
||||
export function renderOverlay(container: HTMLDivElement, params: RenderParams): void {
|
||||
const {
|
||||
@@ -141,14 +94,11 @@ export function renderOverlay(container: HTMLDivElement, params: RenderParams):
|
||||
// Code-point offset of each line in the whole text, so the composition
|
||||
// styling survives the tail slice below.
|
||||
const lineOffsets: number[] = [];
|
||||
const lineLengths: number[] = [];
|
||||
{
|
||||
let offset = 0;
|
||||
for (const line of lines) {
|
||||
const length = [...line].length;
|
||||
lineOffsets.push(offset);
|
||||
lineLengths.push(length);
|
||||
offset += length;
|
||||
offset += [...line].length;
|
||||
}
|
||||
}
|
||||
let visibleLines = lines;
|
||||
@@ -170,29 +120,8 @@ export function renderOverlay(container: HTMLDivElement, params: RenderParams):
|
||||
container.style.left = '0px';
|
||||
container.style.top = topRow * cellH + 'px';
|
||||
|
||||
// Reuse the previous render's lines when nothing they depend on but their
|
||||
// own text and position changed; otherwise start from an empty container.
|
||||
const styleKey = [
|
||||
cellW,
|
||||
cellH,
|
||||
font.fontFamily,
|
||||
font.fontSize,
|
||||
font.fontWeight,
|
||||
font.color,
|
||||
font.backgroundColor,
|
||||
font.letterSpacing,
|
||||
terminal?.unicode?.activeVersion ?? '',
|
||||
].join('\n');
|
||||
let state = rendered.get(container);
|
||||
if (!state || state.styleKey !== styleKey || !holdsOnlyRendered(container, state)) {
|
||||
container.innerHTML = '';
|
||||
state = { styleKey, lines: [], lineKeys: [], cursor: null };
|
||||
rendered.set(container, state);
|
||||
}
|
||||
// Taken out while the lines change, so it is appended last again below.
|
||||
state.cursor?.remove();
|
||||
state.cursor = null;
|
||||
|
||||
// Clear and rebuild (typically 1-3 line divs, negligible cost)
|
||||
container.innerHTML = '';
|
||||
const fullWidthPx = totalCols * cellW;
|
||||
|
||||
for (let i = 0; i < visibleLines.length; i++) {
|
||||
@@ -200,16 +129,8 @@ export function renderOverlay(container: HTMLDivElement, params: RenderParams):
|
||||
const leftPx = indents ? startCol * cellW : 0;
|
||||
const widthPx = indents ? fullWidthPx - leftPx : fullWidthPx;
|
||||
const topPx = i * cellH;
|
||||
// Clamped to the line, so a line the composition does not reach keeps
|
||||
// the same key (and its element) while the composition moves along.
|
||||
let lineCompositionFrom: number | undefined;
|
||||
if (compositionStart !== undefined) {
|
||||
const from = compositionStart - lineOffsets[firstVisible + i];
|
||||
if (from < lineLengths[firstVisible + i]) lineCompositionFrom = Math.max(0, from);
|
||||
}
|
||||
// Text goes last, so no character in it can make two different lines collide.
|
||||
const key = `${leftPx}|${topPx}|${widthPx}|${lineCompositionFrom ?? ''}|${visibleLines[i]}`;
|
||||
if (state.lineKeys[i] === key) continue;
|
||||
const lineCompositionFrom =
|
||||
compositionStart === undefined ? undefined : compositionStart - lineOffsets[firstVisible + i];
|
||||
const lineEl = makeLine(
|
||||
visibleLines[i],
|
||||
leftPx,
|
||||
@@ -223,15 +144,8 @@ export function renderOverlay(container: HTMLDivElement, params: RenderParams):
|
||||
terminal,
|
||||
lineCompositionFrom
|
||||
);
|
||||
const previous = state.lines[i];
|
||||
if (previous) container.replaceChild(lineEl, previous);
|
||||
else container.appendChild(lineEl);
|
||||
state.lines[i] = lineEl;
|
||||
state.lineKeys[i] = key;
|
||||
container.appendChild(lineEl);
|
||||
}
|
||||
for (let i = state.lines.length - 1; i >= visibleLines.length; i--) state.lines[i].remove();
|
||||
state.lines.length = visibleLines.length;
|
||||
state.lineKeys.length = visibleLines.length;
|
||||
|
||||
// Block cursor at end of last line (use visual width for CJK support)
|
||||
if (showCursor) {
|
||||
@@ -247,7 +161,6 @@ export function renderOverlay(container: HTMLDivElement, params: RenderParams):
|
||||
cursor.style.height = cellH + 'px';
|
||||
cursor.style.backgroundColor = cursorColor;
|
||||
container.appendChild(cursor);
|
||||
state.cursor = cursor;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,20 +168,13 @@ export function renderOverlay(container: HTMLDivElement, params: RenderParams):
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a styled line `<div>` with one `<span>` per character on the cell grid.
|
||||
* Create a styled line `<div>` with per-character grid positioning.
|
||||
*
|
||||
* The spans sit side by side in a flex row, each exactly as wide as its cells
|
||||
* (CJK wide characters occupy 2), with every edge snapped by cellEdge() so the
|
||||
* widths add up to the grid without drifting. They are deliberately not
|
||||
* absolutely positioned: a positioned element gets its own paint layer, and
|
||||
* with one per character a long prompt left the browser re-layerizing and
|
||||
* repainting thousands of them on every keystroke. Everything the spans share
|
||||
* (font, colour, line height, centring, ligatures off) is set once on the
|
||||
* line and inherited.
|
||||
*
|
||||
* Characters at or after `compositionFrom` (a code-point index into `text`)
|
||||
* are IME composition text: underlined, like xterm's own composition view, and
|
||||
* marked `data-zerolag-composition` + `aria-hidden` since they are provisional.
|
||||
* Each character gets its own `<span>` positioned by visual column offset.
|
||||
* CJK wide characters occupy 2 cell widths. Characters at or after
|
||||
* `compositionFrom` (a code-point index into `text`, may be negative) are IME
|
||||
* composition text: underlined, like xterm's own composition view, and marked
|
||||
* `data-zerolag-composition` + `aria-hidden` since they are provisional.
|
||||
*/
|
||||
function makeLine(
|
||||
text: string,
|
||||
@@ -284,9 +190,7 @@ function makeLine(
|
||||
compositionFrom?: number
|
||||
): HTMLDivElement {
|
||||
const el = document.createElement('div');
|
||||
// No ligatures: the canvas renders each glyph independently.
|
||||
el.style.cssText =
|
||||
"position:absolute;display:flex;pointer-events:none;text-align:center;font-feature-settings:'liga' 0,'calt' 0";
|
||||
el.style.cssText = 'position:absolute;pointer-events:none';
|
||||
el.style.backgroundColor = font.backgroundColor;
|
||||
el.style.left = leftPx + 'px';
|
||||
el.style.top = topPx + 'px';
|
||||
@@ -296,24 +200,27 @@ function makeLine(
|
||||
// The extra 1px lands in the next row's charTop gap (empty area before
|
||||
// text rendering starts), so no canvas content is obscured.
|
||||
el.style.height = cellH + 1 + 'px';
|
||||
el.style.lineHeight = cellH + 'px';
|
||||
el.style.fontFamily = font.fontFamily;
|
||||
el.style.fontSize = font.fontSize;
|
||||
el.style.fontWeight = font.fontWeight;
|
||||
el.style.color = font.color;
|
||||
if (font.letterSpacing) el.style.letterSpacing = font.letterSpacing;
|
||||
|
||||
// Each span runs from the previous snapped edge to its own, so the spans
|
||||
// land on the grid by visual column (CJK wide chars span 2 cells).
|
||||
const spanHeight = ';height:' + cellH + 'px';
|
||||
let col = 0;
|
||||
let edge = 0;
|
||||
// CJK wide chars occupy 2 cells — position by visual column offset
|
||||
let colOffset = 0;
|
||||
let index = 0;
|
||||
for (const ch of text) {
|
||||
col += charCellWidth(terminal, ch);
|
||||
const nextEdge = cellEdge(col, cellW);
|
||||
const cw = charCellWidth(terminal, ch);
|
||||
const span = document.createElement('span');
|
||||
span.style.cssText = 'flex:none;width:' + (nextEdge - edge) + 'px' + spanHeight;
|
||||
// No ligatures — canvas renders each glyph independently.
|
||||
span.style.cssText =
|
||||
'position:absolute;display:inline-block;text-align:center;pointer-events:none;' +
|
||||
"font-feature-settings:'liga' 0,'calt' 0";
|
||||
span.style.left = colOffset * cellW + 'px';
|
||||
span.style.top = '0px';
|
||||
span.style.width = cw * cellW + 'px';
|
||||
span.style.height = cellH + 'px';
|
||||
span.style.lineHeight = cellH + 'px';
|
||||
span.style.fontFamily = font.fontFamily;
|
||||
span.style.fontSize = font.fontSize;
|
||||
span.style.fontWeight = font.fontWeight;
|
||||
span.style.color = font.color;
|
||||
if (font.letterSpacing) span.style.letterSpacing = font.letterSpacing;
|
||||
if (compositionFrom !== undefined && index >= compositionFrom) {
|
||||
span.style.textDecoration = 'underline';
|
||||
span.setAttribute('data-zerolag-composition', '');
|
||||
@@ -321,7 +228,7 @@ function makeLine(
|
||||
}
|
||||
span.textContent = ch;
|
||||
el.appendChild(span);
|
||||
edge = nextEdge;
|
||||
colOffset += cw;
|
||||
index++;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { renderOverlay, charCellWidth, stringCellWidth } from '../src/overlay-renderer.js';
|
||||
import type { RenderParams, FontStyle } from '../src/types.js';
|
||||
import { createMockTerminal, lineSpans } from './helpers.js';
|
||||
import { createMockTerminal } from './helpers.js';
|
||||
import { ZerolagInputAddon } from '../src/zerolag-input-addon.js';
|
||||
|
||||
// ─── Shared fixtures ─────────────────────────────────────────────────
|
||||
@@ -47,7 +47,16 @@ function makeParams(overrides: Partial<RenderParams> = {}): RenderParams {
|
||||
|
||||
/** Extract span data from a rendered line div */
|
||||
function getSpans(lineDiv: HTMLDivElement) {
|
||||
return lineSpans(lineDiv).map(({ text, left, width }) => ({ text, left, width }));
|
||||
const spans: { text: string; left: number; width: number }[] = [];
|
||||
for (let i = 0; i < lineDiv.children.length; i++) {
|
||||
const span = lineDiv.children[i] as HTMLSpanElement;
|
||||
spans.push({
|
||||
text: span.textContent || '',
|
||||
left: parseFloat(span.style.left),
|
||||
width: parseFloat(span.style.width),
|
||||
});
|
||||
}
|
||||
return spans;
|
||||
}
|
||||
|
||||
// ─── Addon setup helpers ─────────────────────────────────────────────
|
||||
@@ -190,27 +199,18 @@ describe('Test 2: CJK double-width spacing', () => {
|
||||
|
||||
it('CJK span widths are exactly 2 * cellW pixels', () => {
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(container, makeParams({ lines: ['你'], cellW: 8.5 }));
|
||||
renderOverlay(container, makeParams({ lines: ['你'], cellW: 8.4 }));
|
||||
const lineDiv = container.children[0] as HTMLDivElement;
|
||||
const span = lineDiv.children[0] as HTMLSpanElement;
|
||||
expect(span.style.width).toBe('17px'); // 2 * 8.5
|
||||
expect(span.style.width).toBe('16.8px'); // 2 * 8.4
|
||||
});
|
||||
|
||||
it('ASCII span widths remain 1 * cellW pixels', () => {
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(container, makeParams({ lines: ['a'], cellW: 8.5 }));
|
||||
renderOverlay(container, makeParams({ lines: ['a'], cellW: 8.4 }));
|
||||
const lineDiv = container.children[0] as HTMLDivElement;
|
||||
const span = lineDiv.children[0] as HTMLSpanElement;
|
||||
expect(span.style.width).toBe('8.5px'); // 1 * 8.5
|
||||
});
|
||||
|
||||
it('at a fractional cell width, CJK and ASCII spans still cover 2 and 1 cells to within 1/8 px', () => {
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(container, makeParams({ lines: ['你a'], cellW: 8.4 }));
|
||||
const [wide, narrow] = getSpans(container.children[0] as HTMLDivElement);
|
||||
expect(Math.abs(wide.width - 2 * 8.4)).toBeLessThanOrEqual(0.125);
|
||||
expect(Math.abs(narrow.left - 2 * 8.4)).toBeLessThanOrEqual(0.125);
|
||||
expect(Math.abs(narrow.left + narrow.width - 3 * 8.4)).toBeLessThanOrEqual(0.125);
|
||||
expect(span.style.width).toBe('8.4px'); // 1 * 8.4
|
||||
});
|
||||
|
||||
it('terminal unicode addon is preferred over fallback when available', () => {
|
||||
@@ -644,9 +644,8 @@ describe('Test 6: ASCII input — no regression', () => {
|
||||
const spans = getSpans(lineDiv);
|
||||
|
||||
expect(spans[0].left).toBe(0);
|
||||
// Edges are snapped to a quarter pixel, so within 1/8 px of the grid.
|
||||
expect(Math.abs(spans[1].left - 8.4)).toBeLessThanOrEqual(0.125);
|
||||
expect(Math.abs(spans[2].left - 16.8)).toBeLessThanOrEqual(0.125);
|
||||
expect(spans[1].left).toBeCloseTo(8.4, 5);
|
||||
expect(spans[2].left).toBeCloseTo(16.8, 5);
|
||||
});
|
||||
|
||||
it('ASCII cursor positions at correct column', () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { createMockTerminal, lineSpans } from './helpers.js';
|
||||
import { createMockTerminal } from './helpers.js';
|
||||
import { ZerolagInputAddon } from '../src/zerolag-input-addon.js';
|
||||
|
||||
// setComposition(): IME text still being composed, drawn as an underlined tail
|
||||
@@ -68,9 +68,8 @@ describe('setComposition', () => {
|
||||
expect(span.style.textDecoration).toBe('underline');
|
||||
}
|
||||
// Grid positions continue straight on from the pending text.
|
||||
const cells = lineSpans(line);
|
||||
expect(cells[3].left).toBe(3 * CELL_W);
|
||||
expect(cells[4].left).toBe(4 * CELL_W);
|
||||
expect(spans[3].style.left).toBe(3 * CELL_W + 'px');
|
||||
expect(spans[4].style.left).toBe(4 * CELL_W + 'px');
|
||||
expect(overlay.style.display).toBe('');
|
||||
});
|
||||
|
||||
@@ -80,10 +79,9 @@ describe('setComposition', () => {
|
||||
addon.setComposition('天気');
|
||||
const spans = Array.from(lineDivs(overlay)[0].children) as HTMLSpanElement[];
|
||||
expect(spans.map((s) => s.textContent).join('')).toBe('今日は天気');
|
||||
const cells = lineSpans(lineDivs(overlay)[0]);
|
||||
expect(cells[3].left).toBe(6 * CELL_W);
|
||||
expect(cells[3].width).toBe(2 * CELL_W);
|
||||
expect(cells[4].left).toBe(8 * CELL_W);
|
||||
expect(spans[3].style.left).toBe(6 * CELL_W + 'px');
|
||||
expect(spans[3].style.width).toBe(2 * CELL_W + 'px');
|
||||
expect(spans[4].style.left).toBe(8 * CELL_W + 'px');
|
||||
});
|
||||
|
||||
it('does not touch pendingText, hasPending, flushed state or the state snapshot', () => {
|
||||
@@ -117,8 +115,7 @@ describe('setComposition', () => {
|
||||
expect(compositionText(overlay)).toBe('WXYZ');
|
||||
const second = Array.from(lines[1].children) as HTMLSpanElement[];
|
||||
expect(second.every((s) => s.hasAttribute('data-zerolag-composition'))).toBe(true);
|
||||
expect(lineSpans(lines[1])[0].left).toBe(0);
|
||||
expect(lines[1].style.left).toBe('0px');
|
||||
expect(second[0].style.left).toBe('0px');
|
||||
});
|
||||
|
||||
it('keeps the composition styling when only the tail of a tall prompt fits', () => {
|
||||
|
||||
@@ -204,21 +204,3 @@ export function createMockTerminal(opts: MockTerminalOptions = {}) {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The glyph spans of a rendered overlay line, with each one's left edge and
|
||||
* width in px. The spans sit side by side in a flex row, so a span's left
|
||||
* edge is the sum of the widths before it (what the browser lays out), not a
|
||||
* style property of its own; jsdom does no layout, so this adds them up.
|
||||
*/
|
||||
export function lineSpans(lineDiv: Element): { text: string; left: number; width: number; el: HTMLSpanElement }[] {
|
||||
const spans: { text: string; left: number; width: number; el: HTMLSpanElement }[] = [];
|
||||
let left = 0;
|
||||
for (const child of Array.from(lineDiv.children)) {
|
||||
const el = child as HTMLSpanElement;
|
||||
const width = parseFloat(el.style.width);
|
||||
spans.push({ text: el.textContent || '', left, width, el });
|
||||
left += width;
|
||||
}
|
||||
return spans;
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { renderOverlay, charCellWidth, stringCellWidth } from '../src/overlay-renderer.js';
|
||||
import type { RenderParams, FontStyle } from '../src/types.js';
|
||||
import { lineSpans } from './helpers.js';
|
||||
|
||||
const FONT: FontStyle = {
|
||||
fontFamily: 'monospace',
|
||||
@@ -39,7 +38,7 @@ describe('renderOverlay', () => {
|
||||
|
||||
it('creates per-character spans in a line div', () => {
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(container, makeParams({ lines: ['abc'], cellW: 10 }));
|
||||
renderOverlay(container, makeParams({ lines: ['abc'] }));
|
||||
|
||||
// Line div + cursor span
|
||||
expect(container.children.length).toBe(2);
|
||||
@@ -47,43 +46,17 @@ describe('renderOverlay', () => {
|
||||
const lineDiv = container.children[0] as HTMLDivElement;
|
||||
expect(lineDiv.children.length).toBe(3); // a, b, c
|
||||
|
||||
const [a, b, c] = lineSpans(lineDiv);
|
||||
expect([a.text, a.left, a.width]).toEqual(['a', 0, 10]);
|
||||
expect([b.text, b.left, b.width]).toEqual(['b', 10, 10]);
|
||||
expect([c.text, c.left, c.width]).toEqual(['c', 20, 10]);
|
||||
});
|
||||
const spanA = lineDiv.children[0] as HTMLSpanElement;
|
||||
expect(spanA.textContent).toBe('a');
|
||||
expect(spanA.style.left).toBe('0px');
|
||||
|
||||
it('lays the spans out in a flex row, not absolutely positioned (one paint layer per line)', () => {
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(container, makeParams({ lines: ['ab'] }));
|
||||
const lineDiv = container.children[0] as HTMLDivElement;
|
||||
expect(lineDiv.style.position).toBe('absolute');
|
||||
expect(lineDiv.style.display).toBe('flex');
|
||||
for (const { el } of lineSpans(lineDiv)) {
|
||||
expect(el.style.position).toBe('');
|
||||
expect(el.style.left).toBe('');
|
||||
// flex: none, so a span is never stretched or squeezed off its cells.
|
||||
expect(el.style.flexGrow).toBe('0');
|
||||
expect(el.style.flexShrink).toBe('0');
|
||||
}
|
||||
});
|
||||
const spanB = lineDiv.children[1] as HTMLSpanElement;
|
||||
expect(spanB.textContent).toBe('b');
|
||||
expect(spanB.style.left).toBe('8.4px');
|
||||
|
||||
it('snaps cell edges to a quarter pixel so a fractional cell width never drifts', () => {
|
||||
const container = document.createElement('div');
|
||||
const cellW = 8.4;
|
||||
renderOverlay(container, makeParams({ lines: ['x'.repeat(78) + '你'], startCol: 0, totalCols: 80, cellW }));
|
||||
const spans = lineSpans(container.children[0]);
|
||||
let col = 0;
|
||||
for (const span of spans) {
|
||||
// Every width is a whole number of quarter pixels, which Blink, WebKit
|
||||
// and Gecko all store exactly, so the browser adds them up exactly too.
|
||||
expect(Number.isInteger(span.width * 4)).toBe(true);
|
||||
expect(Math.abs(span.left - col * cellW)).toBeLessThanOrEqual(0.125);
|
||||
col += span.text === '你' ? 2 : 1;
|
||||
}
|
||||
// The far edge is still on the grid after 80 columns.
|
||||
const last = spans[spans.length - 1];
|
||||
expect(Math.abs(last.left + last.width - 80 * cellW)).toBeLessThanOrEqual(0.125);
|
||||
const spanC = lineDiv.children[2] as HTMLSpanElement;
|
||||
expect(spanC.textContent).toBe('c');
|
||||
expect(spanC.style.left).toBe('16.8px');
|
||||
});
|
||||
|
||||
it('sets span width to cellW', () => {
|
||||
@@ -94,7 +67,7 @@ describe('renderOverlay', () => {
|
||||
expect(span.style.width).toBe('9.5px');
|
||||
});
|
||||
|
||||
it('applies font styles once on the line, for its spans to inherit', () => {
|
||||
it('applies font styles to spans', () => {
|
||||
const font: FontStyle = {
|
||||
fontFamily: 'Fira Code',
|
||||
fontSize: '16px',
|
||||
@@ -110,16 +83,12 @@ describe('renderOverlay', () => {
|
||||
// jsdom normalizes hex to rgb()
|
||||
expect(lineDiv.style.backgroundColor).toBe('rgb(0, 0, 0)');
|
||||
|
||||
expect(lineDiv.style.fontFamily).toBe('Fira Code');
|
||||
expect(lineDiv.style.fontSize).toBe('16px');
|
||||
expect(lineDiv.style.fontWeight).toBe('bold');
|
||||
expect(lineDiv.style.color).toBe('rgb(255, 0, 0)');
|
||||
expect(lineDiv.style.letterSpacing).toBe('0.5px');
|
||||
|
||||
// Inherited, so a span sets none of them itself.
|
||||
const span = lineDiv.children[0] as HTMLSpanElement;
|
||||
expect(span.style.fontFamily).toBe('');
|
||||
expect(span.style.color).toBe('');
|
||||
expect(span.style.fontFamily).toBe('Fira Code');
|
||||
expect(span.style.fontSize).toBe('16px');
|
||||
expect(span.style.fontWeight).toBe('bold');
|
||||
expect(span.style.color).toBe('rgb(255, 0, 0)');
|
||||
expect(span.style.letterSpacing).toBe('0.5px');
|
||||
});
|
||||
|
||||
it('offsets first line by startCol', () => {
|
||||
@@ -248,49 +217,47 @@ describe('renderOverlay', () => {
|
||||
const lineDiv = container.children[0] as HTMLDivElement;
|
||||
const span = lineDiv.children[0] as HTMLSpanElement;
|
||||
expect(span.style.height).toBe('19px');
|
||||
// Set on the line and inherited by the span.
|
||||
expect(lineDiv.style.lineHeight).toBe('19px');
|
||||
expect(span.style.lineHeight).toBe('19px');
|
||||
});
|
||||
|
||||
it('span has no vertical offset and no transform', () => {
|
||||
it('span top is 0px (no vertical offset / no transform)', () => {
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(container, makeParams({ lines: ['a'], cellH: 19 }));
|
||||
const lineDiv = container.children[0] as HTMLDivElement;
|
||||
const span = lineDiv.children[0] as HTMLSpanElement;
|
||||
// A flex item starts at the top of its line; nothing shifts it down.
|
||||
expect(span.style.top).toBe('');
|
||||
expect(span.style.marginTop).toBe('');
|
||||
expect(span.style.top).toBe('0px');
|
||||
// No translateY transform — sub-pixel overhang causes artifacts
|
||||
expect(span.style.transform).toBe('');
|
||||
});
|
||||
|
||||
// ─── Font rendering tests ─────────────────────────────────────
|
||||
|
||||
// These are inherited properties, set once on the line for every span.
|
||||
|
||||
it('line disables ligatures via font-feature-settings', () => {
|
||||
it('span disables ligatures via font-feature-settings', () => {
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(container, makeParams({ lines: ['fi'] }));
|
||||
const lineDiv = container.children[0] as HTMLDivElement;
|
||||
const span = lineDiv.children[0] as HTMLSpanElement;
|
||||
// Check cssText includes the ligature-disabling settings
|
||||
// jsdom may normalize whitespace; check that both liga and calt are disabled
|
||||
expect(lineDiv.style.cssText).toContain('font-feature-settings:');
|
||||
expect(lineDiv.style.cssText).toContain("'liga' 0");
|
||||
expect(lineDiv.style.cssText).toContain("'calt' 0");
|
||||
expect(span.style.cssText).toContain('font-feature-settings:');
|
||||
expect(span.style.cssText).toContain("'liga' 0");
|
||||
expect(span.style.cssText).toContain("'calt' 0");
|
||||
});
|
||||
|
||||
it('line has text-align: center for glyph centering', () => {
|
||||
it('span has text-align: center for glyph centering', () => {
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(container, makeParams({ lines: ['m'] }));
|
||||
const lineDiv = container.children[0] as HTMLDivElement;
|
||||
expect(lineDiv.style.textAlign).toBe('center');
|
||||
const span = lineDiv.children[0] as HTMLSpanElement;
|
||||
expect(span.style.textAlign).toBe('center');
|
||||
});
|
||||
|
||||
it('line has pointer-events: none', () => {
|
||||
it('span has pointer-events: none', () => {
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(container, makeParams({ lines: ['a'] }));
|
||||
const lineDiv = container.children[0] as HTMLDivElement;
|
||||
expect(lineDiv.style.pointerEvents).toBe('none');
|
||||
const span = lineDiv.children[0] as HTMLSpanElement;
|
||||
expect(span.style.pointerEvents).toBe('none');
|
||||
});
|
||||
|
||||
// ─── Multi-line cursor positioning ────────────────────────────
|
||||
@@ -356,18 +323,20 @@ describe('renderOverlay', () => {
|
||||
const lineDiv = container.children[0] as HTMLDivElement;
|
||||
expect(lineDiv.children.length).toBe(3);
|
||||
|
||||
const [spanA, spanCJK, spanB] = lineSpans(lineDiv);
|
||||
expect(spanA.text).toBe('a');
|
||||
expect(spanA.left).toBe(0);
|
||||
expect(spanA.width).toBe(10); // 1 cell
|
||||
const spanA = lineDiv.children[0] as HTMLSpanElement;
|
||||
expect(spanA.textContent).toBe('a');
|
||||
expect(spanA.style.left).toBe('0px');
|
||||
expect(spanA.style.width).toBe('10px'); // 1 cell
|
||||
|
||||
expect(spanCJK.text).toBe('你');
|
||||
expect(spanCJK.left).toBe(10); // col 1
|
||||
expect(spanCJK.width).toBe(20); // 2 cells
|
||||
const spanCJK = lineDiv.children[1] as HTMLSpanElement;
|
||||
expect(spanCJK.textContent).toBe('你');
|
||||
expect(spanCJK.style.left).toBe('10px'); // col 1
|
||||
expect(spanCJK.style.width).toBe('20px'); // 2 cells
|
||||
|
||||
expect(spanB.text).toBe('b');
|
||||
expect(spanB.left).toBe(30); // col 3
|
||||
expect(spanB.width).toBe(10); // 1 cell
|
||||
const spanB = lineDiv.children[2] as HTMLSpanElement;
|
||||
expect(spanB.textContent).toBe('b');
|
||||
expect(spanB.style.left).toBe('30px'); // col 3
|
||||
expect(spanB.style.width).toBe('10px'); // 1 cell
|
||||
});
|
||||
|
||||
it('cursor position accounts for CJK width', () => {
|
||||
@@ -391,11 +360,13 @@ describe('renderOverlay', () => {
|
||||
renderOverlay(container, makeParams({ lines: ['hi你'], cellW: 8 }));
|
||||
const lineDiv = container.children[0] as HTMLDivElement;
|
||||
// h(col 0), i(col 1), 你(col 2, width 2)
|
||||
const [spanH, spanI, spanCJK] = lineSpans(lineDiv);
|
||||
expect(spanH.left).toBe(0);
|
||||
expect(spanI.left).toBe(8);
|
||||
expect(spanCJK.left).toBe(16);
|
||||
expect(spanCJK.width).toBe(16);
|
||||
const spanH = lineDiv.children[0] as HTMLSpanElement;
|
||||
expect(spanH.style.left).toBe('0px');
|
||||
const spanI = lineDiv.children[1] as HTMLSpanElement;
|
||||
expect(spanI.style.left).toBe('8px');
|
||||
const spanCJK = lineDiv.children[2] as HTMLSpanElement;
|
||||
expect(spanCJK.style.left).toBe('16px');
|
||||
expect(spanCJK.style.width).toBe('16px');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -532,147 +503,3 @@ describe('renderOverlay — staying on screen (totalRows)', () => {
|
||||
expect(container.style.top).toBe(6 * 17 + 'px');
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderOverlay: rebuilding only the lines that changed', () => {
|
||||
const lineEls = (container: HTMLDivElement) =>
|
||||
Array.from(container.children).filter((el) => el.tagName === 'DIV') as HTMLDivElement[];
|
||||
const text = (line: HTMLDivElement) => Array.from(line.children, (s) => s.textContent).join('');
|
||||
|
||||
it('appending to the last line keeps every earlier line element', () => {
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(container, makeParams({ lines: ['first', 'second', 'th'] }));
|
||||
const [first, second, third] = lineEls(container);
|
||||
|
||||
renderOverlay(container, makeParams({ lines: ['first', 'second', 'thi'] }));
|
||||
const after = lineEls(container);
|
||||
expect(after[0]).toBe(first);
|
||||
expect(after[1]).toBe(second);
|
||||
expect(after[2]).not.toBe(third);
|
||||
expect(after.map(text)).toEqual(['first', 'second', 'thi']);
|
||||
});
|
||||
|
||||
it('keeps the cursor as the last child and moves it with the text', () => {
|
||||
const container = document.createElement('div');
|
||||
const params = { startCol: 0, cellW: 10 };
|
||||
renderOverlay(container, makeParams({ ...params, lines: ['ab'] }));
|
||||
renderOverlay(container, makeParams({ ...params, lines: ['ab', 'c'] }));
|
||||
const children = Array.from(container.children) as HTMLElement[];
|
||||
expect(children.length).toBe(3);
|
||||
const cursor = children[2];
|
||||
expect(cursor.tagName).toBe('SPAN');
|
||||
expect(cursor.style.left).toBe('10px');
|
||||
expect(cursor.style.top).toBe(17 + 'px');
|
||||
expect(container.querySelectorAll(':scope > span').length).toBe(1);
|
||||
});
|
||||
|
||||
it('a wrapped line coming back (backspace) removes the extra line', () => {
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(container, makeParams({ lines: ['abc', 'd'] }));
|
||||
const [first] = lineEls(container);
|
||||
renderOverlay(container, makeParams({ lines: ['abc'] }));
|
||||
expect(lineEls(container)).toEqual([first]);
|
||||
expect(container.children.length).toBe(2); // line + cursor
|
||||
});
|
||||
|
||||
it('removes the cursor when the next render does not show one', () => {
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(container, makeParams({ lines: ['ab'] }));
|
||||
renderOverlay(container, makeParams({ lines: ['ab'], showCursor: false }));
|
||||
expect(container.children.length).toBe(1);
|
||||
expect(container.children[0].tagName).toBe('DIV');
|
||||
});
|
||||
|
||||
it('a font, colour or cell size change rebuilds every line', () => {
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(container, makeParams({ lines: ['one', 'two'] }));
|
||||
const before = lineEls(container);
|
||||
|
||||
renderOverlay(container, makeParams({ lines: ['one', 'two'], font: { ...FONT, color: '#ff0000' } }));
|
||||
const recoloured = lineEls(container);
|
||||
expect(recoloured[0]).not.toBe(before[0]);
|
||||
expect(recoloured[0].style.color).toBe('rgb(255, 0, 0)');
|
||||
|
||||
renderOverlay(container, makeParams({ lines: ['one', 'two'], font: { ...FONT, color: '#ff0000' }, cellW: 12 }));
|
||||
const resized = lineEls(container);
|
||||
expect(resized[1]).not.toBe(recoloured[1]);
|
||||
expect(lineSpans(resized[1])[1].left).toBe(12);
|
||||
});
|
||||
|
||||
it('a Unicode version switch rebuilds lines whose text did not change', () => {
|
||||
const container = document.createElement('div');
|
||||
const unicode = { activeVersion: '6', getStringCellWidth: (s: string) => (s === 'W' ? 1 : 1) };
|
||||
const terminal = { unicode } as any;
|
||||
renderOverlay(container, makeParams({ lines: ['aW'], cellW: 10, terminal }));
|
||||
const before = lineEls(container)[0];
|
||||
|
||||
unicode.activeVersion = '11';
|
||||
unicode.getStringCellWidth = (s: string) => (s === 'W' ? 2 : 1);
|
||||
renderOverlay(container, makeParams({ lines: ['aW'], cellW: 10, terminal }));
|
||||
const after = lineEls(container)[0];
|
||||
expect(after).not.toBe(before);
|
||||
expect(lineSpans(after)[1].width).toBe(20);
|
||||
});
|
||||
|
||||
it('starts over when something else emptied the container', () => {
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(container, makeParams({ lines: ['one', 'two'] }));
|
||||
container.innerHTML = '';
|
||||
renderOverlay(container, makeParams({ lines: ['one', 'two'] }));
|
||||
expect(lineEls(container).map(text)).toEqual(['one', 'two']);
|
||||
expect(container.children.length).toBe(3);
|
||||
});
|
||||
|
||||
it('starts over when something else reordered the lines', () => {
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(container, makeParams({ lines: ['one', 'two'] }));
|
||||
const [one, two] = lineEls(container);
|
||||
container.insertBefore(two, one);
|
||||
renderOverlay(container, makeParams({ lines: ['one', 'two'] }));
|
||||
expect(lineEls(container).map(text)).toEqual(['one', 'two']);
|
||||
expect(container.children.length).toBe(3);
|
||||
});
|
||||
|
||||
it('starts over when something else added to the container', () => {
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(container, makeParams({ lines: ['one'] }));
|
||||
container.appendChild(document.createElement('div'));
|
||||
renderOverlay(container, makeParams({ lines: ['one'] }));
|
||||
expect(container.children.length).toBe(2);
|
||||
expect(text(lineEls(container)[0])).toBe('one');
|
||||
});
|
||||
|
||||
it('a composition growing on the last line leaves the lines it does not reach alone', () => {
|
||||
const container = document.createElement('div');
|
||||
// 'first' (5) + 'abc' pending, composition starts at code point 8.
|
||||
renderOverlay(container, makeParams({ lines: ['first', 'abcx'], compositionStart: 8 }));
|
||||
const [first] = lineEls(container);
|
||||
renderOverlay(container, makeParams({ lines: ['first', 'abcxy'], compositionStart: 8 }));
|
||||
const after = lineEls(container);
|
||||
expect(after[0]).toBe(first);
|
||||
const marked = Array.from(after[1].children).filter((s) => s.hasAttribute('data-zerolag-composition'));
|
||||
expect(marked.map((s) => s.textContent).join('')).toBe('xy');
|
||||
});
|
||||
|
||||
it('a composition that starts on an earlier line marks the whole later line', () => {
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(container, makeParams({ lines: ['abXY', 'ZW'], compositionStart: 2 }));
|
||||
const [first, second] = lineEls(container);
|
||||
const marked = (line: HTMLDivElement) =>
|
||||
Array.from(line.children)
|
||||
.filter((s) => s.hasAttribute('data-zerolag-composition'))
|
||||
.map((s) => s.textContent)
|
||||
.join('');
|
||||
expect(marked(first)).toBe('XY');
|
||||
expect(marked(second)).toBe('ZW');
|
||||
});
|
||||
|
||||
it('two containers keep separate records', () => {
|
||||
const a = document.createElement('div');
|
||||
const b = document.createElement('div');
|
||||
renderOverlay(a, makeParams({ lines: ['same'] }));
|
||||
renderOverlay(b, makeParams({ lines: ['same'] }));
|
||||
expect(lineEls(a)[0]).not.toBe(lineEls(b)[0]);
|
||||
expect(text(lineEls(a)[0])).toBe('same');
|
||||
expect(text(lineEls(b)[0])).toBe('same');
|
||||
});
|
||||
});
|
||||
@@ -2835,6 +2835,29 @@ class CodemanApp {
|
||||
a.setAttribute('target', '_blank');
|
||||
a.setAttribute('rel', 'noopener noreferrer');
|
||||
});
|
||||
// Right-to-left text (issue #715). With no `dir`, every block was laid
|
||||
// out left to right, so a Persian, Arabic or Hebrew answer was
|
||||
// left-aligned, its punctuation landed on the wrong end and its lists
|
||||
// and quotes hung off the left edge. Each OUTERMOST prose block now
|
||||
// takes its direction from its own first letter (`dir="auto"`, what
|
||||
// GitHub does for rendered markdown), so an RTL paragraph beside an
|
||||
// English one reads correctly in both; nested blocks inherit, which
|
||||
// keeps a list's markers and indent on one side. A `dir` the agent
|
||||
// wrote (the sanitizer allows it) is kept, and so is what it covers.
|
||||
//
|
||||
// Code and preformatted text are always left to right. `dir="ltr"`
|
||||
// also isolates them, and `dir="auto"` skips any descendant that
|
||||
// carries a `dir`, so `npm install` at the head of a Persian sentence
|
||||
// no longer turns the whole sentence left to right.
|
||||
tmpl.content
|
||||
.querySelectorAll('p, h1, h2, h3, h4, h5, h6, ul, ol, dl, blockquote, .rv-table-wrap')
|
||||
.forEach((el) => {
|
||||
if (el.parentElement?.closest('[dir]') || el.hasAttribute('dir')) return;
|
||||
el.setAttribute('dir', 'auto');
|
||||
});
|
||||
tmpl.content.querySelectorAll('pre, code').forEach((el) => {
|
||||
if (!el.hasAttribute('dir')) el.setAttribute('dir', 'ltr');
|
||||
});
|
||||
return tmpl.innerHTML;
|
||||
} catch { /* fall through */ }
|
||||
}
|
||||
@@ -3064,6 +3087,9 @@ class CodemanApp {
|
||||
link.dataset.path = path;
|
||||
link.dataset.linkified = '1';
|
||||
if (folder) link.dataset.folder = '1';
|
||||
// A path reads left to right, and in a right-to-left sentence it must
|
||||
// not vote on the sentence's direction (`_renderMarkdown`, issue #715).
|
||||
link.dir = 'ltr';
|
||||
link.title = path;
|
||||
link.textContent = path;
|
||||
frag.appendChild(link);
|
||||
|
||||
@@ -1296,7 +1296,7 @@ const KeyboardAccessoryBar = {
|
||||
<strong>Compose prompt</strong>
|
||||
<span>Enter adds a new line</span>
|
||||
</div>
|
||||
<textarea class="paste-textarea prompt-composer-textarea" aria-label="Prompt" placeholder="Write your prompt…" autocorrect="on" autocapitalize="sentences" spellcheck="true"></textarea>
|
||||
<textarea class="paste-textarea prompt-composer-textarea" dir="auto" aria-label="Prompt" placeholder="Write your prompt…" autocorrect="on" autocapitalize="sentences" spellcheck="true"></textarea>
|
||||
<div class="paste-actions prompt-composer-actions">
|
||||
<button type="button" class="prompt-composer-terminal">Use terminal keyboard</button>
|
||||
<button type="button" class="paste-image">📎 Attach</button>
|
||||
@@ -1309,6 +1309,8 @@ const KeyboardAccessoryBar = {
|
||||
|
||||
this._composerOverlay = overlay;
|
||||
const textarea = overlay.querySelector('.prompt-composer-textarea');
|
||||
// The textarea's dir="auto" lays a prompt written in Persian, Arabic or
|
||||
// Hebrew out right to left, from its first letter (#715).
|
||||
// i18n.js skips <textarea> subtrees (what is typed there is user content),
|
||||
// so the placeholder and label are translated here, when the dialog is built.
|
||||
const i18n = typeof window !== 'undefined' ? window.CodemanI18n : undefined;
|
||||
|
||||
@@ -15085,7 +15085,7 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
|
||||
.rv-text ul, .rv-text ol,
|
||||
.response-viewer-body > ul, .response-viewer-body > ol {
|
||||
margin: 0.6em 0;
|
||||
padding-left: 1.5em;
|
||||
padding-inline-start: 1.5em;
|
||||
}
|
||||
|
||||
.rv-text li,
|
||||
@@ -15097,12 +15097,16 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
|
||||
|
||||
.rv-text blockquote,
|
||||
.response-viewer-body > blockquote {
|
||||
border-left: 3px solid var(--accent);
|
||||
border-inline-start: 3px solid var(--accent);
|
||||
background: rgba(var(--accent-rgb), 0.06);
|
||||
margin: 0.8em 0;
|
||||
padding: 0.5em 14px;
|
||||
color: var(--text-dim);
|
||||
border-radius: 0 6px 6px 0;
|
||||
/* Logical corners: the bar sits on the start side, right-to-left too (#715). */
|
||||
border-start-start-radius: 0;
|
||||
border-end-start-radius: 0;
|
||||
border-start-end-radius: 6px;
|
||||
border-end-end-radius: 6px;
|
||||
}
|
||||
|
||||
.rv-text strong,
|
||||
@@ -15172,9 +15176,9 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
|
||||
.response-viewer-body > table td,
|
||||
.rv-table-wrap th, .rv-table-wrap td {
|
||||
border-bottom: 1px solid var(--border);
|
||||
border-right: 1px solid var(--border);
|
||||
border-inline-end: 1px solid var(--border);
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
text-align: start;
|
||||
vertical-align: top;
|
||||
white-space: normal;
|
||||
}
|
||||
@@ -15183,7 +15187,7 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
|
||||
.response-viewer-body > table th:last-child,
|
||||
.response-viewer-body > table td:last-child,
|
||||
.rv-table-wrap th:last-child, .rv-table-wrap td:last-child {
|
||||
border-right: none;
|
||||
border-inline-end: none;
|
||||
}
|
||||
|
||||
.rv-text tr:last-child td,
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* @fileoverview Right-to-left text in rendered markdown (`CodemanApp._renderMarkdown`, issue #715).
|
||||
*
|
||||
* The Response Viewer and the File Viewer's markdown view rendered every block left to
|
||||
* right, so a Persian, Arabic or Hebrew answer was left-aligned, its punctuation landed on
|
||||
* the wrong end and its list markers sat on the left. `_renderMarkdown` now gives each
|
||||
* OUTERMOST prose block `dir="auto"` (the browser picks the direction from the block's own
|
||||
* first letter) and pins code to `dir="ltr"`, which also keeps a code span from deciding a
|
||||
* sentence's direction, since `dir="auto"` skips descendants that carry a `dir`.
|
||||
*
|
||||
* jsdom does not resolve `dir="auto"`, so this pins the attributes the browser acts on;
|
||||
* what Chromium makes of them was checked by eye when the change was made.
|
||||
*
|
||||
* Drives the SHIPPING artifacts (vendored `marked`, vendored DOMPurify + `sanitize-html.js`,
|
||||
* `constants.js` and `app.js`) in a `vm` with a jsdom document, the technique from
|
||||
* response-viewer-external-links.test.ts. No port / server needed.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const publicFile = (name: string) => readFileSync(resolve(import.meta.dirname, '../src/web/public', name), 'utf8');
|
||||
|
||||
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>');
|
||||
const jsdomWindow = dom.window as unknown as Window & typeof globalThis;
|
||||
const { document, NodeFilter } = dom.window;
|
||||
|
||||
function loadShippingSanitizer(): (html: string) => string {
|
||||
const dpModule: { exports: unknown } = { exports: {} };
|
||||
// eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func
|
||||
new Function('module', 'exports', publicFile('vendor/dompurify.min.js'))(dpModule, dpModule.exports);
|
||||
const DOMPurify = (dpModule.exports as (win: unknown) => unknown)(jsdomWindow);
|
||||
|
||||
const sanModule: { exports: { createMarkdownSanitizer?: (dp: unknown) => (html: string) => string } } = {
|
||||
exports: {},
|
||||
};
|
||||
// eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func
|
||||
new Function('module', 'exports', publicFile('sanitize-html.js'))(sanModule, sanModule.exports);
|
||||
const create = sanModule.exports.createMarkdownSanitizer;
|
||||
if (typeof create !== 'function') throw new Error('createMarkdownSanitizer not exported');
|
||||
return create(DOMPurify);
|
||||
}
|
||||
|
||||
function loadShippingMarked(): { parse: (src: string, opts?: unknown) => string } {
|
||||
const module: { exports: unknown } = { exports: {} };
|
||||
// eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func
|
||||
new Function('module', 'exports', publicFile('vendor/marked.min.js'))(module, module.exports);
|
||||
return module.exports as { parse: (src: string, opts?: unknown) => string };
|
||||
}
|
||||
|
||||
type RenderApp = {
|
||||
_renderMarkdown(text: string, opts?: { breaks?: boolean }): string;
|
||||
_linkifyFilePaths(root: unknown): void;
|
||||
};
|
||||
|
||||
function loadCodemanAppClass(): { prototype: RenderApp } {
|
||||
const context = vm.createContext({
|
||||
console,
|
||||
performance,
|
||||
setInterval: vi.fn(),
|
||||
clearInterval: vi.fn(),
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
requestAnimationFrame: vi.fn(),
|
||||
HTMLCanvasElement: class HTMLCanvasElement {},
|
||||
fetch: vi.fn(),
|
||||
document,
|
||||
NodeFilter,
|
||||
localStorage: { length: 0, key: vi.fn(), getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() },
|
||||
// _sanitizeHtml fails closed without the sanitizer, which would make every
|
||||
// assertion below vacuous.
|
||||
window: { addEventListener: vi.fn(), removeEventListener: vi.fn(), sanitizeMarkdownHtml: loadShippingSanitizer() },
|
||||
marked: loadShippingMarked(),
|
||||
MobileDetection: {},
|
||||
});
|
||||
vm.runInContext(
|
||||
`${publicFile('constants.js')}\n${publicFile('app.js')}\nglobalThis.__CodemanApp = CodemanApp;`,
|
||||
context
|
||||
);
|
||||
return (context as { __CodemanApp: { prototype: RenderApp } }).__CodemanApp;
|
||||
}
|
||||
|
||||
const CodemanApp = loadCodemanAppClass();
|
||||
const app = Object.create(CodemanApp.prototype) as RenderApp;
|
||||
|
||||
/** Render markdown the way the response viewer does and return the resulting element. */
|
||||
function render(markdown: string, opts?: { breaks?: boolean }): HTMLElement {
|
||||
const root = document.createElement('div');
|
||||
root.className = 'rv-text';
|
||||
root.innerHTML = app._renderMarkdown(markdown, opts);
|
||||
return root as unknown as HTMLElement;
|
||||
}
|
||||
|
||||
const dirOf = (el: Element | null) => el?.getAttribute('dir') ?? null;
|
||||
|
||||
describe('response viewer right-to-left text (#715)', () => {
|
||||
it('lets every top-level prose block pick its own direction', () => {
|
||||
const root = render(
|
||||
[
|
||||
'# عنوان',
|
||||
'',
|
||||
'سلام، این یک تست است.',
|
||||
'',
|
||||
'An English paragraph.',
|
||||
'',
|
||||
'- مورد اول',
|
||||
'- مورد دوم',
|
||||
'',
|
||||
'1. one',
|
||||
'',
|
||||
'> نقل قول',
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
expect(dirOf(root.querySelector('h1'))).toBe('auto');
|
||||
const paragraphs = Array.from(root.querySelectorAll(':scope > p'));
|
||||
expect(paragraphs).toHaveLength(2);
|
||||
for (const p of paragraphs) expect(dirOf(p)).toBe('auto');
|
||||
expect(dirOf(root.querySelector('ul'))).toBe('auto');
|
||||
expect(dirOf(root.querySelector('ol'))).toBe('auto');
|
||||
expect(dirOf(root.querySelector('blockquote'))).toBe('auto');
|
||||
});
|
||||
|
||||
it('leaves nested blocks to inherit, so a list keeps its markers and indent on one side', () => {
|
||||
// A `dir="auto"` per item would resolve each item on its own while the list's
|
||||
// padding stays on the list's side, which hung RTL markers outside the box.
|
||||
const root = render(['- مورد اول', ' - تو در تو', '', '> پاراگراف اول', '>', '> پاراگراف دوم'].join('\n'));
|
||||
|
||||
for (const li of Array.from(root.querySelectorAll('li'))) expect(li.hasAttribute('dir')).toBe(false);
|
||||
expect(root.querySelector('ul ul')?.hasAttribute('dir')).toBe(false);
|
||||
for (const p of Array.from(root.querySelectorAll('blockquote p'))) expect(p.hasAttribute('dir')).toBe(false);
|
||||
});
|
||||
|
||||
it('gives a table its direction on the scroll wrapper, so an RTL table scrolls from its first column', () => {
|
||||
const root = render('| ستون | مقدار |\n| --- | --- |\n| الف | ۱ |');
|
||||
|
||||
const wrap = root.querySelector('.rv-table-wrap');
|
||||
expect(wrap, 'tables are still wrapped').not.toBeNull();
|
||||
expect(dirOf(wrap)).toBe('auto');
|
||||
expect(root.querySelector('table')?.hasAttribute('dir')).toBe(false);
|
||||
for (const cell of Array.from(root.querySelectorAll('th, td'))) expect(cell.hasAttribute('dir')).toBe(false);
|
||||
});
|
||||
|
||||
it('pins code blocks and inline code left to right', () => {
|
||||
const root = render('`npm install` را اجرا کن.\n\n```\nconst a = 1;\n```');
|
||||
|
||||
expect(dirOf(root.querySelector('p > code'))).toBe('ltr');
|
||||
expect(dirOf(root.querySelector('pre'))).toBe('ltr');
|
||||
// The code-block toolbar shares the template walk; it must survive the new pass.
|
||||
expect(root.querySelector('.rv-code-wrap .rv-copy-btn')).not.toBeNull();
|
||||
expect(root.querySelector('.rv-code-wrap')?.hasAttribute('dir')).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps a dir the agent wrote, and what it covers', () => {
|
||||
// The sanitizer allows `dir`; an explicit choice must not be replaced by a guess.
|
||||
const root = render('<div dir="rtl"><p>inherits</p></div>\n\n<p dir="ltr">explicit</p>');
|
||||
|
||||
expect(dirOf(root.querySelector('div[dir]'))).toBe('rtl');
|
||||
expect(root.querySelector('div[dir] > p')?.hasAttribute('dir')).toBe(false);
|
||||
expect(dirOf(Array.from(root.querySelectorAll('p')).find((p) => p.textContent === 'explicit') ?? null)).toBe('ltr');
|
||||
});
|
||||
|
||||
it('does the same for the File Viewer markdown view (breaks: false)', () => {
|
||||
const root = render('پاراگراف اول\nادامه همان پاراگراف', { breaks: false });
|
||||
|
||||
expect(dirOf(root.querySelector('p'))).toBe('auto');
|
||||
});
|
||||
|
||||
it('pins a linkified file path left to right, so it does not decide the sentence direction', () => {
|
||||
const root = render('فایل /home/user/project/config.json را باز کن.');
|
||||
app._linkifyFilePaths(root);
|
||||
|
||||
const link = root.querySelector('a.rv-path');
|
||||
expect(link?.textContent).toBe('/home/user/project/config.json');
|
||||
expect(dirOf(link)).toBe('ltr');
|
||||
});
|
||||
|
||||
it('does not pin a markdown link label, which is prose in the sentence language', () => {
|
||||
const root = render('[گزارش حادثه](/tmp/report.json)');
|
||||
app._linkifyFilePaths(root);
|
||||
|
||||
const link = root.querySelector('a.rv-path-link');
|
||||
expect(link, 'a path-target link takes the rv-path shape').not.toBeNull();
|
||||
expect(link?.hasAttribute('dir')).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user