anbani
Version:
Multifunctional javascript toolkit for Georgian Alphabet - Anbani
318 lines (283 loc) • 11 kB
JavaScript
// App shell: chrome (hero / tabs / status / footer), the pure reducer that
// routes events to the active screen, the help overlay, the min-size guard, and
// the runtime event loop. init()/update()/render() are pure and TTY-free for
// tests; run() wires the real terminal.
//
// KEEP IN SYNC WITH anbani.py/anbani/tui/app.py
import core from "../lib/core.mjs";
import lorem from "../lib/lorem.mjs";
import * as ui from "./ui.mjs";
import { detectCaps, makeStyle } from "./style.mjs";
import { makeTerm, CUR_HIDE, moveTo, EL } from "./term.mjs";
import { osc52 } from "./clipboard.mjs";
import * as converter from "./screens/converter.mjs";
import * as alphabet from "./screens/alphabet.mjs";
import * as loremScreen from "./screens/lorem.mjs";
import * as nlp from "./screens/nlp.mjs";
import * as toolkit from "./screens/toolkit.mjs";
export const VERSION = "3.1.0";
const SCREENS = [converter, alphabet, loremScreen, nlp, toolkit];
const BY_ID = Object.fromEntries(SCREENS.map((s) => [s.id, s]));
function mtavruli(title) {
try {
return core.convert(title, "mkhedruli", "mtavruli");
} catch (_) {
return title;
}
}
export function initialState() {
const screens = {};
for (const s of SCREENS) screens[s.id] = s.init();
return { tab: "converter", help: false, toast: null, quit: false, size: { cols: 80, rows: 24 }, screens };
}
function setScreen(state, id, scr) {
return { ...state, screens: { ...state.screens, [id]: scr } };
}
function switchTab(state, id, ctx) {
let next = { ...state, tab: id, toast: null };
const screen = BY_ID[id];
if (screen.onEnterTab) next = setScreen(next, id, screen.onEnterTab(state.screens[id], ctx));
return next;
}
function applyScreen(state, screen, scr, event, ctx) {
let ns = screen.update(scr, event, ctx);
let toast = null;
if (ns && ns._toast) {
toast = { text: ns._toast };
ns = { ...ns };
delete ns._toast;
}
if (ns && ns._copy) {
const text = ns._copy;
ns = { ...ns };
delete ns._copy;
if (ctx && ctx.copy) ctx.copy(text);
toast = { text: "copied to clipboard (OSC 52)" };
}
return setScreen({ ...state, toast }, state.tab, ns);
}
export function update(state, event, ctx) {
ctx = ctx || {};
if (event.type === "resize") return { ...state, size: { cols: event.cols, rows: event.rows } };
if (event.type === "nlp:loaded") {
return setScreen(state, "nlp", nlp.onLoaded(state.screens.nlp, ctx));
}
if (event.type === "key" && event.name === "ctrl+c") return { ...state, quit: true };
if (state.help) {
if (event.type === "key" || event.type === "char") return { ...state, help: false };
return state;
}
const screen = BY_ID[state.tab];
const scr = state.screens[state.tab];
if (screen.canInput && scr.focus === "input") {
if (event.type === "key" && event.name === "escape") {
return setScreen(state, state.tab, { ...scr, focus: "normal" });
}
return applyScreen(state, screen, scr, event, ctx);
}
const name = event.type === "char" ? event.char : event.name;
const idx = SCREENS.findIndex((s) => s.id === state.tab);
if (name && name.length === 1 && name >= "1" && name <= "5") {
const i = Number(name) - 1;
if (i < SCREENS.length) return switchTab(state, SCREENS[i].id, ctx);
}
if (name === "tab") return switchTab(state, SCREENS[(idx + 1) % SCREENS.length].id, ctx);
if (name === "shift+tab") return switchTab(state, SCREENS[(idx - 1 + SCREENS.length) % SCREENS.length].id, ctx);
if (name === "?") return { ...state, help: true };
if (name === "q") return { ...state, quit: true };
if (name === "y") {
const text = screen.getCopyText ? screen.getCopyText(scr) : null;
if (text && ctx.copy) ctx.copy(text);
return { ...state, toast: { text: text ? "copied to clipboard (OSC 52)" : "nothing to copy" } };
}
return applyScreen(state, screen, scr, event, ctx);
}
// ---- chrome / render -------------------------------------------------------
function tooSmall(C, R) {
const lines = [];
const midA = Math.floor(R / 2) - 1;
for (let i = 0; i < R; i++) {
if (i === midA) lines.push(ui.center("Terminal too small", C));
else if (i === midA + 1) lines.push(ui.center(`need ≥ 60x16 (now ${C}x${R})`, C));
else lines.push(ui.blank(C));
}
return ui.frame(lines, C, R);
}
function statusLine(state, screen, scr, C, style) {
const st = screen.status ? screen.status(scr) : {};
let text = "";
let token = "textMinor";
if (st.error) {
text = st.error;
token = "danger";
} else if (state.toast) {
text = state.toast.text;
token = "warn";
} else if (st.hint) {
text = st.hint;
token = "textMinor";
}
const cell = ui.fit(" " + text, C);
return style.on ? style.fg(token) + cell + style.reset() : cell;
}
function footerLine(screen, scr, C, style) {
const globalHints = "? help ↹ tabs q quit";
const screenHints = screen.footer ? screen.footer(scr) : "";
const cell = ui.fit(" " + globalHints + " " + screenHints, C);
return style.on ? style.fg("textMinor") + cell + style.reset() : cell;
}
function helpLines(state, C, rows, style) {
const screen = BY_ID[state.tab];
const body = [
"Global",
" 1–5 switch tabs",
" tab / shift+tab cycle tabs",
" ? toggle this help",
" i / enter edit / select",
" y copy output (OSC 52)",
" q ctrl+c quit",
"",
`Screen: ${screen.label}`,
" " + (screen.footer ? screen.footer(state.screens[state.tab]) : ""),
"",
"Tip: paste Georgian with your terminal's paste — works in insert mode.",
"",
"press any key to close",
];
const lines = [ui.boxTop(C, "help")];
for (const b of body) lines.push(ui.boxLine(b, C));
lines.push(ui.boxBottom(C));
void style;
return ui.frame(lines, C, rows);
}
export function render(state, size, style) {
const C = size.cols;
const R = size.rows;
if (C < 60 || R < 16) return tooSmall(C, R);
const screen = BY_ID[state.tab];
const scr = state.screens[state.tab];
const hero = ui.hero(mtavruli(screen.title), C, style, `anbani v${VERSION}`);
const labels = SCREENS.map((s, i) => `${i + 1} ${s.label}`);
const activeIdx = SCREENS.findIndex((s) => s.id === state.tab);
const tabsLine = ui.tabs(labels, activeIdx, C, style);
const contentRows = R - 6;
const content = state.help
? helpLines(state, C, contentRows, style)
: screen.render(scr, { cols: C, rows: contentRows }, style);
const all = hero.concat([tabsLine], content, [statusLine(state, screen, scr, C, style)], [footerLine(screen, scr, C, style)]);
return ui.frame(all, C, R);
}
// ---- splash ----------------------------------------------------------------
// Braille "ANBANI" wordmark shown while the app starts.
export const SPLASH_ART = [
"⠀⣿⣿⣿⣿⣤⠀⣿⣿⣿⣿⣿⣿⠀⠀⣿⣤⣤⠀⠀⠀⠀⣿⣿⣿⣿⣤⠀⣿⣿⣿⣿⣿⣿⠀⣿⣿⣿⣿⣿⣿⠀",
"⠀⠀⠀⠀⣿⣿⠀⣿⣿⠀⠀⠀⠀⠀⠀⠀⣿⣿⠀⠀⠀⠀⠀⠀⠀⣿⣿⠀⣿⣿⠀⠀⠀⠀⠀⣿⣿⠀⠀⣿⣿⠀",
"⠀⠀⠀⠀⣿⣿⠀⣿⣿⣿⣿⣿⣿⠀⣿⣿⣿⣿⣿⣿⠀⠀⠀⠀⠀⣿⣿⠀⣿⣿⣿⣿⣿⣿⠀⣿⣿⠀⠀⣿⣿⠀",
"⣤⣤⠀⠀⣿⣿⠀⣿⣿⠀⠀⣿⣿⠀⣿⣿⠀⠀⣿⣿⠀⣤⣤⠀⠀⣿⣿⠀⣿⣿⠀⠀⣿⣿⠀⣿⣿⠀⠀⣿⣿⠀",
"⣿⣿⣿⣿⣿⣿⠀⣿⣿⣿⣿⣿⣿⠀⣿⣿⣿⣿⣿⣿⠀⣿⣿⣿⣿⣿⣿⠀⣿⣿⣿⣿⣿⣿⠀⣿⣿⠀⠀⣿⣿⠀",
];
// The wordmark screen: white ANBANI on a black backdrop, centered.
export function splashFrame(size, style) {
const C = size.cols;
const R = size.rows;
const bg = style.on ? style.bg("appBg") : "";
const rst = style.on ? style.reset() : "";
const fill = (plain, fgTok) => (style.on ? bg + (fgTok ? style.fg(fgTok) : "") + plain + rst : plain);
const body = [];
const total = SPLASH_ART.length + 2;
const top = Math.max(0, Math.floor((R - total) / 2));
for (let i = 0; i < top; i++) body.push(fill(ui.blank(C)));
for (const line of SPLASH_ART) body.push(fill(ui.center(line, C), "textOnHero"));
body.push(fill(ui.blank(C)));
body.push(fill(ui.center(`v${VERSION} · loading…`, C), "textMinor"));
const frame = [];
for (let i = 0; i < R; i++) frame.push(i < body.length ? body[i] : fill(ui.blank(C)));
return ui.frame(frame, C, R);
}
const SPLASH_MS = 2000;
// ---- runtime loop ----------------------------------------------------------
export async function run(opts = {}) {
const term = opts.term || makeTerm();
if (!term.isTTY()) {
process.stderr.write("error: anbani tui requires an interactive terminal\n");
return 2;
}
const caps = opts.caps || detectCaps({ env: process.env, isTTY: true });
const style = makeStyle(caps);
let state = initialState();
state = { ...state, size: term.size() };
const ctx = {
copy: (text) => {
try {
term.write(osc52(text, process.env));
} catch (_) {}
},
requestNlpLoad: () => loadNlp(),
nlp: null,
lorem,
};
const paint = () => {
const lines = render(state, term.size(), style);
let frame = CUR_HIDE;
for (let i = 0; i < lines.length; i++) frame += moveTo(i + 1, 1) + lines[i] + EL;
term.write(frame);
};
async function loadNlp() {
try {
const [g, c] = await Promise.all([
import("../lib/georgianisation.mjs"),
import("../lib/contractions.mjs"),
]);
ctx.nlp = { georgianisation: g.default || g, contractions: c.default || c };
state = update(state, { type: "nlp:loaded" }, ctx);
paint();
} catch (_) {}
}
const paintSplash = () => {
const lines = splashFrame(term.size(), style);
let frame = CUR_HIDE;
for (let i = 0; i < lines.length; i++) frame += moveTo(i + 1, 1) + lines[i] + EL;
term.write(frame);
};
return await new Promise((resolve) => {
const quit = (code) => {
term.stop();
resolve(code);
};
const startApp = () => {
term.onKey((ev) => {
state = update(state, ev, ctx);
if (state.quit) return quit(0);
paint();
});
term.onPaste((text) => {
state = update(state, { type: "paste", text }, ctx);
paint();
});
term.onResize(() => {
const sz = term.size();
state = update(state, { type: "resize", cols: sz.cols, rows: sz.rows }, ctx);
paint();
});
paint();
};
let done = false;
let timer = null;
const finish = () => {
if (done) return;
done = true;
if (timer) clearTimeout(timer);
startApp();
};
// show the wordmark for ~2s; any key skips straight to the app
term.onKey(() => finish());
term.onPaste(() => {});
term.onResize(() => {
if (!done) paintSplash();
});
term.start();
paintSplash();
timer = setTimeout(finish, SPLASH_MS);
});
}
export const _screens = { converter, alphabet, lorem: loremScreen, nlp, toolkit };