import { CellWidth, cellAtIndex, type Screen, type StylePool, setCellStyleId, } from './screen.js' /** * Highlight all visible occurrences of `query` in the screen buffer by * inverting cell styles (SGR 7). Post-render, same damage-tracking machinery * as applySelectionOverlay — the diff picks up highlighted cells as ordinary * changes, LogUpdate stays a pure diff engine. * * Case-insensitive. Handles wide characters (CJK, emoji) by building a * col-of-char map per row — the Nth character isn't at col N when wide chars * are present (each occupies 2 cells: head + SpacerTail). * * This ONLY inverts — there is no "current match" logic here. The yellow * current-match overlay is handled separately by applyPositionedHighlight * (render-to-screen.ts), which writes on top using positions scanned from * the target message's DOM subtree. * * Returns true if any match was highlighted (damage gate — caller forces * full-frame damage when true). */ export function applySearchHighlight( screen: Screen, query: string, stylePool: StylePool, ): boolean { if (!query) return false const lq = query.toLowerCase() const qlen = lq.length const w = screen.width const noSelect = screen.noSelect const height = screen.height let applied = false for (let row = 0; row < height; row++) { const rowOff = row * w // Build row text (already lowercased) + code-unit→cell-index map. // Three skip conditions, all aligned with setCellStyleId / // extractRowText (selection.ts): // - SpacerTail: 2nd cell of a wide char, no char of its own // - SpacerHead: end-of-line padding when a wide char wraps // - noSelect: gutters (⎿, line numbers) — same exclusion as // applySelectionOverlay. "Highlight what you see" still holds for // content; gutters aren't search targets. // Lowercasing per-char (not on the joined string at the end) means // codeUnitToCell maps positions in the LOWERCASED text — U+0130 // (Turkish İ) lowercases to 2 code units, so lowering the joined // string would desync indexOf positions from the map. let text = '' const colOf: number[] = [] const codeUnitToCell: number[] = [] for (let col = 0; col < w; col++) { const idx = rowOff + col const cell = cellAtIndex(screen, idx) if ( cell.width === CellWidth.SpacerTail || cell.width === CellWidth.SpacerHead || noSelect[idx] === 1 ) { continue } const lc = cell.char.toLowerCase() const cellIdx = colOf.length for (let i = 0; i < lc.length; i++) { codeUnitToCell.push(cellIdx) } text += lc colOf.push(col) } let pos = text.indexOf(lq) while (pos >= 0) { applied = true const startCi = codeUnitToCell[pos]! const endCi = codeUnitToCell[pos + qlen - 1]! for (let ci = startCi; ci <= endCi; ci++) { const col = colOf[ci]! const cell = cellAtIndex(screen, rowOff + col) setCellStyleId(screen, col, row, stylePool.withInverse(cell.styleId)) } // Non-overlapping advance (less/vim/grep/Ctrl+F). pos+1 would find // 'aa' at 0 AND 1 in 'aaa' → double-invert cell 1. pos = text.indexOf(lq, pos + qlen) } } return applied }