/ ink / searchHighlight.ts
searchHighlight.ts
 1  import {
 2    CellWidth,
 3    cellAtIndex,
 4    type Screen,
 5    type StylePool,
 6    setCellStyleId,
 7  } from './screen.js'
 8  
 9  /**
10   * Highlight all visible occurrences of `query` in the screen buffer by
11   * inverting cell styles (SGR 7). Post-render, same damage-tracking machinery
12   * as applySelectionOverlay — the diff picks up highlighted cells as ordinary
13   * changes, LogUpdate stays a pure diff engine.
14   *
15   * Case-insensitive. Handles wide characters (CJK, emoji) by building a
16   * col-of-char map per row — the Nth character isn't at col N when wide chars
17   * are present (each occupies 2 cells: head + SpacerTail).
18   *
19   * This ONLY inverts — there is no "current match" logic here. The yellow
20   * current-match overlay is handled separately by applyPositionedHighlight
21   * (render-to-screen.ts), which writes on top using positions scanned from
22   * the target message's DOM subtree.
23   *
24   * Returns true if any match was highlighted (damage gate — caller forces
25   * full-frame damage when true).
26   */
27  export function applySearchHighlight(
28    screen: Screen,
29    query: string,
30    stylePool: StylePool,
31  ): boolean {
32    if (!query) return false
33    const lq = query.toLowerCase()
34    const qlen = lq.length
35    const w = screen.width
36    const noSelect = screen.noSelect
37    const height = screen.height
38  
39    let applied = false
40    for (let row = 0; row < height; row++) {
41      const rowOff = row * w
42      // Build row text (already lowercased) + code-unit→cell-index map.
43      // Three skip conditions, all aligned with setCellStyleId /
44      // extractRowText (selection.ts):
45      //   - SpacerTail: 2nd cell of a wide char, no char of its own
46      //   - SpacerHead: end-of-line padding when a wide char wraps
47      //   - noSelect: gutters (⎿, line numbers) — same exclusion as
48      //     applySelectionOverlay. "Highlight what you see" still holds for
49      //     content; gutters aren't search targets.
50      // Lowercasing per-char (not on the joined string at the end) means
51      // codeUnitToCell maps positions in the LOWERCASED text — U+0130
52      // (Turkish İ) lowercases to 2 code units, so lowering the joined
53      // string would desync indexOf positions from the map.
54      let text = ''
55      const colOf: number[] = []
56      const codeUnitToCell: number[] = []
57      for (let col = 0; col < w; col++) {
58        const idx = rowOff + col
59        const cell = cellAtIndex(screen, idx)
60        if (
61          cell.width === CellWidth.SpacerTail ||
62          cell.width === CellWidth.SpacerHead ||
63          noSelect[idx] === 1
64        ) {
65          continue
66        }
67        const lc = cell.char.toLowerCase()
68        const cellIdx = colOf.length
69        for (let i = 0; i < lc.length; i++) {
70          codeUnitToCell.push(cellIdx)
71        }
72        text += lc
73        colOf.push(col)
74      }
75  
76      let pos = text.indexOf(lq)
77      while (pos >= 0) {
78        applied = true
79        const startCi = codeUnitToCell[pos]!
80        const endCi = codeUnitToCell[pos + qlen - 1]!
81        for (let ci = startCi; ci <= endCi; ci++) {
82          const col = colOf[ci]!
83          const cell = cellAtIndex(screen, rowOff + col)
84          setCellStyleId(screen, col, row, stylePool.withInverse(cell.styleId))
85        }
86        // Non-overlapping advance (less/vim/grep/Ctrl+F). pos+1 would find
87        // 'aa' at 0 AND 1 in 'aaa' → double-invert cell 1.
88        pos = text.indexOf(lq, pos + qlen)
89      }
90    }
91  
92    return applied
93  }