From ca372d52a948bfc21c6f7c5110841b2519e41069 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 06:27:41 +0000 Subject: [PATCH] Render operators as typographic glyphs in the editor overlay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Display-only substitution in the syntax-highlight overlay: the textarea's raw ASCII is unchanged, so the parser still evaluates correctly while the rendered text shows proper glyphs. - `*` -> ×, spaced `/` -> ÷ (single-cell 1:1 swaps) - ligatures `->` → →, `=>` → ⇒, `!=` → ≠, `<=` → ≤, `>=` → ≥, each in a 2ch-wide span so the caret and soft-wrapping stay aligned with the source - markdown `*` (bold/italic/list) is preserved; only math `*` becomes × - division only fires when whitespace-surrounded, leaving dates and rates (06/28, km/h) literal - native font ligatures disabled on both layers for deterministic rendering Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0167LqyCYk7YA6SXLceXu52d --- CHANGELOG.md | 3 ++ elo-tauri/src/main.ts | 59 ++++++++++++++++++++++++++++++---------- elo-tauri/src/styles.css | 12 ++++++++ 3 files changed, 59 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 021a551..c6a0abe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ All notable changes to Elo are documented here. The format is based on ## [Unreleased] +- The editor now renders operators as typographic glyphs in the display layer + without changing the underlying text: `*` → `×`, spaced `/` → `÷`, and the + ligatures `->` → `→`, `=>` → `⇒`, `!=` → `≠`, `<=` → `≤`, `>=` → `≥`. - New app icon: a typed "E" mark resolving to a result row, rendered across all platforms (macOS `.icns`, Linux/Windows PNGs and `.ico`, and the iOS AppIcon set). diff --git a/elo-tauri/src/main.ts b/elo-tauri/src/main.ts index 8d336f5..ab47b56 100644 --- a/elo-tauri/src/main.ts +++ b/elo-tauri/src/main.ts @@ -129,14 +129,14 @@ function highlightMarkdown(text: string): string { const headerMatch = escaped.match(/^(#{1,3}\s)(.*)/); if (headerMatch) { result.push( - `${headerMatch[1]}${headerMatch[2]}`, + `${headerMatch[1]}${applyOperators(headerMatch[2])}`, ); continue; } // Comments if (trimmed.startsWith("//")) { - result.push(`${escaped}`); + result.push(`${applyOperators(escaped)}`); continue; } @@ -165,19 +165,48 @@ function highlightMarkdown(text: string): string { } function applyInlineFormatting(escaped: string): string { - // Single-pass regex matching inline code, bold, underline, italic - // Order matters: longer delimiters first to avoid partial matches - return escaped.replace( - /(`[^`]+`)|(\*\*[^*]+\*\*)|(__[^_]+__)|(\*[^*]+\*)|(_[^_]+_)/g, - (match, code, bold, underline, italicStar, italicUnderscore) => { - if (code) return `${match}`; - if (bold) return `${match}`; - if (underline) return `${match}`; - if (italicStar) return `${match}`; - if (italicUnderscore) return `${match}`; - return match; - }, - ); + // Walk the markdown matches manually so inline code/bold/italic/underline are + // emitted verbatim, while only the plain-text gaps between them receive + // operator substitution. This protects a markdown `*` (italic/bold) from + // becoming `×` while still converting a math `*` (e.g. `2 * 3`, `(a+b)*c`), + // which lives in a gap. + // Order matters: longer delimiters first to avoid partial matches. + const md = /(`[^`]+`)|(\*\*[^*]+\*\*)|(__[^_]+__)|(\*[^*]+\*)|(_[^_]+_)/g; + let out = ""; + let last = 0; + let m: RegExpExecArray | null; + while ((m = md.exec(escaped)) !== null) { + out += applyOperators(escaped.slice(last, m.index)); + const [match, code, bold, underline, italicStar] = m; + if (code) out += `${match}`; + else if (bold) out += `${match}`; + else if (underline) out += `${match}`; + else if (italicStar) out += `${match}`; + else out += `${match}`; + last = m.index + match.length; + } + out += applyOperators(escaped.slice(last)); + return out; +} + +// Display-only operator glyphs. Runs on already-HTML-escaped text (so `<`/`>` +// are `<`/`>`). The underlying textarea value is never changed, so the +// Rust parser still receives raw ASCII. Two-char ligatures use a 2ch-wide span +// so the glyph occupies exactly the same character grid as the source chars, +// keeping the textarea caret and soft-wrapping aligned with the overlay. +function applyOperators(s: string): string { + return s + .replace(/->/g, `→`) + .replace(/=>/g, `⇒`) + .replace(/!=/g, `≠`) + .replace(/<=/g, `≤`) + .replace(/>=/g, `≥`) + // Division: only when surrounded by whitespace, so dates (06/28), + // rates/units (km/h), and paths stay literal. Single cell. + .replace(/(?<=\s)\/(?=\s)/g, `÷`) + // Multiplication: any remaining `*` (markdown `*` is already consumed on + // inline-formatted lines before this runs). Single cell. + .replace(/\*/g, `×`); } // Sync scroll between editor, highlight, and results diff --git a/elo-tauri/src/styles.css b/elo-tauri/src/styles.css index f0e68d3..4b43215 100644 --- a/elo-tauri/src/styles.css +++ b/elo-tauri/src/styles.css @@ -127,6 +127,7 @@ html, body { overflow: hidden; pointer-events: none; z-index: 0; + font-variant-ligatures: none; } #editor { @@ -149,6 +150,7 @@ html, body { user-select: text; z-index: 1; font-synthesis: none; + font-variant-ligatures: none; } #editor::selection { @@ -190,6 +192,16 @@ html, body { .md-code-fence { color: var(--text-muted); } .md-code-block { color: var(--text-muted); } +/* Display-only operator glyphs in the overlay. Two-char ligatures occupy + exactly two monospace cells so the caret/wrapping stay aligned with the + underlying textarea text. */ +.op-lig { + display: inline-block; + width: 2ch; + text-align: center; +} +/* op-mul / op-div are single-cell 1:1 swaps; classes kept for theming. */ + /* Results gutter */ #results { width: 260px;