Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,16 @@ jobs:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: npm publish ./packages/ink-spinner-shim --access public

- name: Publish @rezi-ui/ink-gradient-shim
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: npm publish ./packages/rezi-ink-gradient-shim --access public

- name: Publish @rezi-ui/ink-spinner-shim
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: npm publish ./packages/rezi-ink-spinner-shim --access public

- name: Publish create-rezi
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
Expand Down
4 changes: 3 additions & 1 deletion biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@
"include": [
"packages/ink-compat/**",
"packages/ink-gradient-shim/**",
"packages/ink-spinner-shim/**"
"packages/ink-spinner-shim/**",
"packages/rezi-ink-gradient-shim/**",
"packages/rezi-ink-spinner-shim/**"
],
"linter": {
"rules": {
Expand Down
9 changes: 9 additions & 0 deletions packages/rezi-ink-gradient-shim/index.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type React from "react";

export interface GradientProps {
colors?: string[];
children?: React.ReactNode;
}

declare const Gradient: React.FC<GradientProps>;
export default Gradient;
153 changes: 153 additions & 0 deletions packages/rezi-ink-gradient-shim/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
/**
* @rezi-ui/ink-gradient-shim.
* Scoped alias package with the same behavior as ink-gradient-shim.
*/
import React from "react";

const NAMED_COLORS = {
black: [0, 0, 0],
red: [255, 0, 0],
green: [0, 255, 0],
yellow: [255, 255, 0],
blue: [0, 0, 255],
magenta: [255, 0, 255],
cyan: [0, 255, 255],
white: [255, 255, 255],
gray: [127, 127, 127],
grey: [127, 127, 127],
};

const GRADIENT_TRACE_ENABLED = process.env.INK_GRADIENT_TRACE === "1";
const SHIM_PATH =
typeof __filename === "string"
? __filename
: typeof import.meta !== "undefined"
? import.meta.url
: "unknown";

const traceGradient = (message) => {
if (!GRADIENT_TRACE_ENABLED) return;
try {
process.stderr.write(`[ink-gradient-shim trace] ${message}\n`);
} catch {
// Best-effort tracing only.
}
};

const clampByte = (value) => Math.max(0, Math.min(255, Math.round(value)));
const ANSI_ESCAPE_REGEX = /\u001b\[[0-9:;]*[ -/]*[@-~]|\u009b[0-9:;]*[ -/]*[@-~]/g;

const parseColor = (color) => {
if (!color || typeof color !== "string") return undefined;
const trimmed = color.trim();
if (!trimmed) return undefined;

const lower = trimmed.toLowerCase();
const named = NAMED_COLORS[lower];
if (named) return { r: named[0], g: named[1], b: named[2] };

if (trimmed.startsWith("#")) {
const hex = trimmed.slice(1);
if (/^[\da-fA-F]{6}$/.test(hex)) {
return {
r: Number.parseInt(hex.slice(0, 2), 16),
g: Number.parseInt(hex.slice(2, 4), 16),
b: Number.parseInt(hex.slice(4, 6), 16),
};
}
if (/^[\da-fA-F]{3}$/.test(hex)) {
return {
r: Number.parseInt(hex[0] + hex[0], 16),
g: Number.parseInt(hex[1] + hex[1], 16),
b: Number.parseInt(hex[2] + hex[2], 16),
};
}
return undefined;
}

const rgbMatch = trimmed.match(/^rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/);
if (!rgbMatch) return undefined;

return {
r: clampByte(Number(rgbMatch[1])),
g: clampByte(Number(rgbMatch[2])),
b: clampByte(Number(rgbMatch[3])),
};
};

const mixChannel = (start, end, t) => clampByte(start + (end - start) * t);

const interpolateStops = (stops, t) => {
if (stops.length === 0) return { r: 255, g: 255, b: 255 };
if (stops.length === 1) return stops[0];

const clamped = Math.max(0, Math.min(1, t));
const scaled = clamped * (stops.length - 1);
const leftIndex = Math.floor(scaled);
const rightIndex = Math.min(stops.length - 1, leftIndex + 1);
const localT = scaled - leftIndex;

const left = stops[leftIndex];
const right = stops[rightIndex];
return {
r: mixChannel(left.r, right.r, localT),
g: mixChannel(left.g, right.g, localT),
b: mixChannel(left.b, right.b, localT),
};
};

const stripAnsi = (value) => value.replace(ANSI_ESCAPE_REGEX, "");

const extractPlainText = (value) => {
if (value == null || typeof value === "boolean") return "";
if (typeof value === "string" || typeof value === "number") return String(value);
if (Array.isArray(value)) return value.map(extractPlainText).join("");
if (React.isValidElement(value)) return extractPlainText(value.props?.children);
return "";
};

const applyGradient = (text, stops) => {
if (stops.length < 2) return stripAnsi(text);

const lines = text.split("\n");

const renderedLines = lines.map((line) => {
const chars = Array.from(stripAnsi(line));
if (chars.length === 0) return "";
const denominator = Math.max(1, chars.length - 1);
const sampled = Array.from({ length: chars.length }, (_, index) =>
interpolateStops(stops, index / denominator),
);
let out = "";
for (let index = 0; index < chars.length; index += 1) {
const color = sampled[index];
out += `\u001b[38;2;${color.r};${color.g};${color.b}m${chars[index]}`;
}
return `${out}\u001b[0m`;
});

return renderedLines.join("\n");
};

const Gradient = ({ colors, children }) => {
const traceCountRef = React.useRef(0);
const parsedStops = (Array.isArray(colors) ? colors : [])
.map((entry) => parseColor(entry))
.filter(Boolean);
const colorsLength = Array.isArray(colors) ? colors.length : 0;
const plainText = extractPlainText(children);
const gradientText = applyGradient(plainText, parsedStops);
React.useEffect(() => {
if (!GRADIENT_TRACE_ENABLED || traceCountRef.current >= 20) return;
if (traceCountRef.current === 0) {
traceGradient(`module=${SHIM_PATH}`);
}
traceCountRef.current += 1;
traceGradient(
`render#${traceCountRef.current} colors=${colorsLength} parsedStops=${parsedStops.length} textChars=${Array.from(plainText).length} emittedAnsi=${gradientText.includes("\u001b[38;2;")}`,
);
}, [colorsLength, parsedStops.length, plainText, gradientText]);
return React.createElement("ink-text", null, gradientText);
};

export default Gradient;
12 changes: 12 additions & 0 deletions packages/rezi-ink-gradient-shim/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"name": "@rezi-ui/ink-gradient-shim",
"version": "1.0.0",
"description": "Scoped ink-gradient shim alias for Rezi Ink compatibility.",
"type": "module",
"main": "index.js",
"types": "index.d.ts",
"peerDependencies": {
"ink": "*",
"react": "^18.0.0 || ^19.0.0"
}
}
8 changes: 8 additions & 0 deletions packages/rezi-ink-spinner-shim/index.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import type React from "react";

export interface SpinnerProps {
type?: string;
}

declare const Spinner: React.FC<SpinnerProps>;
export default Spinner;
27 changes: 27 additions & 0 deletions packages/rezi-ink-spinner-shim/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/**
* @rezi-ui/ink-spinner-shim.
* Scoped alias package with the same behavior as ink-spinner-shim.
*/
import React, { useEffect, useState } from "react";

const SPINNER_FRAMES = {
dots: { frames: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"], interval: 80 },
line: { frames: ["-", "\\", "|", "/"], interval: 130 },
arrow: { frames: ["←", "↖", "↑", "↗", "→", "↘", "↓", "↙"], interval: 120 },
};

const Spinner = ({ type = "dots" }) => {
const [frame, setFrame] = useState(0);
const spinner = SPINNER_FRAMES[type] ?? SPINNER_FRAMES.dots;

useEffect(() => {
const timer = setInterval(() => {
setFrame((f) => (f + 1) % spinner.frames.length);
}, spinner.interval);
return () => clearInterval(timer);
}, [spinner]);

return React.createElement("ink-text", { color: "green" }, spinner.frames[frame]);
};

export default Spinner;
12 changes: 12 additions & 0 deletions packages/rezi-ink-spinner-shim/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"name": "@rezi-ui/ink-spinner-shim",
"version": "1.0.0",
"description": "Scoped ink-spinner shim alias for Rezi Ink compatibility.",
"type": "module",
"main": "index.js",
"types": "index.d.ts",
"peerDependencies": {
"ink": "*",
"react": "^18.0.0 || ^19.0.0"
}
}
2 changes: 2 additions & 0 deletions scripts/release-set-version.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ const EXTRA_RELEASE_PACKAGE_DIRS = [
"packages/ink-compat",
"packages/ink-gradient-shim",
"packages/ink-spinner-shim",
"packages/rezi-ink-gradient-shim",
"packages/rezi-ink-spinner-shim",
];

function die(msg) {
Expand Down
Loading