From 4cd2926e57331430fc2c1bb5f7f43b392bb4395a Mon Sep 17 00:00:00 2001 From: Hako Date: Thu, 1 Oct 2026 23:01:35 +0900 Subject: [PATCH 1/4] fix(terminal): draw CJK characters to fill their cells on iPhone --- CHANGELOG.md | 5 ++ src/components/PaneTerminal.tsx | 3 + src/lib/terminalGlyphs.test.ts | 50 ++++++++++++++ src/lib/terminalGlyphs.ts | 118 ++++++++++++++++++++++++++++++++ 4 files changed, 176 insertions(+) create mode 100644 src/lib/terminalGlyphs.test.ts create mode 100644 src/lib/terminalGlyphs.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a482f95ec6..8f9d5c6f8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ between releases do not reach them. Remote-PC runtime bundles are versioned sepa ## [Unreleased] ### Fixed +- Korean, Japanese and Chinese in the terminal no longer look spread apart on an iPhone. iOS has + no font that draws Hangul as wide as two terminal cells, so every syllable sat at the left of + its cells with a 4px gap after it. A character narrower than its cells is now drawn larger + (up to 1.2×) and centered in them. Vietnamese written with separate accent marks, as in a + file name from a Mac, now shows every mark on iPhone; Safari drew only the first one. - Several lines pasted into the terminal of a Windows PC stay in an agent's message box until you send them. Gajae Code took the first line break for Enter and sent the first line alone. PowerShell and cmd still run a pasted block line by line. Enter, Ctrl+C, Esc, Tab and the diff --git a/src/components/PaneTerminal.tsx b/src/components/PaneTerminal.tsx index 2ce86784fd..535089d7c3 100644 --- a/src/components/PaneTerminal.tsx +++ b/src/components/PaneTerminal.tsx @@ -27,6 +27,7 @@ import type { PaneView } from "../lib/actions.ts"; import { terminalTheme, type Palette, type ResolvedTheme } from "../lib/settings.ts"; import { useT } from "../lib/i18n.ts"; import { isAppShortcut } from "../lib/shortcuts.ts"; +import { adjustTerminalGlyphs } from "../lib/terminalGlyphs.ts"; // xterm sizes every cell from the first matching font, so a proportional one (Malgun Gothic) // must never win it: it stays behind the generic monospace as a per-glyph Hangul fallback @@ -246,6 +247,7 @@ export function PaneTerminal({ // an address in the terminal opens in a new tab; the page never navigates away from the pane term.loadAddon(new WebLinksAddon((_event, uri) => { window.open(uri, "_blank", "noopener,noreferrer"); })); term.open(host); + const stopGlyphs = adjustTerminalGlyphs(term); // Let the browser emit a paste event, which xterm already handles (including // bracketed paste). Otherwise Ctrl+V becomes 0x16, triggering the agent's // image-paste shortcut against the server's clipboard and canceling text paste. @@ -808,6 +810,7 @@ export function PaneTerminal({ if (clipboardTimerRef.current !== null) window.clearTimeout(clipboardTimerRef.current); off(); socket.close(); + stopGlyphs(); term.dispose(); termRef.current = null; socketRef.current = null; diff --git a/src/lib/terminalGlyphs.test.ts b/src/lib/terminalGlyphs.test.ts new file mode 100644 index 0000000000..5eae8fc0e5 --- /dev/null +++ b/src/lib/terminalGlyphs.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "bun:test"; + +import { displayText, glyphFit } from "./terminalGlyphs.ts"; + +// iPhone Safari at 13px: Menlo cells are 7.84px, Hangul from Apple SD Gothic Neo 11.24px +const IOS_CELL = 7.8367; +const IOS_HANGUL = 11.245; + +describe("glyphFit", () => { + it("enlarges an iPhone Hangul syllable, capped, and centers what is left", () => { + const fit = glyphFit(2 * IOS_CELL - IOS_HANGUL, IOS_HANGUL, IOS_CELL); + expect(fit?.scale).toBe(1.2); + // the glyph and the spacing after scaling still fill exactly the two cells + expect(IOS_HANGUL * fit!.scale + fit!.spacing).toBeCloseTo(2 * IOS_CELL, 6); + expect(fit!.spacing).toBeGreaterThan(0); + }); + + it("enlarges a 1em Han glyph only until it fills 90% of its cells", () => { + const fit = glyphFit(2 * IOS_CELL - 13, 13, IOS_CELL); + expect(13 * fit!.scale).toBeCloseTo(0.9 * 2 * IOS_CELL, 6); + expect(fit!.spacing).toBeCloseTo(0.1 * 2 * IOS_CELL, 6); + }); + + it("leaves a glyph that nearly fills its cells as drawn (CJK on Linux)", () => { + expect(glyphFit(1.04, 13, 7.02)).toBeNull(); + }); + + it("only centers a narrow glyph in one cell, never enlarges it", () => { + expect(glyphFit(IOS_CELL - 3.25, 3.25, IOS_CELL)).toEqual({ scale: 1, spacing: IOS_CELL - 3.25 }); + }); + + it("leaves rounding-level spacing and glyphs wider than their cells alone", () => { + expect(glyphFit(0.6, 4, 4.6)).toBeNull(); + expect(glyphFit(-1.3, 17, IOS_CELL)).toBeNull(); + }); +}); + +describe("displayText", () => { + it("composes stacked marks iOS Safari would drop", () => { + expect(displayText("Tie\u0302\u0301ng a\u0306\u0309")).toBe("Tiếng ẳ"); + }); + + it("composes decomposed Hangul into its syllables", () => { + expect(displayText("\u1112\u1161\u11ab\u1100\u1173\u11af")).toBe("한글"); + }); + + it("keeps text without marks as it is", () => { + expect(displayText("한글 ハンカク ㄱㅏ abc")).toBe("한글 ハンカク ㄱㅏ abc"); + }); +}); diff --git a/src/lib/terminalGlyphs.ts b/src/lib/terminalGlyphs.ts new file mode 100644 index 0000000000..756ba25127 --- /dev/null +++ b/src/lib/terminalGlyphs.ts @@ -0,0 +1,118 @@ +/** + * xterm's DOM renderer sizes every cell from the Latin font and gives a glyph narrower than + * its cells the rest as letter-spacing, which lands after the glyph: the glyph sits at the + * left edge of its cells. With a 1em CJK fallback beside a 0.54em Latin cell (Linux) that + * leftover is a pixel. Apple's Menlo cell is 0.6em, though, and no font on iPhone draws + * Hangul wider than 0.87em, so every syllable trailed a 4px gap and Korean read as falling + * apart (kana and Han too, at 0.87em to 1em). + * + * So each span the renderer draws with extra spacing is adjusted after the fact: + * - a glyph in two cells that fills less than FILL of them is drawn larger, up to + * MAX_SCALE, the way a CJK coding font sizes it against its Latin; + * - the glyph is centered in its cells: padding moves the text right by half the spacing + * left over, a negative margin takes it back after the span so the next span keeps its + * column, and a clip trims the trailing half so a background or block cursor stays on + * its own cells. + * + * The renderer also hands the browser each cell's text as written. iOS Safari draws only + * the first of two stacked marks on a decomposed letter (`e` + U+0302 + U+0301, as in a + * Vietnamese file name from a Mac), so such text is shown composed (NFC), and decomposed + * Hangul with it, which then measures as the syllable it is. Copy reads xterm's buffer, not + * the DOM, and still returns what the pane wrote. + */ +import type { Terminal } from "@xterm/xterm"; + +/** Spacing up to this is rounding between the font and the cell grid, not a narrow glyph. */ +const MIN_SPACING_PX = 1; +/** + * A glyph filling this much of its cells is left as drawn (a 1em CJK glyph on Linux fills + * 93%); a narrower one is centered, and a two-cell one enlarged to fill this much. + */ +const FILL = 0.9; +/** The most a wide glyph is enlarged, so it never towers over the Latin beside it. */ +const MAX_SCALE = 1.2; + +/** Combining marks, and the Hangul vowel and final jamo a decomposed syllable continues with. */ +const DECOMPOSED = /[\p{M}\u1160-\u11ff]/u; + +export interface GlyphFit { + /** font-size multiplier for the span; 1 keeps it */ + scale: number; + /** the letter-spacing left after scaling; its half goes before each glyph */ + spacing: number; +} + +/** + * How to draw a span whose glyphs advance `glyphWidth` and get `spacing` added by xterm, in + * a grid of `cellWidth` columns; null when the span is already right. + */ +export function glyphFit(spacing: number, glyphWidth: number, cellWidth: number): GlyphFit | null { + if (!(spacing > MIN_SPACING_PX) || !(glyphWidth > 0) || !(cellWidth > 0)) return null; + const slot = glyphWidth + spacing; + if (glyphWidth >= FILL * slot) return null; + const wide = Math.round(slot / cellWidth) >= 2; + const scale = wide ? Math.min(MAX_SCALE, (FILL * slot) / glyphWidth) : 1; + return { scale, spacing: slot - glyphWidth * scale }; +} + +/** The text to draw for a cell span: composed when it carries marks or decomposed Hangul. */ +export function displayText(text: string): string { + return DECOMPOSED.test(text) ? text.normalize("NFC") : text; +} + +/** + * Keeps every row the DOM renderer draws adjusted. The renderer rebuilds a row's spans on + * each render (and on link hover, which fires no render event), so a MutationObserver + * catches them all; it runs before the frame paints. Returns the stop function. + */ +export function adjustTerminalGlyphs(term: Terminal): () => void { + const rows = term.element?.querySelector(".xterm-rows"); + const screen = term.element?.querySelector(".xterm-screen"); + const measure = document.createElement("canvas").getContext("2d"); + if (!rows || !screen || !measure) return () => {}; + // glyph advances by "", for the font they were measured in + const widths = new Map(); + let measuredFont = ""; + + const adjustAll = (spans: Iterable): void => { + const { fontFamily, fontSize, fontWeight, fontWeightBold } = term.options; + if (fontSize === undefined) return; + const font = `${fontSize}px ${fontFamily}`; + if (font !== measuredFont) { + widths.clear(); + measuredFont = font; + } + const cellWidth = Number.parseFloat(screen.style.width) / term.cols; + for (const span of spans) { + if (!(span instanceof HTMLSpanElement)) continue; + const text = span.textContent ?? ""; + const shown = displayText(text); + if (shown !== text) span.textContent = shown; + const spacing = Number.parseFloat(span.style.letterSpacing); + if (!(spacing > MIN_SPACING_PX)) continue; + // a span merges only cells drawn with the same spacing, so its first glyph speaks for all + const first = String.fromCodePoint(shown.codePointAt(0) ?? 0x20); + const bold = span.classList.contains("xterm-bold"); + const italic = span.classList.contains("xterm-italic"); + const key = `${bold ? 1 : 0}${italic ? 1 : 0}${first}`; + let glyphWidth = widths.get(key); + if (glyphWidth === undefined) { + measure.font = `${italic ? "italic " : ""}${bold ? fontWeightBold : fontWeight} ${font}`; + glyphWidth = measure.measureText(first).width; + widths.set(key, glyphWidth); + } + const fit = glyphFit(spacing, glyphWidth, cellWidth); + if (!fit) continue; + const half = fit.spacing / 2; + // appended in one write: later declarations win over xterm's letter-spacing + span.style.cssText += `;${fit.scale !== 1 ? `font-size:${fontSize * fit.scale}px;` : ""}letter-spacing:${fit.spacing}px;padding-left:${half}px;margin-right:${-half}px;clip-path:inset(0 ${half}px 0 0)`; + } + }; + + adjustAll(rows.querySelectorAll("span")); + const observer = new MutationObserver((records) => { + for (const record of records) adjustAll(record.addedNodes); + }); + observer.observe(rows, { childList: true, subtree: true }); + return () => observer.disconnect(); +} From b1900e6a4fbe669851ce75ddd3a7b7c5e9aeddb1 Mon Sep 17 00:00:00 2001 From: Hako Date: Thu, 1 Oct 2026 23:02:04 +0900 Subject: [PATCH 2/4] docs(changelog): link #272 --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f9d5c6f8e..faad6b872b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,8 @@ between releases do not reach them. Remote-PC runtime bundles are versioned sepa no font that draws Hangul as wide as two terminal cells, so every syllable sat at the left of its cells with a 4px gap after it. A character narrower than its cells is now drawn larger (up to 1.2×) and centered in them. Vietnamese written with separate accent marks, as in a - file name from a Mac, now shows every mark on iPhone; Safari drew only the first one. + file name from a Mac, now shows every mark on iPhone; Safari drew only the first one + ([#272](https://github.com/devswha/herdr-web-ui/pull/272)). - Several lines pasted into the terminal of a Windows PC stay in an agent's message box until you send them. Gajae Code took the first line break for Enter and sent the first line alone. PowerShell and cmd still run a pasted block line by line. Enter, Ctrl+C, Esc, Tab and the From 9a339eb07e2512a9247f21d7d2d747f5396d691c Mon Sep 17 00:00:00 2001 From: Hako Date: Thu, 1 Oct 2026 23:16:07 +0900 Subject: [PATCH 3/4] =?UTF-8?q?fix(terminal):=20draw=20=E2=8F=BA=20and=20o?= =?UTF-8?q?ther=20text=20symbols=20as=20text=20on=20iPhone?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 4 +- src/lib/terminalGlyphs.test.ts | 14 +++++- src/lib/terminalGlyphs.ts | 86 +++++++++++++++++++++++----------- 3 files changed, 73 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index faad6b872b..09568397bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,9 @@ between releases do not reach them. Remote-PC runtime bundles are versioned sepa no font that draws Hangul as wide as two terminal cells, so every syllable sat at the left of its cells with a 4px gap after it. A character narrower than its cells is now drawn larger (up to 1.2×) and centered in them. Vietnamese written with separate accent marks, as in a - file name from a Mac, now shows every mark on iPhone; Safari drew only the first one + file name from a Mac, now shows every mark on iPhone; Safari drew only the first one. The ⏺ + before every Claude Code message is drawn as a symbol in its cell on iPhone, no longer as a + blue emoji over the next character ([#272](https://github.com/devswha/herdr-web-ui/pull/272)). - Several lines pasted into the terminal of a Windows PC stay in an agent's message box until you send them. Gajae Code took the first line break for Enter and sent the first line alone. diff --git a/src/lib/terminalGlyphs.test.ts b/src/lib/terminalGlyphs.test.ts index 5eae8fc0e5..721cd0efb3 100644 --- a/src/lib/terminalGlyphs.test.ts +++ b/src/lib/terminalGlyphs.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "bun:test"; -import { displayText, glyphFit } from "./terminalGlyphs.ts"; +import { displayText, glyphFit, textPresentation } from "./terminalGlyphs.ts"; // iPhone Safari at 13px: Menlo cells are 7.84px, Hangul from Apple SD Gothic Neo 11.24px const IOS_CELL = 7.8367; @@ -31,7 +31,17 @@ describe("glyphFit", () => { it("leaves rounding-level spacing and glyphs wider than their cells alone", () => { expect(glyphFit(0.6, 4, 4.6)).toBeNull(); - expect(glyphFit(-1.3, 17, IOS_CELL)).toBeNull(); + expect(glyphFit(2 * IOS_CELL - 17, 17, IOS_CELL)).toBeNull(); + }); +}); + +describe("textPresentation", () => { + it("asks a text-default symbol for its text form, so iOS does not draw ⏺ as a two-cell emoji", () => { + expect(textPresentation("⏺ ✔")).toBe("⏺\ufe0e ✔\ufe0e"); + }); + + it("leaves emoji, chosen presentations and plain text alone", () => { + expect(textPresentation("😀⚠️⏺\ufe0e#1 ●")).toBe("😀⚠️⏺\ufe0e#1 ●"); }); }); diff --git a/src/lib/terminalGlyphs.ts b/src/lib/terminalGlyphs.ts index 756ba25127..70dd6a483a 100644 --- a/src/lib/terminalGlyphs.ts +++ b/src/lib/terminalGlyphs.ts @@ -1,28 +1,33 @@ /** - * xterm's DOM renderer sizes every cell from the Latin font and gives a glyph narrower than - * its cells the rest as letter-spacing, which lands after the glyph: the glyph sits at the - * left edge of its cells. With a 1em CJK fallback beside a 0.54em Latin cell (Linux) that - * leftover is a pixel. Apple's Menlo cell is 0.6em, though, and no font on iPhone draws - * Hangul wider than 0.87em, so every syllable trailed a 4px gap and Korean read as falling - * apart (kana and Han too, at 0.87em to 1em). + * xterm's DOM renderer sizes every cell from the Latin font and gives each glyph the + * difference to its cells as letter-spacing, which lands after the glyph: a narrower glyph + * sits at the left edge of its cells. With a 1em CJK fallback beside a 0.54em Latin cell + * (Linux) that leftover is a pixel. Apple's Menlo cell is 0.6em, though, and no font on + * iPhone draws Hangul wider than 0.87em, so every syllable trailed a 4px gap and Korean read + * as falling apart (kana and Han too, at 0.87em to 1em). * * So each span the renderer draws with extra spacing is adjusted after the fact: * - a glyph in two cells that fills less than FILL of them is drawn larger, up to * MAX_SCALE, the way a CJK coding font sizes it against its Latin; * - the glyph is centered in its cells: padding moves the text right by half the spacing * left over, a negative margin takes it back after the span so the next span keeps its - * column, and a clip trims the trailing half so a background or block cursor stays on - * its own cells. + * column, and a clip trims the trailing half so a background or block cursor stays on its + * own cells. + * A glyph wider than its cells is left to run over, as in a native terminal. * - * The renderer also hands the browser each cell's text as written. iOS Safari draws only - * the first of two stacked marks on a decomposed letter (`e` + U+0302 + U+0301, as in a - * Vietnamese file name from a Mac), so such text is shown composed (NFC), and decomposed - * Hangul with it, which then measures as the syllable it is. Copy reads xterm's buffer, not - * the DOM, and still returns what the pane wrote. + * The renderer also hands the browser each cell's text as written, and two kinds of text + * draw wrong on iPhone: + * - iOS Safari draws only the first of two stacked marks on a decomposed letter (`e` + + * U+0302 + U+0301, as in a Vietnamese file name from a Mac), so such text is shown composed + * (NFC), and decomposed Hangul with it, which then measures as the syllable it is; + * - iOS draws a symbol that is text by default, such as ⏺ (the bullet before every Claude + * Code message), as a color emoji two cells wide in its one cell, so a symbol that + * overflows its cell is asked for its text form (U+FE0E). + * Copy reads xterm's buffer, not the DOM, and still returns what the pane wrote. */ import type { Terminal } from "@xterm/xterm"; -/** Spacing up to this is rounding between the font and the cell grid, not a narrow glyph. */ +/** Spacing up to this is rounding between the font and the cell grid, not a misfit glyph. */ const MIN_SPACING_PX = 1; /** * A glyph filling this much of its cells is left as drawn (a 1em CJK glyph on Linux fills @@ -34,6 +39,9 @@ const MAX_SCALE = 1.2; /** Combining marks, and the Hangul vowel and final jamo a decomposed syllable continues with. */ const DECOMPOSED = /[\p{M}\u1160-\u11ff]/u; +/** A pictograph that is text by default; one followed by a presentation selector is already decided. */ +const TEXT_DEFAULT_PICTOGRAPH = /(?!\p{Emoji_Presentation})\p{Extended_Pictographic}(?![\ufe0e\ufe0f])/gu; +const TEXT_PRESENTATION = "\ufe0e"; export interface GlyphFit { /** font-size multiplier for the span; 1 keeps it */ @@ -60,6 +68,11 @@ export function displayText(text: string): string { return DECOMPOSED.test(text) ? text.normalize("NFC") : text; } +/** `text` with every text-default pictograph asking for its text form. */ +export function textPresentation(text: string): string { + return text.replace(TEXT_DEFAULT_PICTOGRAPH, `$&${TEXT_PRESENTATION}`); +} + /** * Keeps every row the DOM renderer draws adjusted. The renderer rebuilds a row's spans on * each render (and on link hover, which fires no render event), so a MutationObserver @@ -70,7 +83,7 @@ export function adjustTerminalGlyphs(term: Terminal): () => void { const screen = term.element?.querySelector(".xterm-screen"); const measure = document.createElement("canvas").getContext("2d"); if (!rows || !screen || !measure) return () => {}; - // glyph advances by "", for the font they were measured in + // glyph advances by "", for the font they were measured in const widths = new Map(); let measuredFont = ""; @@ -86,23 +99,40 @@ export function adjustTerminalGlyphs(term: Terminal): () => void { for (const span of spans) { if (!(span instanceof HTMLSpanElement)) continue; const text = span.textContent ?? ""; - const shown = displayText(text); - if (shown !== text) span.textContent = shown; - const spacing = Number.parseFloat(span.style.letterSpacing); - if (!(spacing > MIN_SPACING_PX)) continue; - // a span merges only cells drawn with the same spacing, so its first glyph speaks for all - const first = String.fromCodePoint(shown.codePointAt(0) ?? 0x20); + let shown = displayText(text); + const xtermSpacing = Number.parseFloat(span.style.letterSpacing); + if (!(Math.abs(xtermSpacing) > MIN_SPACING_PX)) { + if (shown !== text) span.textContent = shown; + continue; + } const bold = span.classList.contains("xterm-bold"); const italic = span.classList.contains("xterm-italic"); - const key = `${bold ? 1 : 0}${italic ? 1 : 0}${first}`; - let glyphWidth = widths.get(key); - if (glyphWidth === undefined) { - measure.font = `${italic ? "italic " : ""}${bold ? fontWeightBold : fontWeight} ${font}`; - glyphWidth = measure.measureText(first).width; - widths.set(key, glyphWidth); + const widthOf = (glyph: string): number => { + const key = `${bold ? 1 : 0}${italic ? 1 : 0}${glyph}`; + let width = widths.get(key); + if (width === undefined) { + measure.font = `${italic ? "italic " : ""}${bold ? fontWeightBold : fontWeight} ${font}`; + width = measure.measureText(glyph).width; + widths.set(key, width); + } + return width; + }; + // a span merges only cells drawn with the same spacing, so its first glyph speaks for all + const first = String.fromCodePoint(shown.codePointAt(0) ?? 0x20); + let glyphWidth = widthOf(first); + let spacing = xtermSpacing; + if (spacing < 0 && textPresentation(first) !== first) { + const slot = glyphWidth + spacing; + shown = textPresentation(shown); + glyphWidth = widthOf(first + TEXT_PRESENTATION); + spacing = slot - glyphWidth; } + if (shown !== text) span.textContent = shown; const fit = glyphFit(spacing, glyphWidth, cellWidth); - if (!fit) continue; + if (!fit) { + if (spacing !== xtermSpacing) span.style.letterSpacing = `${spacing}px`; + continue; + } const half = fit.spacing / 2; // appended in one write: later declarations win over xterm's letter-spacing span.style.cssText += `;${fit.scale !== 1 ? `font-size:${fontSize * fit.scale}px;` : ""}letter-spacing:${fit.spacing}px;padding-left:${half}px;margin-right:${-half}px;clip-path:inset(0 ${half}px 0 0)`; From ad983d0bf59b8a10e18c6d109a944862c8b91159 Mon Sep 17 00:00:00 2001 From: Hako Date: Thu, 1 Oct 2026 23:36:55 +0900 Subject: [PATCH 4/4] fix(terminal): keep a symbol that already chooses its presentation as xterm measured it --- src/lib/terminalGlyphs.test.ts | 11 +++++++++-- src/lib/terminalGlyphs.ts | 23 ++++++++++++++++------- 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/src/lib/terminalGlyphs.test.ts b/src/lib/terminalGlyphs.test.ts index 721cd0efb3..517461edcc 100644 --- a/src/lib/terminalGlyphs.test.ts +++ b/src/lib/terminalGlyphs.test.ts @@ -40,8 +40,15 @@ describe("textPresentation", () => { expect(textPresentation("⏺ ✔")).toBe("⏺\ufe0e ✔\ufe0e"); }); - it("leaves emoji, chosen presentations and plain text alone", () => { - expect(textPresentation("😀⚠️⏺\ufe0e#1 ●")).toBe("😀⚠️⏺\ufe0e#1 ●"); + it("leaves a span alone when it already chooses a presentation anywhere", () => { + // xterm measured ⏺️ as the emoji it asks to be; respacing it as text would shift X + expect(textPresentation("⏺\ufe0fX")).toBeNull(); + expect(textPresentation("⏺⏺\ufe0f")).toBeNull(); + expect(textPresentation("⏺\ufe0e")).toBeNull(); + }); + + it("leaves emoji and plain text alone", () => { + expect(textPresentation("😀#1 ●")).toBeNull(); }); }); diff --git a/src/lib/terminalGlyphs.ts b/src/lib/terminalGlyphs.ts index 70dd6a483a..ec1e4bac53 100644 --- a/src/lib/terminalGlyphs.ts +++ b/src/lib/terminalGlyphs.ts @@ -39,8 +39,9 @@ const MAX_SCALE = 1.2; /** Combining marks, and the Hangul vowel and final jamo a decomposed syllable continues with. */ const DECOMPOSED = /[\p{M}\u1160-\u11ff]/u; -/** A pictograph that is text by default; one followed by a presentation selector is already decided. */ -const TEXT_DEFAULT_PICTOGRAPH = /(?!\p{Emoji_Presentation})\p{Extended_Pictographic}(?![\ufe0e\ufe0f])/gu; +/** A pictograph that is text by default. */ +const TEXT_DEFAULT_PICTOGRAPH = /(?!\p{Emoji_Presentation})\p{Extended_Pictographic}/gu; +const PRESENTATION_SELECTOR = /[\ufe0e\ufe0f]/u; const TEXT_PRESENTATION = "\ufe0e"; export interface GlyphFit { @@ -68,9 +69,15 @@ export function displayText(text: string): string { return DECOMPOSED.test(text) ? text.normalize("NFC") : text; } -/** `text` with every text-default pictograph asking for its text form. */ -export function textPresentation(text: string): string { - return text.replace(TEXT_DEFAULT_PICTOGRAPH, `$&${TEXT_PRESENTATION}`); +/** + * `text` with every text-default pictograph asking for its text form, or null when there is + * none or the text already chooses a presentation somewhere. A span is fitted as a whole, so + * its glyphs either all switch to text or all stay as xterm measured them. + */ +export function textPresentation(text: string): string | null { + if (PRESENTATION_SELECTOR.test(text)) return null; + const textForm = text.replace(TEXT_DEFAULT_PICTOGRAPH, `$&${TEXT_PRESENTATION}`); + return textForm === text ? null : textForm; } /** @@ -121,9 +128,11 @@ export function adjustTerminalGlyphs(term: Terminal): () => void { const first = String.fromCodePoint(shown.codePointAt(0) ?? 0x20); let glyphWidth = widthOf(first); let spacing = xtermSpacing; - if (spacing < 0 && textPresentation(first) !== first) { + // the spacing is re-measured from the first glyph, so that glyph must be one that switches + const textForm = spacing < 0 ? textPresentation(shown) : null; + if (textForm?.startsWith(first + TEXT_PRESENTATION)) { const slot = glyphWidth + spacing; - shown = textPresentation(shown); + shown = textForm; glyphWidth = widthOf(first + TEXT_PRESENTATION); spacing = slot - glyphWidth; }