diff --git a/apps/demo/README.md b/apps/demo/README.md index 5833305..714f015 100644 --- a/apps/demo/README.md +++ b/apps/demo/README.md @@ -58,7 +58,7 @@ npx --yes @profullstack/hqtui-demo@latest # Node 22.6+ works too --fps Frame cap (default 30, 15 over SSH) --theme dark, dracula, nord, tokyoNight, gruvbox, matrix, monochrome, highContrast, light ---screen dashboard, components, graphics, themes, input, stress +--screen dashboard, components, graphics, themes, input, stress, world --interval Metric refresh interval (default 1000) -h, --help Show help -v, --version Show the version @@ -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 | @@ -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. diff --git a/apps/demo/src/main.ts b/apps/demo/src/main.ts index 7c35270..5992c8b 100755 --- a/apps/demo/src/main.ts +++ b/apps/demo/src/main.ts @@ -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"; @@ -69,13 +69,13 @@ Options: --seed Simulation seed (default 1337) --fps Frame cap (default 30, 15 over SSH) --theme dark, dracula, nord, tokyoNight, gruvbox, matrix, monochrome, highContrast, light - --screen dashboard, components, graphics, themes, input, stress + --screen dashboard, components, graphics, themes, input, stress, world --interval 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 `); } @@ -91,6 +91,7 @@ const PALETTE_COMMANDS: { label: string; hint: string; run: (state: ReturnType { 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; } }, @@ -224,6 +225,14 @@ async function main(): Promise { 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; @@ -266,7 +275,7 @@ async function main(): Promise { 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]; }, }); @@ -288,6 +297,7 @@ async function main(): Promise { 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; } }); @@ -313,7 +323,7 @@ async function main(): Promise { 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" + diff --git a/apps/demo/src/screens/components.ts b/apps/demo/src/screens/components.ts index 0e18c0c..3ef7a63 100644 --- a/apps/demo/src/screens/components.ts +++ b/apps/demo/src/screens/components.ts @@ -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) => { diff --git a/apps/demo/src/screens/dashboard.ts b/apps/demo/src/screens/dashboard.ts index 6e97f6f..150f5f6 100644 --- a/apps/demo/src/screens/dashboard.ts +++ b/apps/demo/src/screens/dashboard.ts @@ -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([ @@ -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 }, diff --git a/apps/demo/src/screens/graphics.ts b/apps/demo/src/screens/graphics.ts index 9d232c3..e2a4a63 100644 --- a/apps/demo/src/screens/graphics.ts +++ b/apps/demo/src/screens/graphics.ts @@ -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 }); diff --git a/apps/demo/src/screens/index.ts b/apps/demo/src/screens/index.ts index 45a6bc3..3f94250 100644 --- a/apps/demo/src/screens/index.ts +++ b/apps/demo/src/screens/index.ts @@ -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"; diff --git a/apps/demo/src/screens/network.ts b/apps/demo/src/screens/network.ts index 0515440..80e4cea 100644 --- a/apps/demo/src/screens/network.ts +++ b/apps/demo/src/screens/network.ts @@ -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`, diff --git a/apps/demo/src/screens/services.ts b/apps/demo/src/screens/services.ts index 084906e..5e3b3fd 100644 --- a/apps/demo/src/screens/services.ts +++ b/apps/demo/src/screens/services.ts @@ -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`, diff --git a/apps/demo/src/screens/sessions.ts b/apps/demo/src/screens/sessions.ts index 7482236..9ca4648 100644 --- a/apps/demo/src/screens/sessions.ts +++ b/apps/demo/src/screens/sessions.ts @@ -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."); diff --git a/apps/demo/src/screens/themes.ts b/apps/demo/src/screens/themes.ts index 0f750e2..c1a2e7c 100644 --- a/apps/demo/src/screens/themes.ts +++ b/apps/demo/src/screens/themes.ts @@ -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, diff --git a/apps/demo/src/screens/traffic.ts b/apps/demo/src/screens/traffic.ts index e5428b3..51ab05d 100644 --- a/apps/demo/src/screens/traffic.ts +++ b/apps/demo/src/screens/traffic.ts @@ -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", diff --git a/apps/demo/src/screens/world.ts b/apps/demo/src/screens/world.ts new file mode 100644 index 0000000..babce9c --- /dev/null +++ b/apps/demo/src/screens/world.ts @@ -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 }, + ], + }); + }); + }); + }); +} diff --git a/apps/demo/src/state.ts b/apps/demo/src/state.ts index 490e024..9217d57 100644 --- a/apps/demo/src/state.ts +++ b/apps/demo/src/state.ts @@ -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 = { + 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; @@ -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; @@ -142,6 +156,8 @@ export function createState( focused: {}, sort: "cpu", collapsed: false, + worldHovered: "", + worldZoom: false, filter: "", filtering: false, showHelp: false,