Skip to content

fix: respect terminal cell widths in highlight and cursor rendering#14

Merged
remorses merged 1 commit intoremorses:mainfrom
aarcamp:wide-char-highlight-fix
Mar 26, 2026
Merged

fix: respect terminal cell widths in highlight and cursor rendering#14
remorses merged 1 commit intoremorses:mainfrom
aarcamp:wide-char-highlight-fix

Conversation

@aarcamp
Copy link
Copy Markdown
Contributor

@aarcamp aarcamp commented Mar 12, 2026

applyHighlightsToLine() and applyCursorToLine() tracked column positions using chunk.text.length (JS string length), but HighlightRegion start/end and cursor positions are in terminal cell columns. Wide characters (CJK, etc.) occupy 2 cells but are 1 JS char, causing highlights and cursor to land on the wrong text.

Add getChunkCellWidth() and cellColToStringIndex() helpers using wcwidth for portable cell-width measurement. convertSpanToChunk() now preserves span.width as cellWidth on chunks, and split chunks get recomputed cellWidth so cursor rendering after highlights sees correct column widths.

FWIW I'm fixing wide char stuff in OpenTUI as well -- see anomalyco/opentui#791.

Before:

wide-character-highlight-fix-before

After:

wide-character-highlight-fix-after

Demo script:

#!/usr/bin/env bun

/**
 * Demonstrates the wide-character highlight misalignment bug in
 * ghostty-opentui's applyHighlightsToLine().
 *
 * The function tracks column positions using chunk.text.length (JS string
 * length) but HighlightRegion start/end are in terminal cell columns.
 * For wide characters like CJK (2 cells, 1 JS char), these diverge.
 *
 * Usage: bun repro-highlight.ts
 */

import { applyHighlightsToLine, type HighlightRegion } from "ghostty-opentui/terminal-buffer"
import { RGBA, type TextChunk, rgbToHex } from "@opentui/core"
import wcwidth from "wcwidth"

const HIGHLIGHT_COLOR = "#ff0000"

// ANSI escapes for terminal rendering
const RESET = "\x1b[0m"
const DIM = "\x1b[2m"
const BOLD = "\x1b[1m"
const BG_GREEN = "\x1b[42;30m"  // green bg, black fg — expected highlight
const BG_RED = "\x1b[41;97m"    // red bg, white fg — actual highlight

function hex(rgba: RGBA | undefined): string | undefined {
  return rgba ? rgbToHex(rgba) : undefined
}

function createChunk(text: string): TextChunk {
  return { __isChunk: true, text, fg: RGBA.fromHex("#ffffff"), bg: undefined, attributes: 0 }
}

/**
 * Renders a string into a cell grid where each cell is one fixed-width column.
 * Wide characters occupy two adjacent cells (char + spacer).
 * Returns an array of cell strings, each representing one terminal column.
 */
function toCellGrid(text: string): string[] {
  const cells: string[] = []
  for (const ch of text) {
    const w = wcwidth(ch)
    cells.push(ch)
    for (let i = 1; i < w; i++) cells.push("") // spacer cells
  }
  return cells
}

/**
 * Render a cell grid as a visual row with ANSI highlights applied.
 * Each cell gets a fixed-width 3-char slot so columns stay aligned.
 * highlightedCells: set of cell indices that should be colored.
 */
function renderRow(cells: string[], highlightedCells: Set<number>, bgEscape: string): string {
  let out = ""
  for (let i = 0; i < cells.length; i++) {
    const ch = cells[i]
    const lit = highlightedCells.has(i)
    if (ch === "") continue // skip spacer cells (already printed as part of wide char)
    out += lit ? ` ${bgEscape}${ch}${RESET}` : ` ${ch}`
  }
  return out
}

/**
 * Given the chunks returned by applyHighlightsToLine, figure out which
 * original-text JS character indices got highlighted.
 */
function highlightedCharIndices(original: string, resultChunks: TextChunk[]): Set<number> {
  const indices = new Set<number>()
  let pos = 0
  for (const chunk of resultChunks) {
    const isLit = hex(chunk.bg) === HIGHLIGHT_COLOR
    for (const ch of chunk.text) {
      if (isLit) indices.add(pos)
      pos++
    }
  }
  return indices
}

/**
 * Convert JS char indices into terminal cell indices.
 */
function charIndicesToCellIndices(text: string, charIdxs: Set<number>): Set<number> {
  const cellIdxs = new Set<number>()
  let cell = 0
  let ci = 0
  for (const ch of text) {
    const w = wcwidth(ch)
    if (charIdxs.has(ci)) {
      for (let j = 0; j < w; j++) cellIdxs.add(cell + j)
    }
    cell += w
    ci++
  }
  return cellIdxs
}

/**
 * Build the expected set of highlighted cell indices from the highlight region.
 */
function expectedCellIndices(start: number, end: number): Set<number> {
  const s = new Set<number>()
  for (let i = start; i < end; i++) s.add(i)
  return s
}

// ─────────────────────────────────────────────────────────────────────────────

interface TestCase {
  label: string
  text: string
  /** Highlight region in terminal cell columns [start, end) */
  start: number
  end: number
  /** What we want highlighted (for the description) */
  target: string
}

