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
2 changes: 1 addition & 1 deletion .github/workflows/bindings.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ jobs:
- name: Install only binding test toolchains
run: mise install ruby@4.0.6 conda:php@8.5.9 perl@5.44.0.0 cmake@4.4.3 python@3.12.13
- name: Install private Perl dependencies
run: mise exec perl@5.44.0.0 -- cpanm --local-lib-contained "${{ runner.temp }}/hqtui-perl" --notest --mirror https://cpan.metacpan.org --mirror-only FFI::Platypus@2.11
run: mise exec perl@5.44.0.0 -- cpanm --local-lib-contained "${{ runner.temp }}/hqtui-perl" --notest --mirror https://cpan.metacpan.org --mirror-only FFI::Platypus@2.12
- name: Build optimized binding library and PHP adapter
shell: bash
run: |
Expand Down
38 changes: 38 additions & 0 deletions apps/web/app/docs/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const SECTIONS = [
{ id: "widgets", label: "Widgets" },
{ id: "graphics", label: "Graphics" },
{ id: "themes", label: "Themes" },
{ id: "icons", label: "Icons" },
{ id: "input", label: "Input" },
{ id: "testing", label: "Testing" },
{ id: "escape-hatches", label: "Escape hatches" },
Expand Down Expand Up @@ -323,6 +324,43 @@ const app = await createApp({ theme: brand });
app.setTheme(themes.nord); // switch at runtime`}
/>

<H2 id="icons">Icons</H2>
<P>
The <a className="underline" href="https://logicsrc.com/openicon">OpenIcon</a> pack is built in and
on by default: 370 icons, from <code className="font-mono text-white/80">mail</code> and{" "}
<code className="font-mono text-white/80">git-branch</code> to{" "}
<code className="font-mono text-white/80">github</code> and{" "}
<code className="font-mono text-white/80">bluesky</code>. Each has three glyphs, and{" "}
<code className="font-mono text-white/80">icon()</code> returns the best one this terminal can draw: a
Nerd Font glyph, a Unicode symbol, or ASCII. Aliases work too, so{" "}
<code className="font-mono text-white/80">icon(&quot;email&quot;)</code> is{" "}
<code className="font-mono text-white/80">icon(&quot;mail&quot;)</code>.
</P>
<Code
className="mt-4"
code={`import { icon, setIconMode } from "@profullstack/hqtui";

ui.text(\`\${icon("mail")} Inbox \${icon("git-branch")} main\`);
// 󰇰 Inbox main with a Nerd Font
// ✉ Inbox ⎇ main in a UTF-8 terminal
// @ Inbox Y main anywhere else

icon("github", { mode: "ascii" }); // "gh", for this call only
setIconMode("nerd"); // for the whole app`}
/>
<P>
A Nerd Font cannot be detected from inside a terminal, so it is never assumed. Set{" "}
<code className="font-mono text-white/80">NERD_FONT=1</code>, or{" "}
<code className="font-mono text-white/80">OPENICON_GLYPHS=nerd|unicode|ascii</code>, or call{" "}
<code className="font-mono text-white/80">setIconMode()</code>. Otherwise you get Unicode where the
terminal draws it and ASCII where it does not. An icon Nerd Fonts has no glyph for falls back to Unicode,
and an unknown name draws nothing. Swap in another OpenIcon set with{" "}
<code className="font-mono text-white/80">useIconPack(iconPackFrom(json))</code>. The Rust, Go and Python
ports carry the same table: <code className="font-mono text-white/80">icon(&quot;mail&quot;)</code>,{" "}
<code className="font-mono text-white/80">hqtui.Icon(&quot;mail&quot;)</code>,{" "}
<code className="font-mono text-white/80">hqtui.icon(&quot;mail&quot;)</code>.
</P>

<H2 id="input">Input</H2>
<P>
Keys arrive normalized — <code className="font-mono text-white/80">&quot;ctrl+c&quot;</code>,{" "}
Expand Down
22 changes: 22 additions & 0 deletions packages/hqtui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,28 @@ activity, sessions, services and the full widget catalogue.
| **Input** | normalized keys with modifiers, SGR mouse (click, drag, scroll, move), bracketed paste, focus events, Tab focus traversal |
| **Testing** | headless renderer: `renderToText`, `renderToScreen`, `renderToAnsi`, `renderToHtml` — no TTY required |

## Icons

The [OpenIcon](https://logicsrc.com/openicon) pack is built in and on by
default: 370 icons, each with a Nerd Font glyph, a Unicode symbol and an ASCII
spelling. `icon()` returns the best one this terminal can draw.

```ts
import { icon, setIconMode } from "@profullstack/hqtui";

ui.text(`${icon("mail")} Inbox ${icon("git-branch")} main`);
// 󰇰 Inbox main with a Nerd Font
// ✉ Inbox ⎇ main in a UTF-8 terminal
// @ Inbox Y main anywhere else
```

A Nerd Font is never assumed, because it cannot be detected from inside the
terminal: set `NERD_FONT=1`, `OPENICON_GLYPHS=nerd|unicode|ascii`, or call
`setIconMode()`. Aliases resolve (`icon("email")`), an icon Nerd Fonts lacks
falls back to Unicode, and an unknown name draws nothing. `useIconPack()` swaps
in any other OpenIcon set. The table is generated from the set by
`scripts/generate-icons.ts`, for this library and the Rust, Go and Python ports.

## Testing your TUI

Terminal apps are usually untestable. Here they are not:
Expand Down
150 changes: 150 additions & 0 deletions packages/hqtui/scripts/generate-icons.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
/**
* Regenerate the built-in icon pack from an OpenIcon set.
*
* node packages/hqtui/scripts/generate-icons.ts [openicon.json | URL]
*
* Defaults to the reference set, github.com/profullstack/openicon. Writes the
* same table for the TypeScript library and the Go, Python and Rust ports, so
* `icon("mail")` is the same glyph in every language. Only the terminal
* glyphs are kept (Nerd Font, Unicode, ASCII) plus aliases; the SVGs are for
* other surfaces and never ship in a terminal library.
*/

import { spawnSync } from "node:child_process";
import { readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";

const DEFAULT = "https://raw.githubusercontent.com/profullstack/openicon/main/openicon.json";
const root = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");

interface Entry {
key: string;
aliases?: string[];
tui: { nerd?: string; unicode: string; ascii: string };
}
interface Set {
openicon: string;
name: string;
version: string;
icons: Entry[];
}

/** Byte order, not locale order: the Rust port binary-searches these tables. */
const byteOrder = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0);

const source = process.argv[2] ?? DEFAULT;
const set: Set = source.startsWith("http")
? await (await fetch(source)).json()
: JSON.parse(readFileSync(source, "utf8"));

const rows = set.icons
.map((i) => [i.key, i.tui.nerd ?? "", i.tui.unicode, i.tui.ascii] as const)
.sort((a, b) => byteOrder(a[0], b[0]));
const aliases = set.icons
.flatMap((i) => (i.aliases ?? []).map((a) => [a, i.key] as const))
.sort((a, b) => byteOrder(a[0], b[0]));
const stamp = `${set.name} ${set.version} (OpenIcon ${set.openicon}), ${rows.length} icons`;

/** A string literal every one of the four languages reads the same way. */
const lit = (s: string) =>
`"${[...s]
.map((ch) => {
const cp = ch.codePointAt(0)!;
if (ch === '"' || ch === "\\") return `\\${ch}`;
return cp >= 0x20 && cp < 0x7f ? ch : null;
})
.map((out, i) => out ?? escapeFor([...s][i]!))
.join("")}"`;
let escapeFor = (ch: string) => `\\u{${ch.codePointAt(0)!.toString(16)}}`;

function write(rel: string, text: string) {
writeFileSync(join(root, rel), text);
console.log(`wrote ${rel}`);
}

// TypeScript: \u{...} escapes.
escapeFor = (ch) => `\\u{${ch.codePointAt(0)!.toString(16)}}`;
write(
"packages/hqtui/src/icons-data.ts",
`// Generated by scripts/generate-icons.ts from ${stamp}. Do not edit.
// [key, nerd, unicode, ascii]; an empty nerd means Nerd Fonts has no glyph for it.

export const OPENICON_VERSION = ${lit(set.version)};

export const OPENICON_GLYPHS: ReadonlyArray<readonly [string, string, string, string]> = [
${rows.map((r) => ` [${r.map(lit).join(", ")}],`).join("\n")}
];

export const OPENICON_ALIASES: ReadonlyArray<readonly [string, string]> = [
${aliases.map((a) => ` [${a.map(lit).join(", ")}],`).join("\n")}
];
`,
);

// Go: \U00XXXXXX escapes.
escapeFor = (ch) => `\\U${ch.codePointAt(0)!.toString(16).padStart(8, "0")}`;
write(
"ports/go/icons_data.go",
`// Code generated by packages/hqtui/scripts/generate-icons.ts from ${stamp}. DO NOT EDIT.

package hqtui

// OpenIconVersion is the OpenIcon set the built-in pack was generated from.
const OpenIconVersion = ${lit(set.version)}

// {key, nerd, unicode, ascii}; an empty nerd means Nerd Fonts has no glyph for it.
var openIconGlyphs = [][4]string{
${rows.map((r) => `\t{${r.map(lit).join(", ")}},`).join("\n")}
}

var openIconAliases = map[string]string{
${aliases.map(([a, k]) => `\t${lit(a)}: ${lit(k)},`).join("\n")}
}
`,
);

// gofmt aligns the alias map; run it when Go is installed, so the file is
// already in the shape CI's gofmt check expects.
spawnSync("gofmt", ["-w", join(root, "ports/go/icons_data.go")], { stdio: "ignore" });

// Python: \UXXXXXXXX escapes.
escapeFor = (ch) => `\\U${ch.codePointAt(0)!.toString(16).padStart(8, "0")}`;
write(
"ports/python/hqtui/icons_data.py",
`# Generated by packages/hqtui/scripts/generate-icons.ts from ${stamp}. Do not edit.
# (key, nerd, unicode, ascii); an empty nerd means Nerd Fonts has no glyph for it.

OPENICON_VERSION = ${lit(set.version)}

OPENICON_GLYPHS = (
${rows.map((r) => ` (${r.map(lit).join(", ")}),`).join("\n")}
)

OPENICON_ALIASES = {
${aliases.map(([a, k]) => ` ${lit(a)}: ${lit(k)},`).join("\n")}
}
`,
);

// Rust: \u{...} escapes.
escapeFor = (ch) => `\\u{${ch.codePointAt(0)!.toString(16)}}`;
write(
"ports/rust/src/icons_data.rs",
`// Generated by packages/hqtui/scripts/generate-icons.ts from ${stamp}. Do not edit.
// (key, nerd, unicode, ascii); an empty nerd means Nerd Fonts has no glyph for it.

/// The OpenIcon set the built-in pack was generated from.
pub const OPENICON_VERSION: &str = ${lit(set.version)};

/// Sorted by key, so lookups can binary-search.
pub static OPENICON_GLYPHS: &[(&str, &str, &str, &str)] = &[
${rows.map((r) => ` (${r.map(lit).join(", ")}),`).join("\n")}
];

/// Sorted by alias.
pub static OPENICON_ALIASES: &[(&str, &str)] = &[
${aliases.map((a) => ` (${a.map(lit).join(", ")}),`).join("\n")}
];
`,
);
Loading
Loading