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: 8 additions & 2 deletions apps/demo/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ npx --yes @profullstack/hqtui-demo@latest # Node 22.6+ works too
--fps <n> Frame cap (default 30, 15 over SSH)
--theme <name> dark, dracula, nord, tokyoNight, gruvbox, matrix,
monochrome, highContrast, light
--screen <name> dashboard, components, graphics, themes, input, stress
--screen <name> dashboard, components, graphics, themes, input, stress, world
--interval <ms> Metric refresh interval (default 1000)
-h, --help Show help
-v, --version Show the version
Expand All @@ -68,7 +68,7 @@ npx --yes @profullstack/hqtui-demo@latest # Node 22.6+ works too

| Key | Action |
|---|---|
| `1`–`6`, `Tab` | Switch screens |
| `1`–`0`, `w`, `Tab` | Switch screens |
| `F1` | Help |
| `F2` | Cycle theme |
| `F3` | Filter processes |
Expand Down Expand Up @@ -118,6 +118,12 @@ with inode usage.

Every widget in the library, interactive.

### world
A clickable world map. Hover to see what is under the cursor, click to select a
country, `z` to zoom to the selection and `r` to go back to the whole globe. The
country list and the map are two views of one selection, so the arrows move the
highlight too.

### graphics, themes, input, stress
Braille vs block vs ASCII rendering, all nine themes side by side, a keyboard and
mouse event visualizer, and a full-screen churn test with live render statistics.
Expand Down
22 changes: 16 additions & 6 deletions apps/demo/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@
import { createApp, themeList, themes, type KeyEvent } from "@profullstack/hqtui";
import { createCollector } from "./system/index.ts";
import { intervalMs } from "./options.ts";
import { createState, focusedPane, moveSelection, SCREENS, type ScreenName } from "./state.ts";
import { createState, focusedPane, moveSelection, SCREENS, SCREEN_KEYS, type ScreenName } from "./state.ts";
import {
componentsScreen, dashboardScreen, graphicsScreen, inputScreen, networkScreen, servicesScreen,
sessionsScreen, stressScreen, themesScreen, trafficScreen, visibleProcesses,
sessionsScreen, stressScreen, themesScreen, trafficScreen, visibleProcesses, worldScreen,
} from "./screens/index.ts";
import { clock, num } from "./format.ts";

Expand Down Expand Up @@ -69,13 +69,13 @@ Options:
--seed <n> Simulation seed (default 1337)
--fps <n> Frame cap (default 30, 15 over SSH)
--theme <name> dark, dracula, nord, tokyoNight, gruvbox, matrix, monochrome, highContrast, light
--screen <name> dashboard, components, graphics, themes, input, stress
--screen <name> dashboard, components, graphics, themes, input, stress, world
--interval <ms> Metric refresh interval (default 1000)
-h, --help Show this help
-v, --version Show the version

