From 2fe8c6056485b6ef7067c2f9eb8f09ac1edf5cc9 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Wed, 9 Sep 2026 16:27:07 +0000 Subject: [PATCH] Close every panel seam on collapse, and put the world map in the demo Two things the demo promised and did not do. `c` toggled collapsed borders and only half the panels moved. Every screen already computed `panelGap(state)`, which is zero while collapsed, and every screen used it for the seams inside its columns -- but the row that splits the screen into left and right was written `gap: 1` in six of them, and the library only merges a seam where two bordered siblings actually touch. So the vertical stacks closed up and the one seam running down the middle of the screen never did, on every tab after the dashboard. themes.ts never imported panelGap at all, and the three sub-panels inside the dashboard's System panel were the same oversight one level down. Rendering all eleven screens both ways and counting seams that stayed open now gives zero on all of them, where six screens had an open seam before. The world map has been in the library since #82 but was only ever reachable from examples/world.ts, so `bunx @profullstack/hqtui-demo` showed no map anywhere. It is a screen now, on `w`: hover to read the country under the cursor, click to select, z to zoom to the selection and r to go back. The country list and the map are two views of one selection rather than two that have to be kept in step -- the list's pane index *is* the selection, so the arrow keys move the highlight on the map for free and a click on the map only has to move the list. Digits run out at ten screens, so the eleventh takes a letter and the tab labels read their key from SCREEN_KEYS rather than recomputing `(i + 1) % 10`, which would have printed a second "1" and shadowed the dashboard. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VhrgsHr1C5BYxXDpN1mahf --- apps/demo/README.md | 10 ++- apps/demo/src/main.ts | 22 ++++-- apps/demo/src/screens/components.ts | 2 +- apps/demo/src/screens/dashboard.ts | 3 +- apps/demo/src/screens/graphics.ts | 2 +- apps/demo/src/screens/index.ts | 1 + apps/demo/src/screens/network.ts | 2 +- apps/demo/src/screens/services.ts | 2 +- apps/demo/src/screens/sessions.ts | 2 +- apps/demo/src/screens/themes.ts | 5 +- apps/demo/src/screens/traffic.ts | 2 +- apps/demo/src/screens/world.ts | 102 ++++++++++++++++++++++++++++ apps/demo/src/state.ts | 20 +++++- 13 files changed, 156 insertions(+), 19 deletions(-) create mode 100644 apps/demo/src/screens/world.ts 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,