fix(tui): track rendered spaces for selection copy

- add a written-cell bitmap so selection can distinguish rendered spaces from blank padding
- preserve code indentation without markdown-specific rendering hacks
This commit is contained in:
Brooklyn Nicholson
2026-04-25 15:21:26 -05:00
parent 1735ced93b
commit bd66e55a02
4 changed files with 57 additions and 28 deletions

View File

@@ -436,6 +436,13 @@ export type Screen = Size & {
*/
noSelect: Uint8Array
/**
* Per-cell written bitmap. A written plain space and never-written padding
* share the same packed cell value, so selection needs this side channel to
* preserve code indentation without selecting blank UI margins.
*/
written: Uint8Array
/**
* Per-ROW soft-wrap continuation marker. softWrap[r]=N>0 means row r
* is a word-wrap continuation of row r-1 (the `\n` before it was
@@ -475,6 +482,14 @@ export function isEmptyCellAt(screen: Screen, x: number, y: number): boolean {
return isEmptyCellByIndex(screen, y * screen.width + x)
}
export function isWrittenCellAt(screen: Screen, x: number, y: number): boolean {
if (x < 0 || y < 0 || x >= screen.width || y >= screen.height) {
return false
}
return screen.written[y * screen.width + x] === 1
}
/**
* Check if a Cell (view object) represents an empty cell.
*/
@@ -533,6 +548,7 @@ export function createScreen(
emptyStyleId: styles.none,
damage: undefined,
noSelect: new Uint8Array(size),
written: new Uint8Array(size),
softWrap: new Int32Array(height)
}
}
@@ -566,6 +582,7 @@ export function resetScreen(screen: Screen, width: number, height: number): void
screen.cells = new Int32Array(buf)
screen.cells64 = new BigInt64Array(buf)
screen.noSelect = new Uint8Array(size)
screen.written = new Uint8Array(size)
}
if (screen.softWrap.length < height) {
@@ -575,6 +592,7 @@ export function resetScreen(screen: Screen, width: number, height: number): void
// Reset all cells — single fill call, no loop
screen.cells64.fill(EMPTY_CELL_VALUE, 0, size)
screen.noSelect.fill(0, 0, size)
screen.written.fill(0, 0, size)
screen.softWrap.fill(0, 0, height)
// Update dimensions
@@ -770,6 +788,7 @@ export function setCellAt(screen: Screen, x: number, y: number, cell: Cell): voi
if ((cells[spacerCI + 1]! & WIDTH_MASK) === CellWidth.SpacerTail) {
cells[spacerCI] = EMPTY_CHAR_INDEX
cells[spacerCI + 1] = packWord1(screen.emptyStyleId, 0, CellWidth.Narrow)
screen.written[y * screen.width + spacerX] = 0
}
}
}
@@ -787,6 +806,7 @@ export function setCellAt(screen: Screen, x: number, y: number, cell: Cell): voi
if ((cells[wideCI + 1]! & WIDTH_MASK) === CellWidth.Wide) {
cells[wideCI] = EMPTY_CHAR_INDEX
cells[wideCI + 1] = packWord1(screen.emptyStyleId, 0, CellWidth.Narrow)
screen.written[y * screen.width + x - 1] = 0
clearedWideX = x - 1
}
}
@@ -795,6 +815,7 @@ export function setCellAt(screen: Screen, x: number, y: number, cell: Cell): voi
// Pack cell data into cells array
cells[ci] = internCharString(screen, cell.char)
cells[ci + 1] = packWord1(cell.styleId, internHyperlink(screen, cell.hyperlink), cell.width)
screen.written[y * screen.width + x] = 1
// Track damage - expand bounds in place instead of allocating new objects
// Include the main cell position and any cleared orphan cells
@@ -841,11 +862,13 @@ export function setCellAt(screen: Screen, x: number, y: number, cell: Cell): voi
if (spacerX + 1 < screen.width && (cells[orphanCI + 1]! & WIDTH_MASK) === CellWidth.SpacerTail) {
cells[orphanCI] = EMPTY_CHAR_INDEX
cells[orphanCI + 1] = packWord1(screen.emptyStyleId, 0, CellWidth.Narrow)
screen.written[y * screen.width + spacerX + 1] = 0
}
}
cells[spacerCI] = SPACER_CHAR_INDEX
cells[spacerCI + 1] = packWord1(screen.emptyStyleId, 0, CellWidth.SpacerTail)
screen.written[y * screen.width + spacerX] = 1
// Expand damage to include SpacerTail so diff() scans it
const d = screen.damage
@@ -929,6 +952,8 @@ export function blitRegion(
const dstCells = dst.cells
const srcNoSel = src.noSelect
const dstNoSel = dst.noSelect
const srcWritten = src.written
const dstWritten = dst.written
// softWrap is per-row — copy the row range regardless of stride/width.
// Partial-width blits still carry the row's wrap provenance since the
@@ -947,6 +972,7 @@ export function blitRegion(
const nsStart = regionY * src.width
const nsLen = (maxY - regionY) * src.width
dstNoSel.set(srcNoSel.subarray(nsStart, nsStart + nsLen), nsStart)
dstWritten.set(srcWritten.subarray(nsStart, nsStart + nsLen), nsStart)
} else {
// Per-row copy for partial-width or mismatched-stride regions
let srcRowCI = regionY * srcStride + (regionX << 1)
@@ -957,6 +983,7 @@ export function blitRegion(
for (let y = regionY; y < maxY; y++) {
dstCells.set(srcCells.subarray(srcRowCI, srcRowCI + rowBytes), dstRowCI)
dstNoSel.set(srcNoSel.subarray(srcRowNS, srcRowNS + rowLen), dstRowNS)
dstWritten.set(srcWritten.subarray(srcRowNS, srcRowNS + rowLen), dstRowNS)
srcRowCI += srcStride
dstRowCI += dstStride
srcRowNS += src.width
@@ -989,6 +1016,7 @@ export function blitRegion(
if ((srcCells[srcLastCI + 1]! & WIDTH_MASK) === CellWidth.Wide) {
dstCells[dstSpacerCI] = SPACER_CHAR_INDEX
dstCells[dstSpacerCI + 1] = packWord1(dst.emptyStyleId, 0, CellWidth.SpacerTail)
dstWritten[y * dst.width + maxX] = 1
wroteSpacerOutsideRegion = true
}
@@ -1030,6 +1058,7 @@ export function clearRegion(
const cells = screen.cells
const cells64 = screen.cells64
const written = screen.written
const screenWidth = screen.width
const rowBase = startY * screenWidth
let damageMinX = startX
@@ -1040,6 +1069,7 @@ export function clearRegion(
if (startX === 0 && maxX === screenWidth) {
// Full-width: single fill, no boundary checks needed
cells64.fill(EMPTY_CELL_VALUE, rowBase, rowBase + (maxY - startY) * screenWidth)
written.fill(0, rowBase, rowBase + (maxY - startY) * screenWidth)
} else {
// Partial-width: single loop handles boundary cleanup and fill per row.
const stride = screenWidth << 1 // 2 Int32s per cell
@@ -1062,6 +1092,7 @@ export function clearRegion(
if ((cells[prevW1]! & WIDTH_MASK) === CellWidth.Wide) {
cells[prevW1 - 1] = EMPTY_CHAR_INDEX
cells[prevW1] = packWord1(screen.emptyStyleId, 0, CellWidth.Narrow)
written[y * screenWidth + startX - 1] = 0
damageMinX = startX - 1
}
}
@@ -1078,12 +1109,14 @@ export function clearRegion(
if ((cells[nextW1]! & WIDTH_MASK) === CellWidth.SpacerTail) {
cells[nextW1 - 1] = EMPTY_CHAR_INDEX
cells[nextW1] = packWord1(screen.emptyStyleId, 0, CellWidth.Narrow)
written[y * screenWidth + maxX] = 0
damageMaxX = maxX + 1
}
}
}
cells64.fill(EMPTY_CELL_VALUE, fillStart, fillStart + rowLen)
written.fill(0, fillStart, fillStart + rowLen)
leftEdge += stride
rightEdge += stride
fillStart += screenWidth
@@ -1120,12 +1153,14 @@ export function shiftRows(screen: Screen, top: number, bottom: number, n: number
const w = screen.width
const cells64 = screen.cells64
const noSel = screen.noSelect
const written = screen.written
const sw = screen.softWrap
const absN = Math.abs(n)
if (absN > bottom - top) {
cells64.fill(EMPTY_CELL_VALUE, top * w, (bottom + 1) * w)
noSel.fill(0, top * w, (bottom + 1) * w)
written.fill(0, top * w, (bottom + 1) * w)
sw.fill(0, top, bottom + 1)
return
@@ -1135,17 +1170,21 @@ export function shiftRows(screen: Screen, top: number, bottom: number, n: number
// SU: row top+n..bottom → top..bottom-n; clear bottom-n+1..bottom
cells64.copyWithin(top * w, (top + n) * w, (bottom + 1) * w)
noSel.copyWithin(top * w, (top + n) * w, (bottom + 1) * w)
written.copyWithin(top * w, (top + n) * w, (bottom + 1) * w)
sw.copyWithin(top, top + n, bottom + 1)
cells64.fill(EMPTY_CELL_VALUE, (bottom - n + 1) * w, (bottom + 1) * w)
noSel.fill(0, (bottom - n + 1) * w, (bottom + 1) * w)
written.fill(0, (bottom - n + 1) * w, (bottom + 1) * w)
sw.fill(0, bottom - n + 1, bottom + 1)
} else {
// SD: row top..bottom+n → top-n..bottom; clear top..top-n-1
cells64.copyWithin((top - n) * w, top * w, (bottom + n + 1) * w)
noSel.copyWithin((top - n) * w, top * w, (bottom + n + 1) * w)
written.copyWithin((top - n) * w, top * w, (bottom + n + 1) * w)
sw.copyWithin(top - n, top, bottom + n + 1)
cells64.fill(EMPTY_CELL_VALUE, top * w, (top - n) * w)
noSel.fill(0, top * w, (top - n) * w)
written.fill(0, top * w, (top - n) * w)
sw.fill(0, top, top - n)
}
}

View File

@@ -19,8 +19,6 @@ const screenWithText = () => {
return { screen, styles }
}
const styledSpace = (styles: StylePool) => styles.intern([{ code: '\x1b[4m', endCode: '\x1b[24m', type: 'ansi' }])
describe('selection whitespace handling', () => {
it('does not copy whitespace-only selections', () => {
const { screen } = screenWithText()
@@ -45,11 +43,10 @@ describe('selection whitespace handling', () => {
it('preserves selected indentation when spaces are rendered content', () => {
const styles = new StylePool()
const screen = createScreen(10, 1, styles, new CharPool(), new HyperlinkPool())
const indentStyle = styledSpace(styles)
const selection = createSelectionState()
setCellAt(screen, 0, 0, { char: ' ', hyperlink: undefined, styleId: indentStyle, width: CellWidth.Narrow })
setCellAt(screen, 1, 0, { char: ' ', hyperlink: undefined, styleId: indentStyle, width: CellWidth.Narrow })
setCellAt(screen, 0, 0, { char: ' ', hyperlink: undefined, styleId: screen.emptyStyleId, width: CellWidth.Narrow })
setCellAt(screen, 1, 0, { char: ' ', hyperlink: undefined, styleId: screen.emptyStyleId, width: CellWidth.Narrow })
setCellAt(screen, 2, 0, { char: 'x', hyperlink: undefined, styleId: screen.emptyStyleId, width: CellWidth.Narrow })
startSelection(selection, 0, 0)

View File

@@ -12,7 +12,7 @@
import { clamp } from './layout/geometry.js'
import type { Screen, StylePool } from './screen.js'
import { cellAt, cellAtIndex, CellWidth, isEmptyCellAt, setCellStyleId } from './screen.js'
import { cellAt, cellAtIndex, CellWidth, isWrittenCellAt, setCellStyleId } from './screen.js'
type Point = { col: number; row: number }
@@ -847,7 +847,7 @@ function selectableCell(screen: Screen, row: number, col: number): boolean {
return (
screen.noSelect[row * screen.width + col] !== 1 &&
!isEmptyCellAt(screen, col, row) &&
isWrittenCellAt(screen, col, row) &&
!!cell &&
cell.width !== CellWidth.SpacerTail &&
cell.width !== CellWidth.SpacerHead

View File

@@ -114,25 +114,6 @@ const renderTable = (k: number, rows: string[][], t: Theme) => {
)
}
const codeText = (text: string, color: string | undefined, key: number | string, t: Theme) => (
<Text color={color ?? (text.trim() ? undefined : t.color.dim)} key={key}>
{text}
</Text>
)
const plainCodeLine = (text: string, t: Theme) => {
const indent = text.match(/^\s+/)?.[0] ?? ''
return indent ? (
<>
{codeText(indent, undefined, 'indent', t)}
{text.slice(indent.length)}
</>
) : (
text
)
}
function MdInline({ t, text }: { t: Theme; text: string }) {
const parts: ReactNode[] = []
@@ -335,7 +316,19 @@ function MdImpl({ compact, t, text }: MdProps) {
{block.map((l, j) => {
if (highlighted) {
return <Text key={j}>{highlightLine(l, lang, t).map(([color, text], kk) => codeText(text, color, kk, t))}</Text>
return (
<Text key={j}>
{highlightLine(l, lang, t).map(([color, text], kk) =>
color ? (
<Text color={color} key={kk}>
{text}
</Text>
) : (
<Text key={kk}>{text}</Text>
)
)}
</Text>
)
}
const add = isDiff && l.startsWith('+')
@@ -349,7 +342,7 @@ function MdImpl({ compact, t, text }: MdProps) {
dimColor={isDiff && !add && !del && !hunk && l.startsWith(' ')}
key={j}
>
{plainCodeLine(l, t)}
{l}
</Text>
)
})}