teqo-backup/scripts/lib/ansi.mjs

131 lines
4.5 KiB
JavaScript

/**
* OPS109 — helpers ANSI mínimos para o painel de issues (`pnpm issues:tui`).
*
* Puro by contract: só constantes de escape + operações de string (zero
* dependência, zero I/O). O painel roda num terminal SSH comum, então o
* conjunto é deliberadamente pequeno e conservador — cores funcionais de
* estado, negrito/dim e o controle de tela alternada. Nada de emoji ou
* glifos double-width: a largura é calculada por `visibleWidth`, que ignora
* as sequências CSI/SGR.
*/
const ESC = '\u001b['
const RESET = `${ESC}0m`
/** SGR codes used by the panel. Names map to the UI draft's functional roles. */
export const SGR = {
reset: RESET,
bold: `${ESC}1m`,
dim: `${ESC}2m`,
italic: `${ESC}3m`,
inverse: `${ESC}7m`,
red: `${ESC}31m`,
green: `${ESC}32m`,
yellow: `${ESC}33m`,
cyan: `${ESC}36m`,
gray: `${ESC}90m`,
}
/**
* Runs of CSI escape sequences: `ESC [ … final-byte`. Covers SGR
* (`\u001b[0m`) and cursor moves (`\u001b[2J`), so width math never counts
* control bytes. Deliberately not a full terminal parser (no OSC/links).
*/
const CSI_PATTERN = /\u001b\[[0-9;?]*[ -/]*[@-~]/g
/**
* Strip every CSI sequence — for measuring and for re-wrapping already
* styled text.
* @param {string} text
*/
export const stripAnsi = (text) => String(text ?? '').replace(CSI_PATTERN, '')
/**
* Printable width in terminal cells. Conservative: counts code points, not
* grapheme clusters or East-Asian double-width (the panel avoids those), so
* the value equals “how many characters the eye sees”.
* @param {string} text
*/
export const visibleWidth = (text) => [...stripAnsi(text)].length
/**
* Truncate to `width` visible columns, appending an ellipsis when it had to
* cut. The result is unstyled plain text (escape sequences are stripped):
* callers that need color apply it around the truncated value.
* @param {string} text
* @param {number} width
*/
export const truncateToWidth = (text, width) => {
const source = String(text ?? '')
const plain = stripAnsi(source)
const chars = [...plain]
const limit = Math.max(0, Math.trunc(width))
if (chars.length <= limit) return source
if (limit === 0) return ''
return `${chars.slice(0, Math.max(0, limit - 1)).join('')}…`
}
/**
* Pad a styled string with spaces up to `width` visible columns. When the
* text is longer it is truncated with `truncateToWidth`; padding happens
* AFTER the text so a background color covers the whole cell.
* @param {string} text
* @param {number} width
*/
export const padToWidth = (text, width) => {
const target = Math.max(0, Math.trunc(width))
const clipped = visibleWidth(text) > target ? truncateToWidth(text, target) : String(text ?? '')
return `${clipped}${' '.repeat(Math.max(0, target - visibleWidth(clipped)))}`
}
/** Enter the alternate screen buffer (restores the shell on leave). */
export const enterAlternateScreen = () => `${ESC}?1049h`
/** Leave the alternate screen buffer. */
export const leaveAlternateScreen = () => `${ESC}?1049l`
/** Hide the hardware cursor (during full redraws). */
export const hideCursor = () => `${ESC}?25l`
/** Show the hardware cursor. */
export const showCursor = () => `${ESC}?25h`
/** Move the cursor to a 1-based row/column (no clearing). */
export const moveTo = (row, column) => `${ESC}${row};${column}H`
/** Clear the whole screen and home the cursor. */
export const clearScreen = () => `${ESC}2J${ESC}H`
/** Erase from the cursor to the end of the current line. */
export const clearLine = () => `${ESC}K`
/** Current terminal size, with conservative defaults when not a TTY. */
export const terminalSize = ({ columns, rows } = {}) => ({
columns: Number.isInteger(columns) && columns > 0 ? columns : 80,
rows: Number.isInteger(rows) && rows > 0 ? rows : 24,
})
/** Wrap `text` in an SGR code and reset at the end (no-op on empty text). */
export const style = (code, text) => (text ? `${code}${text}${RESET}` : '')
/** Bold and dim shorthands over `style`. */
export const bold = (text) => style(SGR.bold, text)
export const dim = (text) => style(SGR.dim, text)
/**
* Severity → color for functional state reading (matches the UI draft):
* green = done/approved, yellow = waiting, red = blocked/error, blue/gray =
* neutral. Unknown states degrade to gray.
* @param {'ok'|'wait'|'bad'|'neutral'|string} tone
* @param {string} text
*/
export const tone = (toneName, text) => {
const code = {
ok: SGR.green,
wait: SGR.yellow,
bad: SGR.red,
accent: SGR.cyan,
neutral: SGR.gray,
}[toneName]
return style(code ?? SGR.gray, text)
}