const tests: TestCase[] = [
  {
    label: "Highlight 'B' after a wide char",
    text: "A東B",
    start: 3, end: 4,
    target: "B",
  },
  {
    label: "Highlight the wide char '東' itself",
    text: "A東B",
    start: 1, end: 3,
    target: "東",
  },
  {
    label: "Highlight 'X' after two wide chars",
    text: "東世X",
    start: 4, end: 5,
    target: "X",
  },
  {
    label: "Highlight 'ok' in mixed content",
    text: "日本語ok",
    start: 6, end: 8,
    target: "ok",
  },
]

console.log()
console.log(`${BOLD}Wide-character highlight misalignment repro${RESET}`)
console.log()
console.log(`The highlight API specifies regions in terminal cell columns,`)
console.log(`but applyHighlightsToLine() tracks positions with JS string length.`)
console.log(`Wide chars (2 cells, 1 JS char) cause every subsequent column to`)
console.log(`shift, so highlights land on the wrong text.`)

let anyFailed = false

for (const t of tests) {
  console.log()
  console.log(`${BOLD}── ${t.label} ──${RESET}`)

  // Show the cell layout
  const cells = toCellGrid(t.text)
  const totalCells = cells.length

  // Column numbers
  let numRow = "  "
  for (let i = 0; i < totalCells; i++) {
    if (cells[i] === "") continue
    const w = wcwidth(cells[i])
    numRow += ` ${String(i).padEnd(w, " ")}`
  }
  console.log(`${DIM}cell:     ${numRow}${RESET}`)

  // Text row
  let textRow = "  "
  for (let i = 0; i < totalCells; i++) {
    if (cells[i] === "") continue
    const w = wcwidth(cells[i])
    textRow += ` ${cells[i].padEnd(w, " ")}`
  }
  console.log(`text:     ${textRow}`)
  console.log(`${DIM}request:  highlight cells ${t.start}–${t.end - 1} (= '${t.target}')${RESET}`)

  // Run the buggy code
  const chunks = [createChunk(t.text)]
  const highlights: HighlightRegion[] = [
    { line: 0, start: t.start, end: t.end, backgroundColor: HIGHLIGHT_COLOR },
  ]
  const result = applyHighlightsToLine(chunks, highlights)

  // What actually got highlighted
  const litCharIdxs = highlightedCharIndices(t.text, result)
  const actualCellIdxs = charIndicesToCellIndices(t.text, litCharIdxs)
  const expectCellIdxs = expectedCellIndices(t.start, t.end)

  const expectedRow = renderRow(cells, expectCellIdxs, BG_GREEN)
  const actualRow = renderRow(cells, actualCellIdxs, BG_RED)

  console.log(`${BG_GREEN} expected ${RESET}${expectedRow}`)
  console.log(`${BG_RED} actual   ${RESET}${actualRow}`)

  const match = [...expectCellIdxs].every(i => actualCellIdxs.has(i)) &&
                [...actualCellIdxs].every(i => expectCellIdxs.has(i))
  if (!match) anyFailed = true
}

console.log()
if (anyFailed) {
  console.log(`${BG_RED} BUG ${RESET} Highlights are misaligned after wide characters.`)
  process.exit(1)
} else {
  console.log("All highlights aligned correctly.")
}

@aarcamp aarcamp marked this pull request as draft March 13, 2026 00:12
@aarcamp aarcamp force-pushed the wide-char-highlight-fix branch 2 times, most recently from 503ec4f to bf71c6e Compare March 13, 2026 00:54
@aarcamp aarcamp marked this pull request as ready for review March 13, 2026 00:55
@aarcamp aarcamp force-pushed the wide-char-highlight-fix branch from bf71c6e to 5a3c9df Compare March 13, 2026 07:58
applyHighlightsToLine() and applyCursorToLine() tracked column
positions using chunk.text.length (JS string length), but
HighlightRegion start/end and cursor positions are in terminal cell
columns. Wide characters (CJK, etc.) occupy 2 cells but are 1 JS
char, causing highlights and cursor to land on the wrong text.

Add getChunkCellWidth() and cellColToStringIndex() helpers using
wcwidth for portable cell-width measurement. convertSpanToChunk()
now preserves span.width as cellWidth on chunks, and split chunks
get recomputed cellWidth so cursor rendering after highlights sees
correct column widths.
@aarcamp aarcamp force-pushed the wide-char-highlight-fix branch from 5a3c9df to 768ae36 Compare March 26, 2026 00:27
@aarcamp
Copy link
Copy Markdown
Contributor Author

aarcamp commented Mar 26, 2026

@remorses Any concerns on this PR? I rebased and bumped the version increment to 1.4.10 also, could a release be cut after merge? Thanks!

@remorses remorses merged commit 4daacf1 into remorses:main Mar 26, 2026
4 checks passed
@remorses
Copy link
Copy Markdown
Owner

thanks! npm releases are done automatically. the gh releases are just done manually

@remorses
Copy link
Copy Markdown
Owner

By the way can I see what you are working on? I am very interested! Is it open source?

@aarcamp
Copy link
Copy Markdown
Contributor Author

aarcamp commented Mar 27, 2026

By the way can I see what you are working on? I am very interested! Is it open source?

It will be open yes, I've been building it offline so far but it will be pushed to GitHub soon (next week hopefully), I'll let you know!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants