203 lines
6.0 KiB
TypeScript
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;
|
|
}
|