/** * QR Code component for displaying scannable QR codes in the terminal. * * Uses the lower-half-block character (▄) exclusively for rendering. The top * half of each cell is controlled via backgroundColor and the bottom half via * the foreground color. This avoids the sub-pixel seams that occur when mixing * different Unicode block characters (█, ▀, ▄, space) across adjacent rows. */ import React, { useMemo } from 'react'; import { Box, Text } from 'ink'; import QRCodeLib from 'qrcode'; import { DialogWrapper } from './Dialog.js'; import { colors } from '../theme.js'; /** Color used for light (background) QR modules. */ const LIGHT = 'white'; /** Color used for dark (data) QR modules. Must match the dialog/terminal bg. */ const DARK = colors.bg as string; /** Default quiet zone size in modules (QR spec recommends 4, 2 is usually sufficient). */ const QUIET_ZONE = 2; /** * A run of consecutive characters in a rendered QR row that share the * same foreground/background color pair. */ interface ColorSpan { /** The repeated ▄ characters for this span. */ chars: string; /** Foreground color (controls the bottom half of each cell). */ fg: string; /** Background color (controls the top half of each cell). */ bg: string; } /** * Props for the QRCode component. */ interface QRCodeProps { /** The data to encode in the QR code. */ value: string; /** Whether to wrap the QR code in a DialogWrapper. */ dialog?: boolean; /** Dialog title (only used when dialog is true). Defaults to "QR Code". */ dialogTitle?: string; /** Whether to display the raw encoded value as copyable text above the QR code. */ showValue?: boolean; /** Optional subtitle to display below the QR code. */ subtitle?: React.ReactNode; } /** * Generates the QR code module matrix with a quiet zone border. * * @param value - The string to encode. * @param quietZone - Number of light-module rows/columns to add around the QR data. * @returns A 2D array where `true` means dark module and `false` means light module. */ function generateMatrix(value: string, quietZone: number = QUIET_ZONE): boolean[][] { const qr = QRCodeLib.create(value, { errorCorrectionLevel: 'M' }); const { size, data } = qr.modules; const totalSize = size + quietZone * 2; const matrix: boolean[][] = []; for (let row = 0; row < totalSize; row++) { const matrixRow: boolean[] = []; for (let col = 0; col < totalSize; col++) { const qrRow = row - quietZone; const qrCol = col - quietZone; const insideData = qrRow >= 0 && qrRow < size && qrCol >= 0 && qrCol < size; // Quiet zone modules are always light (false). matrixRow.push(insideData ? data[qrRow * size + qrCol] === 1 : false); } matrix.push(matrixRow); } return matrix; } /** * Converts a pair of module rows into an array of {@link ColorSpan}s. * * Every cell uses the `▄` (lower half block) character. The foreground color * paints the bottom half and the backgroundColor paints the top half, giving * us artifact-free rendering with a single glyph. * * Consecutive cells that share the same color pair are merged into one span * to keep the element count low. * * @param matrix - The full module matrix. * @param row - The index of the top row in the pair (the bottom row is row + 1). * @returns An array of color spans for this terminal line. */ function buildRowSpans(matrix: boolean[][], row: number): ColorSpan[] { const width = matrix[0]?.length ?? 0; const spans: ColorSpan[] = []; for (let col = 0; col < width; col++) { const topDark = matrix[row]?.[col] ?? false; const bottomDark = matrix[row + 1]?.[col] ?? false; // ▄ lower-half block: foreground = bottom color, backgroundColor = top color const fg = bottomDark ? DARK : LIGHT; const bg = topDark ? DARK : LIGHT; const last = spans[spans.length - 1]; if (last && last.fg === fg && last.bg === bg) { last.chars += '▄'; } else { spans.push({ chars: '▄', fg, bg }); } } return spans; } /** * Renders the full module matrix into an array of span-arrays, one per * terminal row (each covering two QR module rows). * * @param matrix - The 2D dark/light module matrix from {@link generateMatrix}. */ function renderMatrix(matrix: boolean[][]): ColorSpan[][] { const rows: ColorSpan[][] = []; const height = matrix.length; for (let row = 0; row < height; row += 2) { rows.push(buildRowSpans(matrix, row)); } return rows; } /** * Displays a scannable QR code in the terminal. * * Supports optional dialog wrapping via the `dialog` prop and an optional * copyable text display of the encoded value via `showValue`. * * @example * ```tsx * // Minimal usage * * * // Inside a dialog with the raw value shown * * ``` */ export function QRCode({ value, dialog = false, dialogTitle = 'QR Code', showValue = false, subtitle = null, }: QRCodeProps): React.ReactElement { const { rows, moduleCount } = useMemo(() => { const matrix = generateMatrix(value); return { rows: renderMatrix(matrix), moduleCount: matrix[0]?.length ?? 0, }; }, [value]); const qrContent = ( {showValue && ( {value} )} {rows.map((spans, i) => ( {spans.map((span, j) => ( {span.chars} ))} ))} ); if (dialog) { const dialogWidth = Math.max(moduleCount + 8, 40); return ( {qrContent} {subtitle} ); } return qrContent; }