-
-
Save Kattoor/cfdb47d4479a53d4f2718e7f6db85ecb to your computer and use it in GitHub Desktop.
g.js
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env node | |
| /** | |
| * Gradient text formatter (per line): | |
| * - Each line = one sentence | |
| * - Random gradient palette per sentence (configurable anchor colors) | |
| * - Per-character coloring based on sentence length | |
| * - Outputs tags like <#f0a> before each character | |
| * | |
| * Non-repeating “faster” gradients: | |
| * - --curve <n> : gamma curve on the gradient position (default 1.8). Higher = faster changes early. | |
| * - --span <0..1> : finish the gradient earlier, then hold the final color (default 1.0). | |
| * | |
| * Modes: | |
| * - Args provided: one-shot (args joined into one sentence) | |
| * - Stdin piped: processes stdin block once | |
| * - No args + TTY: interactive REPL (keeps terminal open; one sentence per line) | |
| * | |
| * Options: | |
| * --anchors <2..6> Number of anchor colors (default 3) | |
| * --curve <n> Gradient curve/gamma (default 1.8, must be > 0) | |
| * --span <0..1> Portion of text used for the whole gradient (default 1.0, must be > 0) | |
| * --no-spaces Don't color spaces | |
| * --no-short Disable short hex compression (<#rgb>), force 6-digit hex (<#rrggbb>) | |
| * --no-copy Don't copy output to clipboard | |
| */ | |
| const fs = require("fs"); | |
| const readline = require("readline"); | |
| const { spawnSync } = require("child_process"); | |
| // -------------------- helpers -------------------- | |
| function clamp01(x) { | |
| return Math.max(0, Math.min(1, x)); | |
| } | |
| function lerp(a, b, t) { | |
| return a + (b - a) * t; | |
| } | |
| function rgbToHex({ r, g, b }) { | |
| const toByte = (x) => { | |
| const v = Math.round(clamp01(x) * 255); | |
| return v.toString(16).padStart(2, "0"); | |
| }; | |
| return `${toByte(r)}${toByte(g)}${toByte(b)}`.toLowerCase(); | |
| } | |
| // Compress rrggbb -> rgb when possible (e.g., ff00aa -> f0a) | |
| function compressHex6To3(hex6) { | |
| if (!/^[0-9a-f]{6}$/i.test(hex6)) return hex6; | |
| const h = hex6.toLowerCase(); | |
| if (h[0] === h[1] && h[2] === h[3] && h[4] === h[5]) { | |
| return `${h[0]}${h[2]}${h[4]}`; | |
| } | |
| return h; | |
| } | |
| // HSV -> RGB (all in [0,1]) | |
| function hsvToRgb(h, s, v) { | |
| h = ((h % 1) + 1) % 1; | |
| const i = Math.floor(h * 6); | |
| const f = h * 6 - i; | |
| const p = v * (1 - s); | |
| const q = v * (1 - f * s); | |
| const t = v * (1 - (1 - f) * s); | |
| switch (i % 6) { | |
| case 0: return { r: v, g: t, b: p }; | |
| case 1: return { r: q, g: v, b: p }; | |
| case 2: return { r: p, g: v, b: t }; | |
| case 3: return { r: p, g: q, b: v }; | |
| case 4: return { r: t, g: p, b: v }; | |
| case 5: return { r: v, g: p, b: q }; | |
| } | |
| } | |
| // Random “nice” color: high saturation/value so it pops | |
| function randomNiceRgb() { | |
| const h = Math.random(); | |
| const s = 0.75 + Math.random() * 0.25; // 0.75..1.0 | |
| const v = 0.85 + Math.random() * 0.15; // 0.85..1.0 | |
| return hsvToRgb(h, s, v); | |
| } | |
| function pickAnchors(n) { | |
| const anchors = []; | |
| for (let tries = 0; anchors.length < n && tries < 300; tries++) { | |
| anchors.push(randomNiceRgb()); | |
| } | |
| return anchors; | |
| } | |
| // Piecewise gradient across anchors for totalSteps colors | |
| // curve: gamma curve ( >1 faster early, <1 faster late) | |
| // span: 0..1 portion used to complete gradient, rest holds at end | |
| function gradientColors(anchors, totalSteps, curve = 1.8, span = 1.0) { | |
| if (totalSteps <= 0) return []; | |
| if (anchors.length === 1) return Array.from({ length: totalSteps }, () => anchors[0]); | |
| const segments = anchors.length - 1; | |
| const colors = []; | |
| const safeCurve = Number.isFinite(curve) && curve > 0 ? curve : 1.0; | |
| const safeSpan = Number.isFinite(span) && span > 0 ? Math.min(1, span) : 1.0; | |
| for (let i = 0; i < totalSteps; i++) { | |
| let u = totalSteps === 1 ? 0 : i / (totalSteps - 1); | |
| // span: finish earlier then clamp at 1 (no repetition) | |
| if (safeSpan < 1) { | |
| u = Math.min(1, u / safeSpan); | |
| } | |
| // curve: reshape for “faster” feeling without repeating | |
| u = Math.pow(u, safeCurve); | |
| const segFloat = u * segments; | |
| const segIndex = Math.min(segments - 1, Math.floor(segFloat)); | |
| const t = segFloat - segIndex; | |
| const a = anchors[segIndex]; | |
| const b = anchors[segIndex + 1]; | |
| colors.push({ | |
| r: lerp(a.r, b.r, t), | |
| g: lerp(a.g, b.g, t), | |
| b: lerp(a.b, b.b, t), | |
| }); | |
| } | |
| return colors; | |
| } | |
| function formatGradient(sentence, opts) { | |
| const chars = Array.from(sentence); | |
| const indexable = opts.colorSpaces ? chars : chars.filter((c) => c !== " "); | |
| const steps = indexable.length; | |
| if (steps === 0) return sentence; | |
| const anchors = pickAnchors(opts.anchors); | |
| const cols = gradientColors(anchors, steps, opts.curve, opts.span); | |
| let ci = 0; | |
| let out = ""; | |
| for (const ch of chars) { | |
| if (!opts.colorSpaces && ch === " ") { | |
| out += " "; | |
| continue; | |
| } | |
| const hex6 = rgbToHex(cols[ci++]); | |
| const hex = opts.shortHex ? compressHex6To3(hex6) : hex6; | |
| out += `<#${hex}>${ch}`; | |
| } | |
| return out; | |
| } | |
| // -------------------- Tagged -> ANSI preview -------------------- | |
| function expandHex3To6(hex3) { | |
| const h = hex3.toLowerCase(); | |
| return `${h[0]}${h[0]}${h[1]}${h[1]}${h[2]}${h[2]}`; | |
| } | |
| function hexToRgb(hex) { | |
| let h = hex.trim().toLowerCase(); | |
| if (h.startsWith("#")) h = h.slice(1); | |
| if (/^[0-9a-f]{3}$/.test(h)) h = expandHex3To6(h); | |
| if (!/^[0-9a-f]{6}$/.test(h)) return null; | |
| const r = parseInt(h.slice(0, 2), 16); | |
| const g = parseInt(h.slice(2, 4), 16); | |
| const b = parseInt(h.slice(4, 6), 16); | |
| return { r, g, b }; | |
| } | |
| function ansiFg({ r, g, b }) { | |
| return `\x1b[38;2;${r};${g};${b}m`; | |
| } | |
| const ANSI_RESET = "\x1b[0m"; | |
| /** | |
| * Takes a string like "<#f00>a<#0f0>b<#00f>c" | |
| * Returns a colored version for terminals (tags removed). | |
| */ | |
| function renderTaggedToAnsi(tagged) { | |
| const re = /<\#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})>/g; | |
| let out = ""; | |
| let lastIndex = 0; | |
| let currentAnsi = ""; | |
| for (const match of tagged.matchAll(re)) { | |
| const start = match.index; | |
| const end = start + match[0].length; | |
| const chunk = tagged.slice(lastIndex, start); | |
| if (chunk) out += (currentAnsi ? currentAnsi : "") + chunk; | |
| const rgb = hexToRgb(match[1]); | |
| currentAnsi = rgb ? ansiFg(rgb) : ""; | |
| lastIndex = end; | |
| } | |
| const tail = tagged.slice(lastIndex); | |
| if (tail) out += (currentAnsi ? currentAnsi : "") + tail; | |
| return out + ANSI_RESET; | |
| } | |
| // -------------------- Clipboard (no external deps) -------------------- | |
| function copyToClipboard(text) { | |
| const plat = process.platform; | |
| // macOS | |
| if (plat === "darwin") { | |
| const p = spawnSync("pbcopy", [], { input: text }); | |
| if (p.error) throw p.error; | |
| if (p.status !== 0) throw new Error("pbcopy failed"); | |
| return; | |
| } | |
| // Windows | |
| if (plat === "win32") { | |
| const p = spawnSync("clip", [], { input: text, shell: true }); | |
| if (p.error) throw p.error; | |
| if (p.status !== 0) throw new Error("clip failed"); | |
| return; | |
| } | |
| // Linux / others | |
| const attempts = [ | |
| { cmd: "wl-copy", args: [] }, // Wayland | |
| { cmd: "xclip", args: ["-selection", "clipboard"] }, // X11 | |
| { cmd: "xsel", args: ["--clipboard", "--input"] }, // X11 alt | |
| ]; | |
| for (const a of attempts) { | |
| const p = spawnSync(a.cmd, a.args, { input: text }); | |
| if (!p.error && p.status === 0) return; | |
| } | |
| throw new Error("No clipboard tool found. Install wl-clipboard (wl-copy) or xclip/xsel."); | |
| } | |
| // -------------------- CLI parsing -------------------- | |
| const argv = process.argv.slice(2); | |
| const opts = { | |
| anchors: 3, | |
| shortHex: true, | |
| colorSpaces: true, | |
| curve: 1.8, | |
| span: 1.0, | |
| copy: true, | |
| }; | |
| function takeFlag(name) { | |
| const idx = argv.indexOf(name); | |
| if (idx === -1) return false; | |
| argv.splice(idx, 1); | |
| return true; | |
| } | |
| if (takeFlag("--no-short")) opts.shortHex = false; | |
| if (takeFlag("--no-spaces")) opts.colorSpaces = false; | |
| if (takeFlag("--no-copy")) opts.copy = false; | |
| function takeNumberFlag(flag, onValue) { | |
| const idx = argv.indexOf(flag); | |
| if (idx === -1) return; | |
| const val = Number(argv[idx + 1]); | |
| onValue(val); | |
| argv.splice(idx, 2); | |
| } | |
| takeNumberFlag("--anchors", (val) => { | |
| if (Number.isFinite(val) && val >= 2 && val <= 6) opts.anchors = Math.floor(val); | |
| }); | |
| takeNumberFlag("--curve", (val) => { | |
| if (Number.isFinite(val) && val > 0) opts.curve = val; | |
| }); | |
| takeNumberFlag("--span", (val) => { | |
| if (Number.isFinite(val) && val > 0) opts.span = Math.min(1, val); | |
| }); | |
| // -------------------- main modes -------------------- | |
| function processTextBlock(text) { | |
| const lines = text.replace(/\r\n/g, "\n").split("\n"); | |
| const output = lines | |
| .map((line) => (line.length ? formatGradient(line, opts) : "")) | |
| .join("\n"); | |
| const preview = output | |
| .split("\n") | |
| .map(renderTaggedToAnsi) | |
| .join("\n"); | |
| console.log(); | |
| console.log("========PREVIEW========"); | |
| console.log(preview); | |
| console.log(); | |
| console.log("========OUTPUT" + (opts.copy ? " (COPIED TO CLIPBOARD)" : "") + "========"); | |
| console.log(output); | |
| console.log(); | |
| if (opts.copy) { | |
| try { | |
| copyToClipboard(output); | |
| } catch (e) { | |
| console.error("[copy failed]", e.message); | |
| } | |
| } | |
| } | |
| const hasArgs = argv.length > 0; | |
| const stdinIsTTY = process.stdin.isTTY; | |
| if (hasArgs) { | |
| // One-shot: sentence from args | |
| processTextBlock(argv.join(" ")); | |
| } else if (!stdinIsTTY) { | |
| // Piped stdin: process once | |
| const inputText = fs.readFileSync(0, "utf8"); | |
| processTextBlock(inputText); | |
| } else { | |
| // Interactive REPL mode | |
| const rl = readline.createInterface({ | |
| input: process.stdin, | |
| output: process.stdout, | |
| prompt: "> ", | |
| }); | |
| console.log("Gradient REPL mode. Type a sentence and press Enter."); | |
| console.log('Commands: "exit" / "quit" to leave. Ctrl+C also works.'); | |
| console.log(opts.copy ? "Clipboard: ON (use --no-copy to disable)" : "Clipboard: OFF"); | |
| rl.prompt(); | |
| rl.on("line", (line) => { | |
| const trimmed = line.trim(); | |
| if (!trimmed) { | |
| rl.prompt(); | |
| return; | |
| } | |
| const lower = trimmed.toLowerCase(); | |
| if (lower === "exit" || lower === "quit") { | |
| rl.close(); | |
| return; | |
| } | |
| processTextBlock(line); | |
| rl.prompt(); | |
| }); | |
| rl.on("close", () => { | |
| console.log("\nbye 👋"); | |
| process.exit(0); | |
| }); | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment