From bba16943f650641b71827483ccc0f6cb341bfc84 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 25 Apr 2026 15:14:26 -0500 Subject: [PATCH] fix(tui): preserve rendered indentation in selections - trim only empty edge rows instead of full selected text - bound selection paint using unwritten cells so rendered indentation remains copyable --- .../hermes-ink/src/ink/selection.test.ts | 18 ++++ .../packages/hermes-ink/src/ink/selection.ts | 86 ++++++++++++------- 2 files changed, 72 insertions(+), 32 deletions(-) diff --git a/ui-tui/packages/hermes-ink/src/ink/selection.test.ts b/ui-tui/packages/hermes-ink/src/ink/selection.test.ts index 97f7815db2..97676a7313 100644 --- a/ui-tui/packages/hermes-ink/src/ink/selection.test.ts +++ b/ui-tui/packages/hermes-ink/src/ink/selection.test.ts @@ -19,6 +19,8 @@ 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() @@ -40,6 +42,22 @@ describe('selection whitespace handling', () => { expect(getSelectedText(selection, screen)).toBe('hi') }) + 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, 2, 0, { char: 'x', hyperlink: undefined, styleId: screen.emptyStyleId, width: CellWidth.Narrow }) + + startSelection(selection, 0, 0) + updateSelection(selection, 9, 0) + + expect(getSelectedText(selection, screen)).toBe(' x') + }) + it('does not paint selection background on leading/trailing empty cells or empty rows', () => { const { screen, styles } = screenWithText() const selection = createSelectionState() diff --git a/ui-tui/packages/hermes-ink/src/ink/selection.ts b/ui-tui/packages/hermes-ink/src/ink/selection.ts index 3834a97315..ff3260be4b 100644 --- a/ui-tui/packages/hermes-ink/src/ink/selection.ts +++ b/ui-tui/packages/hermes-ink/src/ink/selection.ts @@ -12,7 +12,7 @@ import { clamp } from './layout/geometry.js' import type { Screen, StylePool } from './screen.js' -import { cellAt, cellAtIndex, CellWidth, setCellStyleId } from './screen.js' +import { cellAt, cellAtIndex, CellWidth, isEmptyCellAt, setCellStyleId } from './screen.js' type Point = { col: number; row: number } @@ -842,31 +842,41 @@ export function isCellSelected(s: SelectionState, col: number, row: number): boo return true } -function rowSelectableContentBounds(screen: Screen, row: number): { first: number; last: number } | null { - if (row < 0 || row >= screen.height) { +function selectableCell(screen: Screen, row: number, col: number): boolean { + const cell = cellAt(screen, col, row) + + return ( + screen.noSelect[row * screen.width + col] !== 1 && + !isEmptyCellAt(screen, col, row) && + !!cell && + cell.width !== CellWidth.SpacerTail && + cell.width !== CellWidth.SpacerHead + ) +} + +function selectionContentBounds( + screen: Screen, + row: number, + start: number, + end: number +): { first: number; last: number } | null { + let first = start + + while (first <= end && !selectableCell(screen, row, first)) { + first++ + } + + if (first > end) { return null } - const rowOff = row * screen.width - let first = -1 - let last = -1 + let last = end - for (let col = 0; col < screen.width; col++) { - if (screen.noSelect[rowOff + col] === 1) { - continue - } - - const cell = cellAt(screen, col, row) - - if (!cell || cell.width === CellWidth.SpacerTail || cell.width === CellWidth.SpacerHead || !cell.char.trim()) { - continue - } - - first = first === -1 ? col : first - last = col + while (last >= first && !selectableCell(screen, row, last)) { + last-- } - return first === -1 ? null : { first, last } + return { first, last } } /** Extract text from one screen row. When the next row is a soft-wrap @@ -917,6 +927,21 @@ function joinRows(lines: string[], text: string, sw: boolean | undefined): void } } +function trimEmptyEdgeRows(lines: string[]): string[] { + let start = 0 + let end = lines.length + + while (start < end && !lines[start]!.trim()) { + start++ + } + + while (end > start && !lines[end - 1]!.trim()) { + end-- + } + + return lines.slice(start, end) +} + /** * Extract text from the screen buffer within the selection range. * Rows are joined with newlines unless the screen's softWrap bitmap @@ -946,14 +971,16 @@ export function getSelectedText(s: SelectionState, screen: Screen): string { for (let row = start.row; row <= end.row; row++) { const rowStart = row === start.row ? start.col : 0 const rowEnd = row === end.row ? end.col : screen.width - 1 - joinRows(lines, extractRowText(screen, row, rowStart, rowEnd), sw[row]! > 0) + const bounds = selectionContentBounds(screen, row, rowStart, rowEnd) + + joinRows(lines, bounds ? extractRowText(screen, row, bounds.first, bounds.last) : '', sw[row]! > 0) } for (let i = 0; i < s.scrolledOffBelow.length; i++) { joinRows(lines, s.scrolledOffBelow[i]!, s.scrolledOffBelowSW[i]) } - return lines.join('\n').trim() + return trimEmptyEdgeRows(lines).join('\n') } /** @@ -1076,21 +1103,16 @@ export function applySelectionOverlay(screen: Screen, selection: SelectionState, const noSelect = screen.noSelect for (let row = start.row; row <= end.row && row < screen.height; row++) { - const bounds = rowSelectableContentBounds(screen, row) + const colStart = row === start.row ? start.col : 0 + const colEnd = row === end.row ? Math.min(end.col, width - 1) : width - 1 + const bounds = selectionContentBounds(screen, row, colStart, colEnd) + const rowOff = row * width if (!bounds) { continue } - const colStart = Math.max(row === start.row ? start.col : 0, bounds.first) - const colEnd = Math.min(row === end.row ? end.col : width - 1, bounds.last) - const rowOff = row * width - - if (colStart > colEnd) { - continue - } - - for (let col = colStart; col <= colEnd; col++) { + for (let col = bounds.first; col <= bounds.last; col++) { const idx = rowOff + col // Skip noSelect cells — gutters stay visually unchanged so it's