Keys:
1-6 / Tab screens F2 theme F3 filter F6 sort Ctrl+K palette
1-0/w / Tab screens F2 theme F3 filter F6 sort Ctrl+K palette
↑/↓ select Space pause F1 help q quit
`);
}
Expand All @@ -91,6 +91,7 @@ const PALETTE_COMMANDS: { label: string; hint: string; run: (state: ReturnType<t
{ label: "Go to Themes", hint: "8", run: (s) => { s.screen = "themes"; } },
{ label: "Go to Input", hint: "9", run: (s) => { s.screen = "input"; } },
{ label: "Go to Stress Test", hint: "0", run: (s) => { s.screen = "stress"; } },
{ label: "Go to World Map", hint: "w", run: (s) => { s.screen = "world"; } },
{ label: "Sort by CPU", hint: "F6", run: (s) => { s.sort = "cpu"; } },
{ label: "Sort by Memory", hint: "F6", run: (s) => { s.sort = "mem"; } },
{ label: "Pause updates", hint: "Space", run: (s) => { s.paused = !s.paused; } },
Expand Down Expand Up @@ -224,6 +225,14 @@ async function main(): Promise<void> {
state.sort = order[(order.indexOf(state.sort) + 1) % order.length];
return;
}
case "w": state.screen = "world"; return;
case "z":
// The map is the only screen with somewhere to zoom to.
if (state.screen === "world") state.worldZoom = !state.worldZoom;
return;
case "r":
if (state.screen === "world") state.worldZoom = false;
return;
case "f10": app.quit(); return;
case "ctrl+k":
state.showPalette = true;
Expand Down Expand Up @@ -266,7 +275,7 @@ async function main(): Promise<void> {
ui.row({ size: 1 }, (header) => {
header.text(" hqtui.com", { fg: theme.title, bold: true, size: 12 });
header.tabs({
tabs: SCREENS.map((s, i) => `${(i + 1) % 10} ${s}`),
tabs: SCREENS.map((s) => `${SCREEN_KEYS[s]} ${s}`),
active: SCREENS.indexOf(state.screen),
onSelect: (index) => { state.screen = SCREENS[index]; },
});
Expand All @@ -288,6 +297,7 @@ async function main(): Promise<void> {
case "themes": themesScreen(body, state, theme); break;
case "input": inputScreen(body, state, theme); break;
case "stress": stressScreen(body, state, theme); break;
case "world": worldScreen(body, state, theme); break;
default: dashboardScreen(body, state, theme); break;
}
});
Expand All @@ -313,7 +323,7 @@ async function main(): Promise<void> {
width: 62,
height: 18,
message:
"1-6 or Tab switch screens.\n" +
"1-0, w or Tab switch screens; w is the clickable world map.\n" +
"F2 cycles themes, F3 filters processes, F6 changes sort.\n" +
"c collapses adjacent panel borders into shared lines.\n" +
"Ctrl+K opens the command palette, Space pauses updates.\n" +
Expand Down
2 changes: 1 addition & 1 deletion apps/demo/src/screens/components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ const TREE = [
/** Every widget in the library, in one screen. Also the interaction sandbox. */
export function componentsScreen(ui: Container, state: DemoState, theme: Theme): void {
const gap = panelGap(state);
ui.row({ size: "1fr", gap: 1 }, (row) => {
ui.row({ size: "1fr", gap }, (row) => {
row.column({ gap }, (left) => {
left.panel({ title: "Buttons & Inputs", size: 13 }, (p) => {
p.row({ size: 1, gap: 1 }, (r) => {
Expand Down
3 changes: 2 additions & 1 deletion apps/demo/src/screens/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ function disksPanel(ui: Container, state: DemoState, theme: Theme, width?: Size)

function systemPanel(ui: Container, state: DemoState, theme: Theme, width?: Size): void {
const s = state.sample;
const gap = panelGap(state);
ui.panel({ title: "System", width }, (p) => {
p.row({ size: 6, gap: 2, min: 6 }, (r) => {
r.keyValues([
Expand All @@ -114,7 +115,7 @@ function systemPanel(ui: Container, state: DemoState, theme: Theme, width?: Size
// Sub-panels need both width and height to be legible; otherwise they are
// replaced by a single dense stat line.
if (p.width >= 46 && p.height >= 16) {
p.row({ size: 6, gap: 1 }, (r) => {
p.row({ size: 6, gap }, (r) => {
r.panel({ title: "Quick Stats" }, (q) => {
q.keyValues([
{ label: "Uptime", value: duration(s.system.uptime), color: theme.accent },
Expand Down
2 changes: 1 addition & 1 deletion apps/demo/src/screens/graphics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export function graphicsScreen(ui: Container, state: DemoState, theme: Theme): v
const b = wave(240, t / 3 + 2, 5);
const c = wave(240, t / 2, 17);

ui.row({ size: "1fr", gap: 1 }, (row) => {
ui.row({ size: "1fr", gap }, (row) => {
row.column({ gap }, (left) => {
left.panel({ title: "Braille (2×4 pixels per cell)" }, (p) => {
p.graph({ values: a, min: 0, max: 100, fill: true, color: theme.accent, grid: true });
Expand Down
1 change: 1 addition & 0 deletions apps/demo/src/screens/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ export { graphicsScreen } from "./graphics.ts";
export { themesScreen } from "./themes.ts";
export { inputScreen } from "./input.ts";
export { stressScreen } from "./stress.ts";
export { worldScreen } from "./world.ts";
2 changes: 1 addition & 1 deletion apps/demo/src/screens/network.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export function networkScreen(ui: Container, state: DemoState, theme: Theme): vo
});
});

ui.row({ size: "1fr", gap: 1 }, (row) => {
ui.row({ size: "1fr", gap }, (row) => {
row.panel({
title: "Connections",
subtitle: `${t.connections.length} open`,
Expand Down
2 changes: 1 addition & 1 deletion apps/demo/src/screens/services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export function servicesScreen(ui: Container, state: DemoState, theme: Theme): v
const t = state.sample.telemetry;
const failed = t.services.filter((s) => s.active === "failed");

ui.row({ size: "1fr", gap: 1 }, (row) => {
ui.row({ size: "1fr", gap }, (row) => {
row.panel({
title: "Services",
subtitle: failed.length ? `${failed.length} failed` : `${t.services.length} units`,
Expand Down
2 changes: 1 addition & 1 deletion apps/demo/src/screens/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export function sessionsScreen(ui: Container, state: DemoState, theme: Theme): v
});
});

ui.row({ size: "1fr", gap: 1 }, (row) => {
ui.row({ size: "1fr", gap }, (row) => {
row.panel({ title: "Recent Logins", subtitle: `${t.logins.length} from wtmp`, borderColor: theme.accent }, (p) => {
if (t.logins.length === 0) {
p.label("No login history available.");
Expand Down
5 changes: 3 additions & 2 deletions apps/demo/src/screens/themes.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import type { Container, Theme } from "@profullstack/hqtui";
import { themeList } from "@profullstack/hqtui";
import type { DemoState } from "../state.ts";
import { panelGap, type DemoState } from "../state.ts";

/** Live theme switching. Every theme renders the same content side by side. */
export function themesScreen(ui: Container, state: DemoState, theme: Theme): void {
const values = state.sample.cpu.history;
const gap = panelGap(state);
ui.label(`Theme ${state.themeIndex + 1}/${themeList.length}: ${theme.name} ←/→ or F2 to change`, { size: 1 });
ui.spacer(1);
ui.grid({ columns: 3, rows: Math.ceil(themeList.length / 3), gap: 1 }, (grid) => {
ui.grid({ columns: 3, rows: Math.ceil(themeList.length / 3), gap }, (grid) => {
themeList.forEach((entry, i) => {
grid.panel({
title: entry.name,
Expand Down
2 changes: 1 addition & 1 deletion apps/demo/src/screens/traffic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ export function trafficScreen(ui: Container, state: DemoState, theme: Theme): vo
});
});

ui.row({ size: "1fr", gap: 1 }, (row) => {
ui.row({ size: "1fr", gap }, (row) => {
row.column({ gap }, (column) => {
column.panel({
title: "HTTP",
Expand Down
102 changes: 102 additions & 0 deletions apps/demo/src/screens/world.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import type { Container, Theme } from "@profullstack/hqtui";
import { WORLD_COUNTRIES, WORLD_X, WORLD_Y, countryBounds } from "@profullstack/hqtui";
import { focusPane, pane, panelGap, scrollPane, type DemoState } from "../state.ts";

/** How many coordinates a country's outlines are drawn from. */
function pointCount(rings: number[][]): number {
let total = 0;
for (const ring of rings) total += ring.length / 2;
return total;
}

function degrees(value: number, positive: string, negative: string): string {
return `${Math.abs(value).toFixed(1)}°${value >= 0 ? positive : negative}`;
}

/**
* The clickable world map.
*
* The country list and the map are two views of one selection rather than two
* selections that have to be kept in step: the list's pane index *is* which
* country is picked, so the arrows move the highlight on the map for free, and
* a click on the map only has to move the list.
*/
export function worldScreen(ui: Container, state: DemoState, theme: Theme): void {
const gap = panelGap(state);
const countries = pane(state, "world.countries", WORLD_COUNTRIES.length);
const selected = WORLD_COUNTRIES[countries.selected];
const hovered = state.worldHovered ? WORLD_COUNTRIES.find((c) => c.name === state.worldHovered) : undefined;

// Zooming is only meaningful with something to zoom to, and a country whose
// outlines are a single pixel would otherwise zoom to an empty window.
const window = state.worldZoom && selected ? countryBounds(selected) : { x: WORLD_X, y: WORLD_Y };

ui.row({ size: "1fr", gap }, (row) => {
row.panel({
title: "World",
subtitle: hovered ? hovered.name : selected?.name ?? "click a country",
subtitleColor: hovered ? theme.accent : theme.muted,
borderColor: theme.primary,
footer: state.worldZoom ? "click select · z zoom · r whole globe" : "click select · z zoom to selection",
}, (p) => {
p.worldMap({
...window,
color: theme.border,
highlight: [selected?.iso || selected?.name || "", hovered?.iso || hovered?.name || ""],
highlightColor: theme.accent,
onSelect: (country) => {
if (!country) return;
const index = WORLD_COUNTRIES.findIndex((c) => c.name === country.name);
if (index >= 0) {
countries.selected = index;
focusPane(state, "world.countries");
}
},
onHover: (country) => {
state.worldHovered = country?.name ?? "";
},
});
});

row.column({ width: 34, gap }, (column) => {
column.panel({ title: "Selection", size: 9, borderColor: theme.accent }, (p) => {
if (!selected) {
p.label("Nothing selected.");
return;
}
const b = countryBounds(selected);
p.keyValues([
{ label: "Country", value: selected.name, color: theme.accent },
{ label: "ISO", value: selected.iso || "—" },
{ label: "Longitude", value: `${degrees(b.x.min, "E", "W")} – ${degrees(b.x.max, "E", "W")}`, color: theme.primary },
{ label: "Latitude", value: `${degrees(b.y.min, "N", "S")} – ${degrees(b.y.max, "N", "S")}`, color: theme.primary },
{ label: "Outlines", value: String(selected.rings.length), color: theme.muted },
{ label: "Points", value: String(pointCount(selected.rings)), color: theme.muted },
], { spread: false });
});

column.panel({
title: "Countries",
subtitle: String(WORLD_COUNTRIES.length),
size: "1fr",
borderColor: theme.secondary,
}, (p) => {
p.table({
rows: WORLD_COUNTRIES as unknown as Array<{ name: string; iso: string }>,
selected: countries.selected,
offset: countries.offset,
followSelection: true,
onScroll: (delta) => scrollPane(countries, delta),
onFocus: () => focusPane(state, "world.countries"),
onSelectRow: (row) => { countries.selected = countries.offset + row; },
zebra: true,
scrollbar: true,
columns: [
{ key: "name", title: "Country", min: 16, color: theme.foreground },
{ key: "iso", title: "ISO", width: 4, color: theme.muted },
],
});
});
});
});
}
20 changes: 18 additions & 2 deletions apps/demo/src/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,23 @@ import type { SystemSample } from "./system/index.ts";

export type ScreenName =
| "dashboard" | "traffic" | "sessions" | "network" | "services"
| "components" | "graphics" | "themes" | "input" | "stress";
| "components" | "graphics" | "themes" | "input" | "stress" | "world";

export const SCREENS: ScreenName[] = [
"dashboard", "traffic", "sessions", "network", "services",
"components", "graphics", "themes", "input", "stress",
"components", "graphics", "themes", "input", "stress", "world",
];

/**
* The key that jumps straight to each screen. Digits run out at ten, so the
* eleventh takes a letter rather than a second "1" that would shadow the first.
*/
export const SCREEN_KEYS: Record<ScreenName, string> = {
dashboard: "1", traffic: "2", sessions: "3", network: "4", services: "5",
components: "6", graphics: "7", themes: "8", input: "9", stress: "0",
world: "w",
};

export interface DemoState {
sample: SystemSample;
screen: ScreenName;
Expand Down Expand Up @@ -42,6 +52,10 @@ export interface DemoState {
paletteIndex: number;
themeIndex: number;
/** Component-showcase interactive state. */
/** The country under the pointer on the world map, by name; "" for open water. */
worldHovered: string;
/** Whether the world map is framed on the selection rather than the globe. */
worldZoom: boolean;
toggle: boolean;
checkbox: boolean;
selectOpen: boolean;
Expand Down Expand Up @@ -142,6 +156,8 @@ export function createState(
focused: {},
sort: "cpu",
collapsed: false,
worldHovered: "",
worldZoom: false,
filter: "",
filtering: false,
showHelp: false,
Expand Down