Files
xo-cli/src/tui/components/QRCode.tsx

203 lines
6.0 KiB
TypeScript

/**
* 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
* <QRCode value="bitcoincash:qr..." />
*
* // Inside a dialog with the raw value shown
* <QRCode value="bitcoincash:qr..." dialog dialogTitle="Receive Address" showValue />
* ```
*/
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 = (
<Box flexDirection="column" alignItems="center">
{showValue && (
<Box marginBottom={1} width={moduleCount}>
<Text color={colors.textMuted} wrap="wrap">{value}</Text>
</Box>
)}
<Box flexDirection="column">
{rows.map((spans, i) => (
<Text key={i}>
{spans.map((span, j) => (
<Text key={j} color={span.fg} backgroundColor={span.bg}>
{span.chars}
</Text>
))}
</Text>
))}
</Box>
</Box>
);
if (dialog) {
const dialogWidth = Math.max(moduleCount + 8, 40);
return (
<DialogWrapper title={dialogTitle} borderColor={colors.primary} width={dialogWidth}>
{qrContent}
{subtitle}
</DialogWrapper>
);
}
return qrContent;
}