diff --git a/.github/workflows/c-cpp.yml b/.github/workflows/c-cpp.yml index e420254..e50c4bd 100644 --- a/.github/workflows/c-cpp.yml +++ b/.github/workflows/c-cpp.yml @@ -28,7 +28,7 @@ jobs: - name: Configure run: cmake -S ports/cpp -B ports/cpp/build -DCMAKE_BUILD_TYPE=Release -DBUILD_SHARED_LIBS=${{ matrix.shared }} - run: cmake --build ports/cpp/build --config Release --parallel - - run: ctest --test-dir ports/cpp/build -C Release --output-on-failure + - run: ctest --test-dir ports/cpp/build -C Release --output-on-failure --timeout 120 - name: Install without touching system directories run: cmake --install ports/cpp/build --config Release --prefix "${{ runner.temp }}/hqtui-install" - name: Build separate installed consumers diff --git a/apps/demo/scripts/check-native-data.py b/apps/demo/scripts/check-native-data.py index 20a36f8..5aed445 100644 --- a/apps/demo/scripts/check-native-data.py +++ b/apps/demo/scripts/check-native-data.py @@ -16,6 +16,11 @@ 'python': ['mise', 'exec', 'python@3.12.13', '--', 'python', '-m', 'examples.dashboard'], 'zig': ['mise', 'exec', 'zig@0.16.0', '--', 'zig', 'build', 'run-dashboard', '--'], } +if os.environ.get('HQTUI_CPP_DEMO'): + COMMANDS['cpp'] = [os.environ['HQTUI_CPP_DEMO']] +if os.environ.get('HQTUI_NATIVE_LANGUAGES'): + selected = os.environ['HQTUI_NATIVE_LANGUAGES'].split(',') + COMMANDS = {language: COMMANDS[language] for language in selected} def main(): @@ -33,18 +38,28 @@ def main(): elif name=='journalctl': if '-u' in sys.argv: print(fixture['ssh']) elif name=='nvidia-smi': print('Test GPU, 25, 100, 1000, 55, 30') +elif name=='tail': print(fixture['http']) +elif name=='docker': print(json.dumps({'Names':'cpp-fixture-container','Image':'test:local','Status':'Up 1 hour'})) ''' with tempfile.TemporaryDirectory(prefix='hqtui-native-data-') as directory: - for name in ('who', 'last', 'lastb', 'ss', 'journalctl', 'nvidia-smi'): + utilities = ['who', 'last', 'lastb', 'ss', 'journalctl', 'nvidia-smi'] + if 'cpp' in COMMANDS: + utilities += ['tail', 'docker'] + for name in utilities: path = Path(directory) / name path.write_text(utility) path.chmod(0o700) env = {**os.environ, 'PATH': directory + os.pathsep + os.environ['PATH'], 'HQTUI_TEST_TRAFFIC': json.dumps(fixture)} for language, command in COMMANDS.items(): - for screen, expected in [('dashboard', ['Sensors', 'Test GPU', '25%']), - ('traffic', ['HTTPS', 'DNS', 'SSH', 'accepted', 'alice']), - ('sessions', ['alice', 'eve', 'still', 'failed'])]: + cases = [('dashboard', ['Sensors', 'Test GPU', '25%']), + ('traffic', ['HTTPS', 'DNS', 'SSH', 'accepted', 'alice']), + ('sessions', ['alice', 'eve', 'still', 'failed'])] + if language == 'cpp': + cases[1][1].extend(['/health', '/missing', '/chat']) + cases.append(('services', ['cpp-fixture-container', 'test:local'])) + cases.append(('network', ['127.0.0.1'])) + for screen, expected in cases: result = subprocess.run([*command, '--real', '--snapshot', '--screen', screen, '--width', '240', '--height', '80'], cwd=ROOT / 'ports' / language, env=env, capture_output=True, text=True, timeout=120) @@ -52,6 +67,8 @@ def main(): for label in expected: assert label.lower() in result.stdout.lower(), (language, screen, f'missing {label}') assert 'simulated' not in result.stdout.lower(), (language, screen) + if language == 'cpp': + assert 'token=secret' not in result.stdout, 'HTTP query secret leaked' print(f'{language} {screen}: fixture utilities reached the real collector and rendered rows', flush=True) diff --git a/apps/demo/scripts/test-updater.py b/apps/demo/scripts/test-updater.py index 9dc54d4..00e3452 100644 --- a/apps/demo/scripts/test-updater.py +++ b/apps/demo/scripts/test-updater.py @@ -40,7 +40,7 @@ def git(self, *args): return subprocess.check_output(["git", *args], cwd=self.repo, stderr=subprocess.DEVNULL, text=True).strip() def commit(self, label): - (self.repo / "mise.toml").write_text('[tools]\nbun = "1.4.0"\npython = "3.12.13"\nrust = "1.97.1"\ngo = "1.26.0"\nzig = "0.16.0"\n') + (self.repo / "mise.toml").write_text('[tools]\nbun = "1.4.0"\npython = "3.12.13"\nrust = "1.97.1"\ngo = "1.26.0"\nzig = "0.16.0"\ncmake = "4.4.3"\n') (self.repo / ".gitignore").write_text('__pycache__/\n') path = self.repo / "ports/python/examples" path.mkdir(parents=True, exist_ok=True) @@ -49,7 +49,7 @@ def commit(self, label): 'import json, os, sys\n' f'print(json.dumps({{"revision": {label!r}, "args": sys.argv[1:], "cwd": os.getcwd(), "tty": os.isatty(0)}}), flush=True)\n' 'if "--wait" in sys.argv: input()\n') - for language in ("rust", "go", "zig"): + for language in ("rust", "go", "zig", "cpp"): (self.repo / "ports" / language).mkdir(exist_ok=True) (self.repo / "ports" / language / "source").write_text(label) self.git("add", ".") @@ -100,7 +100,7 @@ def test_check_only_reports_fetched_commit(self): self.assertFalse((self.root / "cache/v1/revisions").exists()) def test_invalid_language_and_missing_terminal_fail_before_fetch(self): - for args in (("--system", "cpp"), ("--system", "python")): + for args in (("--system", "c"), ("--system", "python")): result = self.run_demo(*args) self.assertNotEqual(result.returncode, 0) self.assertFalse((self.root / "cache").exists()) @@ -124,13 +124,21 @@ def stub_compilers(self): elif tool=="zig": assert args[0:3]==["build","-Doptimize=ReleaseFast","--prefix"] output=pathlib.Path(args[3])/"bin/hqtui-demo-zig" +elif tool=="cmake": + if args[0]=="-S": + source=pathlib.Path(args[1]); build=pathlib.Path(args[3]) + build.mkdir(parents=True,exist_ok=True) + (build/"source").write_text((source/"source").read_text()) + sys.exit(0) + assert args[0]=="--build" and args[2:4]==["--target","hqtui-demo-cpp"] + output=pathlib.Path(args[1])/"hqtui-demo-cpp" else: raise AssertionError(tool) -label=pathlib.Path("source").read_text() +label=(pathlib.Path(args[1])/"source" if tool=="cmake" else pathlib.Path("source")).read_text() output.parent.mkdir(parents=True,exist_ok=True) output.write_text("#!/usr/bin/env python3\\nimport json,sys\\nprint(json.dumps(dict(revision="+repr(label)+",args=sys.argv[1:])))\\n") output.chmod(0o700) ''' - for name in ("mise", "cargo", "go", "zig"): + for name in ("mise", "cargo", "go", "zig", "cmake"): path = self.bin / name path.write_text(code) path.chmod(0o700) @@ -138,22 +146,22 @@ def stub_compilers(self): def test_vanilla_and_mise_build_latest_and_reuse_only_same_revision(self): self.stub_compilers() for manager in ("--system", "--mise"): - for language in ("rust", "go", "zig"): + for language in ("rust", "go", "zig", "cpp"): result = self.run_demo(manager, language, "--snapshot", "argument with spaces") self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual(json.loads(result.stdout)["revision"], "first") self.assertEqual(json.loads(result.stdout)["args"][-1], "argument with spaces") log = [json.loads(line) for line in (self.root / "tools.jsonl").read_text().splitlines()] - self.assertEqual(sum(row[0] in ("cargo", "go", "zig") for row in log), 6) - for language in ("rust", "go", "zig"): + self.assertEqual(sum(row[0] in ("cargo", "go", "zig") or row[:2]==["cmake","--build"] for row in log), 8) + for language in ("rust", "go", "zig", "cpp"): self.assertEqual(self.run_demo("--mise", language, "--snapshot").returncode, 0) self.commit("second") - for language in ("rust", "go", "zig"): + for language in ("rust", "go", "zig", "cpp"): result = self.run_demo("--mise", language, "--snapshot") self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual(json.loads(result.stdout)["revision"], "second") log = [json.loads(line) for line in (self.root / "tools.jsonl").read_text().splitlines()] - self.assertEqual(sum(row[0] in ("cargo", "go", "zig") for row in log), 9) + self.assertEqual(sum(row[0] in ("cargo", "go", "zig") or row[:2]==["cmake","--build"] for row in log), 12) @unittest.skipUnless(os.name == "posix", "requires a controlling PTY") def test_pipe_launcher_reattaches_keyboard_and_releases_build_lock(self): diff --git a/apps/web/app/docs/page.tsx b/apps/web/app/docs/page.tsx index 3bd960b..35583fa 100644 --- a/apps/web/app/docs/page.tsx +++ b/apps/web/app/docs/page.tsx @@ -11,6 +11,7 @@ import { CLONE, LANGUAGES, PORTS } from "@/lib/languages"; export const dynamic = "force-dynamic"; export const metadata = { title: "Docs" }; +const TYPESCRIPT = LANGUAGES.find(({ id }) => id === "typescript")!; const SECTIONS = [ { id: "languages", label: "Choose a language" }, @@ -64,9 +65,9 @@ export default async function Docs() {

Documentation

- HQTUI builds terminal applications in TypeScript, Rust, Go, Python and Zig. - All five implementations provide differential rendering, Braille graphics, - truecolor, widgets and headless testing. + HQTUI builds terminal applications in TypeScript, Rust, Go, Python and Zig, + with a new native C++ ten-screen demo over the shared C rendering core. + The C++ library API remains experimental.

Choose a language

@@ -76,12 +77,12 @@ export default async function Docs() { ))}

- Rust, Go, Python and Zig are native ports with no JavaScript runtime requirement. + Rust, Go, Python, Zig and C++ demos need no JavaScript runtime. Both the vanilla and mise commands below fetch latest main before running. They work from any directory and never switch branches, reset, or pull in your checkout. - All four dashboard commands now launch ten-screen native demos. Live metrics + All five native dashboard commands launch ten-screen demos. Live metrics currently require Linux; use --sim for generated sample data on other platforms. - All four native demos now use the TypeScript reference's ten screen layouts, + All five native demos use the TypeScript reference's ten screen layouts, including its responsive dashboard, detailed telemetry tabs and widget showcases. Each port is checked against 120 shared TypeScript reference frames across four terminal sizes and three themes. Live-data availability still depends on the host @@ -89,6 +90,13 @@ export default async function Docs() { Use 1–9 / 0 or Tab to change screens and q to quit. Headless screenshots work without a TTY. Zig requires version 0.16.

+

+ C++ requires a C++17 compiler (GCC or Clang) and CMake 3.20+. + Its mise command supplies pinned CMake; you still need your platform's + C/C++ build tools. The interactive C++ terminal supports Linux/macOS; + live collection and exact 120-frame parity are currently tested on Linux. + The C-only demo and other mise language integrations are not ready yet. +

Linux sensor panels now collect available hwmon temperatures, fans, voltage, current and power, plus CPU clocks (including the /proc/cpuinfo fallback for VMs), @@ -127,7 +135,7 @@ export default async function Docs() {

For development only, you can still clone the monorepo and use its local commands; those do not auto-update:

-

In an updated checkout, mise run demo:rust also updates before running (likewise demo:go, demo:python, demo:zig and demo:typescript). Use demo-local:rust and the other demo-local tasks to work on your local edits without updating.

+

In an updated checkout, mise run demo:cpp updates before running (likewise demo:rust, demo:go, demo:python, demo:zig and demo:typescript). Use demo-local:cpp and the other demo-local tasks to work on your local edits without updating.

Read how the ports share a conformance corpus. The API guide below describes the TypeScript reference implementation. @@ -135,8 +143,8 @@ export default async function Docs() {

Install TypeScript

Try the full ten-screen demo with simulated data, without creating an app:

- - + +

Omit --sim to use real system metrics. To build your own app, install the library:

diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index 2add662..5a77674 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -49,7 +49,7 @@ const DASHBOARD = `app.render(({ ui }) => { });`; /** Fetch current main into a private cache, build, then run. */ -const DEMO = LANGUAGES[0].demo; +const DEMO = LANGUAGES.find(({ id }) => id === "typescript")!.demo; const TESTING = `import { renderToScreen } from "@profullstack/hqtui"; @@ -144,12 +144,12 @@ export default async function Home() { priority className="mx-auto mb-6 w-[22rem] max-w-full sm:w-[30rem]" /> -

HQTUI — High Quality Terminal UI for TypeScript, Rust, Go, Python and Zig

+

HQTUI — High Quality Terminal UI for TypeScript, Rust, Go, Python, Zig and C++

- High Quality Terminal UI for TypeScript, Rust, Go, Python and Zig + High Quality Terminal UI for TypeScript, Rust, Go, Python, Zig and C++

- v0.1.12 · 5 languages · MIT + v0.1.12 · 6 language demos · MIT

Terminal dashboards that @@ -227,17 +227,18 @@ export default async function Home() {

-

One terminal UI, five languages

+

One terminal UI, six language demos

Build in TypeScript or use a native Rust, Go, Python or Zig implementation. - Each port includes the app loop, widgets, themes, input handling and a headless renderer. - The four native ports need no JavaScript runtime and use only their standard libraries. + C++ now has a native ten-screen demo over the shared C rendering core, with an experimental library API. + The native demos need no JavaScript runtime.

Every command checks latest main, builds in a private cache, and runs it. No manual clone or pull. Vanilla uses your installed toolchain; mise uses - the pinned toolchain. Git and curl are required; the first build takes longer. + the pinned toolchain. C++ uses pinned CMake with your installed GCC/Clang. + Git and curl are required; the first build takes longer.

diff --git a/apps/web/lib/languages.ts b/apps/web/lib/languages.ts index 12aa16c..dac4350 100644 --- a/apps/web/lib/languages.ts +++ b/apps/web/lib/languages.ts @@ -17,11 +17,22 @@ export type Language = { export const CLONE = "git clone https://github.com/profullstack/hqtui"; export const LAUNCHER = "https://hqtui.com/demo.sh"; export function latestDemo(language: string, mise = false): string { - if (!["typescript", "rust", "go", "python", "zig"].includes(language)) throw new Error("Unsupported demo language"); + if (!["typescript", "rust", "go", "python", "zig", "cpp"].includes(language)) throw new Error("Unsupported demo language"); return `curl -fsSL ${LAUNCHER} | sh -s -- --${mise ? "mise" : "system"} ${language}`; } export const LANGUAGES: readonly Language[] = [ + { + name: "C++", + id: "cpp", + description: "Native C++17 demo over the shared C renderer. Experimental library API; requires GCC/Clang and CMake.", + href: "/docs#cpp", + demo: latestDemo("cpp"), + snapshotDemo: `${latestDemo("cpp")} --snapshot`, + interactiveDemo: latestDemo("cpp"), + miseDemo: latestDemo("cpp", true), + native: true, + }, { name: "TypeScript", id: "typescript", diff --git a/apps/web/public/demo.sh b/apps/web/public/demo.sh index e1f62c7..96a3529 100644 --- a/apps/web/public/demo.sh +++ b/apps/web/public/demo.sh @@ -9,7 +9,7 @@ main() ( note() { printf 'hqtui-demo: %s\n' "$*" >&2; } usage() { printf '%s\n' 'Usage: demo.sh [--mise|--system] [--check] LANGUAGE [demo arguments...]' \ - 'Languages: typescript (ts), rust, go, python, zig' \ + 'Languages: typescript (ts), rust, go, python, zig, cpp (c++)' \ 'Every invocation fetches latest main. --check prints the revision without building.' \ 'Defaults to mise when installed, otherwise uses your installed compiler/runtime.' \ 'Examples: demo.sh rust --sim; demo.sh --mise rust --snapshot' @@ -31,7 +31,8 @@ main() ( case "$language" in ts|typescript) language=typescript; tool=bun ;; rust|go|python|zig) tool=$language ;; - *) fail "Unsupported language '$language'. C/C++ demos are not ready; use typescript, rust, go, python or zig." ;; + cpp|c++) language=cpp; tool=cmake ;; + *) fail "Unsupported language '$language'. The C-only demo is not ready; use typescript, rust, go, python, zig or cpp." ;; esac # When invoked through curl | sh, the pipe is not the demo's keyboard. # Connect only interactive runs to the controlling terminal; preserve pipes @@ -55,7 +56,7 @@ main() ( command -v mise >/dev/null 2>&1 || fail 'mise was requested but is not installed. Install mise or omit --mise.' fi if [ "$manager" = system ] && [ "$check" -eq 0 ]; then - case "$language" in rust) driver=cargo ;; typescript) driver=bun ;; python) driver=python3 ;; *) driver=$language ;; esac + case "$language" in rust) driver=cargo ;; typescript) driver=bun ;; python) driver=python3 ;; cpp) driver=cmake ;; *) driver=$language ;; esac driver_path=$(command -v "$driver") || fail "$driver is not installed. Install it, or use --mise." # Resolve a mise shim BEFORE entering the fetched checkout. Vanilla must # not accidentally load or ask to trust the downloaded mise.toml. Keep the @@ -152,6 +153,22 @@ main() ( bins=$cache/bin/$platform/$manager-$tool-$version/$revision mkdir -p "$bins" "$cache/build" case "$language" in + cpp) + case "$(uname -s)" in Linux|Darwin) ;; *) fail 'The C++ terminal demo currently supports Linux/macOS; use another demo on this platform.' ;; esac + # mise manages CMake here. The native C/C++ compiler is supplied by + # the host (GCC/Clang), not silently replaced or downloaded. + command -v "${CXX:-c++}" >/dev/null 2>&1 || fail 'A C++17 compiler is required (GCC/Clang). Install your platform build tools, then rerun.' + if [ ! -f "$bins/cpp.ready" ] || [ ! -x "$bins/cpp" ]; then + note 'Building optimized C++ demo (first run of this revision)…' + cpp_build=$cache/build/cpp/$platform/$manager/$revision + run_tool cmake -S "$source/ports/cpp" -B "$cpp_build" -DCMAKE_BUILD_TYPE=Release -DBUILD_SHARED_LIBS=OFF -DBUILD_TESTING=OFF -DHQTUI_LTO=ON >&2 + run_tool cmake --build "$cpp_build" --target hqtui-demo-cpp --parallel 2 >&2 + cp "$cpp_build/hqtui-demo-cpp" "$bins/cpp.pending" + mv "$bins/cpp.pending" "$bins/cpp" + printf '%s\n' "$revision" > "$bins/cpp.ready" + fi + launch "$bins/cpp" "$@" + ;; rust) cd "$source/ports/rust" if [ ! -f "$bins/rust.ready" ] || [ ! -x "$bins/rust" ]; then diff --git a/apps/web/test/languages.test.ts b/apps/web/test/languages.test.ts index 04adc35..98f5dac 100644 --- a/apps/web/test/languages.test.ts +++ b/apps/web/test/languages.test.ts @@ -7,7 +7,7 @@ import { LANGUAGES, PORTS, LAUNCHER, latestDemo } from "../lib/languages.ts"; const root = resolve(import.meta.dirname, "../../.."); test("every supported language has latest-source vanilla and mise demo commands", () => { - assert.deepEqual(LANGUAGES.map(({ id }) => id), ["typescript", "rust", "go", "python", "zig"]); + assert.deepEqual(LANGUAGES.map(({ id }) => id), ["cpp", "typescript", "rust", "go", "python", "zig"]); for (const language of LANGUAGES) { assert.ok(language.interactiveDemo.length > 0, language.id); assert.ok(language.interactiveDemo.startsWith(`curl -fsSL ${LAUNCHER} | sh -s -- --system ${language.id}`)); @@ -18,6 +18,7 @@ test("every supported language has latest-source vanilla and mise demo commands" test("native updater commands retain full-dashboard source entrypoints", () => { const files: Record = { + cpp: ["demo/main.cpp", "demo/dashboard.cpp"], rust: ["examples/dashboard.rs", "examples/screenshot.rs"], go: ["examples/dashboard/main.go", "examples/screenshot/main.go"], python: ["examples/dashboard.py", "examples/screenshot.py"], @@ -34,7 +35,8 @@ test("the homepage also exposes both vanilla and mise commands", () => { const page = readFileSync(resolve(root, "apps/web/app/page.tsx"), "utf8"); assert.ok(page.includes("command={language.demo}")); assert.ok(page.includes("command={language.miseDemo}")); - assert.throws(() => latestDemo("cpp"), /Unsupported/); + assert.ok(page.includes('LANGUAGES.find(({ id }) => id === "typescript")')); + assert.throws(() => latestDemo("c"), /Unsupported/); assert.ok(existsSync(resolve(root, "apps/web/public/demo.sh"))); }); @@ -45,4 +47,7 @@ test("docs show demos and mise alternatives at the existing native language anch assert.ok(page.includes("command={port.miseDemo}")); assert.ok(page.includes("Headless screenshot")); assert.ok(page.includes("generated sample data")); + assert.ok(page.includes('LANGUAGES.find(({ id }) => id === "typescript")')); + assert.ok(page.includes("command={TYPESCRIPT.interactiveDemo}")); + assert.ok(page.includes("command={TYPESCRIPT.miseDemo}")); }); diff --git a/mise.toml b/mise.toml index 1dc2b2e..efbf469 100644 --- a/mise.toml +++ b/mise.toml @@ -1,4 +1,4 @@ -# Reproducible toolchains for the five native implementations in this monorepo. +# Reproducible runtime/build tools for the demos in this monorepo. # Adding a tool here is not a claim that a native hqtui library exists for it. [tools] bun = "1.4.0" @@ -6,6 +6,15 @@ python = "3.12.13" go = "1.26.0" rust = "1.97.1" zig = "0.16.0" +cmake = "4.4.3" + +[tasks."demo:cpp"] +description = "Update and run the native C++ ten-screen demo (requires C/C++ compiler)" +run = "sh apps/web/public/demo.sh --mise cpp" + +[tasks."demo-local:cpp"] +description = "Build and run this checkout's C++ demo without updating" +run = ["cmake -S ports/cpp -B ports/cpp/build-demo -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=OFF", "cmake --build ports/cpp/build-demo --target hqtui-demo-cpp --parallel 2", "ports/cpp/build-demo/hqtui-demo-cpp"] [tasks."demo:typescript"] description = "TypeScript reference demo (Bun)" diff --git a/ports/MISE-LANGUAGES.md b/ports/MISE-LANGUAGES.md new file mode 100644 index 0000000..51d57c1 --- /dev/null +++ b/ports/MISE-LANGUAGES.md @@ -0,0 +1,38 @@ +# Mise language expansion + +C++ is the current priority. Its native ten-screen demo is implemented over the +shared C rendering core; the C++ API remains experimental. Existing TypeScript, +Rust, Go, Python and Zig demos are retained in this monorepo. + +Mise's registry is a tool catalog, not a list of HQTUI implementations. Installing +a compiler does not create a native HQTUI library. An integration is not marked +ready until it has a runnable demo, real-data behavior, terminal cleanup tests, +the shared reference-frame gate and documented update-and-run commands. + +## Verified registry candidates + +The following runtime/tool IDs were checked with `mise registry` on 2026-09-07: + +| Language/runtime family | Mise IDs | HQTUI status | +|---|---|---| +| TypeScript / JavaScript | bun, node, deno | Existing reference implementation | +| Rust, Go, Python, Zig | rust, go, python, zig | Existing native implementations | +| C++ | cmake (host GCC/Clang compiler required) | Native demo; experimental API | +| C# / F# / .NET | dotnet | Pending implementation strategy | +| Java / Kotlin / Scala / Clojure | java, kotlin, scala, clojure | Pending | +| Ruby, PHP, Perl | ruby, php, perl | Pending | +| Lua / LuaJIT | lua, luajit | Pending | +| Swift, Crystal, Odin, V | swift, crystal, odin, v | Pending | +| Elixir / Erlang / Gleam | elixir, erlang, gleam | Pending | +| Haskell, Julia, Dart | ghc, julia, dart | Pending | + +This is a checked expansion inventory, not an exhaustive claim about third-party +mise plugins, future registry additions or completed HQTUI ports. + +## Decision needed after C++ + +The user has been asked whether the remaining languages should bind the shared +C core or each maintain a separate implementation. C-core bindings can preserve +the native renderer's performance, but must be named and documented as bindings, +not independent native ports. Do not quietly launch another language's demo or +replay reference screenshots under a different command name. diff --git a/ports/README.md b/ports/README.md index 7c35c0e..5869d14 100644 --- a/ports/README.md +++ b/ports/README.md @@ -1,7 +1,9 @@ # hqtui, in other languages C and C++ are now being implemented with one shared native C rendering core and -a thin C++17 ownership API. They are **not complete supported ports yet**. See +a C++17 ownership/widget API. The C++ ten-screen demo is now runnable and has +120 exact screen-body checks on Linux; its library API remains experimental. +The C-only demo is **not complete**. See [C](c/README.md), [C++](cpp/README.md), and the [acceptance checklist](c/STATUS.md). Native ports of [the TypeScript reference implementation](https://hqtui.com). diff --git a/ports/TARGETS.md b/ports/TARGETS.md index d6d451f..a702578 100644 --- a/ports/TARGETS.md +++ b/ports/TARGETS.md @@ -53,7 +53,7 @@ Community, 1-5, weighted toward *terminal* work rather than language size: | TypeScript | 3 | 5 | ink, blessed, hqtui | reference | | Zig | 5 | 2 | libvaxis, and not much else | **ported** | | C | 5 | 4 | ncurses, notcurses, termbox2 | **core in development** | -| C++ | 5 | 4 | btop, FTXUI, ncurses | **C++ API over shared C core in development** | +| C++ | 5 | 4 | btop, FTXUI, ncurses | **ten-screen demo available; library API experimental** | | C# / .NET | 4 | 4 | Spectre.Console, Terminal.Gui | candidate | | Swift | 4 | 3 | almost nothing serious | candidate | | Lua | 2 | 3 | via neovim, not standalone | candidate | diff --git a/ports/c/README.md b/ports/c/README.md index c96ace5..0201fb7 100644 --- a/ports/c/README.md +++ b/ports/c/README.md @@ -6,6 +6,9 @@ the full widget suite, terminal/input layer and ten-screen reference demo are still being implemented. It is deliberately absent from the website's list of supported languages. Do not substitute a small sample for `hqtui-demo`. +The [C++ demo](../cpp/README.md) now uses this C core with a C++ widget/terminal +layer. It does not make the C-only library or C-only demo complete. + Implemented: packed color math and palette conversion, bounded UTF-8 graphemes, parallel-array framebuffers, clipped surfaces, reference border/title rendering, nine theme palettes, constrained layouts, and a reusable changed-cell encoder. diff --git a/ports/c/STATUS.md b/ports/c/STATUS.md index 52ada67..c6e2802 100644 --- a/ports/c/STATUS.md +++ b/ports/c/STATUS.md @@ -15,19 +15,22 @@ string views and value types, not a second render loop or foreign runtime. - Buffer-local Unicode pools compare by text, not coincidentally equal IDs. - Malformed UTF-8 is replaced, not re-emitted as raw C1 control bytes. - Separate installed C and C++ consumer builds. +- C++ ten-screen demo and deferred widget layout layer: all 120 shared TS screen + bodies match exact glyph/color/attribute hashes on Linux. +- C++ real Linux collectors, bounded/cancellable utility subprocesses and an + asynchronous initial load; no JavaScript or another demo executable at runtime. +- C++ PTY launch, ten-tab switching, overlay, resize, q/SIGTERM and restoration. +- Real collector fixture checks for sensors/GPU, protocols/SSH, sessions, HTTP + routes (query strings redacted) and Docker container rows. ## Required before marking a complete supported port - Remaining Unicode operations and text/ANSI parsing conformance. -- Braille, block graphics, plot modes, and all 53 widget scenes. -- Deferred container builder and all ten shared whole-screen fixtures. -- Input parser including incremental escapes, mouse and bracketed paste. -- Native terminal lifecycle, resize, signals, and PTY cleanup tests. -- Full ten-screen native demo: same reference layouts, interactions and real - collectors. No shell-out to another language's demo, no rendered-fixture replay. -- Full demo screen-body parity at all 120 Rust-gated reference configurations, - followed by shell/overlay and live-data coverage tests. -- Cross-platform CI and performance regression artifacts. +- Complete reusable widget/control API and all 53 standalone widget scenes. +- Remaining input-parser conformance and complete shell/overlay interaction parity. +- Cross-platform collector coverage; C++ live data remains Linux-specific. +- C API equivalents for the new C++ builder/widgets/input layer and a C-only demo. +- End-to-end performance comparisons including collectors and terminal transport. The current microbenchmarks exclude system collection, terminal transport, layout/widgets and full application behavior. Do not market their timings as diff --git a/ports/conformance/DEMO.md b/ports/conformance/DEMO.md index e279bbe..e1ea761 100644 --- a/ports/conformance/DEMO.md +++ b/ports/conformance/DEMO.md @@ -17,6 +17,11 @@ attributes, allowing at most one Braille dot in the Components gauge: Go's cosin and the reference runtime differ by one ULP at 2π/3, which straddles a half-pixel. That tolerance cannot hide spacing, color, text or other-screen differences. +C++ now runs the same 120 exact screen-body hash checks on Linux through +`mise run test:cpp`. Its shell/terminal acceptance checks also exercise all ten +tabs, overlays, resize, q/SIGTERM and terminal cleanup. The reusable C++ library +API is still experimental, and the C-only demo is not implemented. + The reference frames check screen bodies, not every possible interaction or live system sample. Native suites additionally exercise keyboard/overlay priority, scroll offsets, mouse selection, real-source parsing, real/simulated CLI launches diff --git a/ports/cpp/CMakeLists.txt b/ports/cpp/CMakeLists.txt index 1697ee5..01ca630 100644 --- a/ports/cpp/CMakeLists.txt +++ b/ports/cpp/CMakeLists.txt @@ -14,12 +14,53 @@ if(HQTUI_LTO) set(CMAKE_INTERPROCEDURAL_OPTIMIZATION ON) endif() add_library(hqtui_cpp INTERFACE) +add_library(hqtui_cpp_widgets src/widgets.cpp) +target_compile_features(hqtui_cpp_widgets PUBLIC cxx_std_17) +if(MSVC) + target_compile_options(hqtui_cpp_widgets PUBLIC /utf-8) +endif() +target_include_directories(hqtui_cpp_widgets PUBLIC $ $) +target_link_libraries(hqtui_cpp_widgets PUBLIC hqtui::c) add_library(hqtui::cpp ALIAS hqtui_cpp) target_compile_features(hqtui_cpp INTERFACE cxx_std_17) target_include_directories(hqtui_cpp INTERFACE $ $) -target_link_libraries(hqtui_cpp INTERFACE hqtui::c) +target_link_libraries(hqtui_cpp INTERFACE hqtui::c hqtui_cpp_widgets) set_target_properties(hqtui_cpp PROPERTIES EXPORT_NAME cpp) +file(READ ${CMAKE_CURRENT_SOURCE_DIR}/../rust/demo/src/sample.json HQTUI_DEMO_SAMPLE) +configure_file(demo/sample.hpp.in generated/sample.hpp @ONLY) +if(UNIX) + find_package(Threads REQUIRED) + add_executable(hqtui-demo-cpp demo/main.cpp demo/collect.cpp demo/dashboard.cpp demo/telemetry.cpp demo/showcase.cpp) + target_include_directories(hqtui-demo-cpp PRIVATE demo ${CMAKE_CURRENT_BINARY_DIR}/generated) + target_compile_definitions(hqtui-demo-cpp PRIVATE HQTUI_DEMO_VERSION="${PROJECT_VERSION}") + target_link_libraries(hqtui-demo-cpp PRIVATE hqtui::cpp Threads::Threads) + install(TARGETS hqtui-demo-cpp RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}) +endif() if(BUILD_TESTING) + find_package(Python3 COMPONENTS Interpreter) + if(UNIX) + add_executable(hqtui_cpp_demo_test tests/demo.cpp demo/collect.cpp demo/dashboard.cpp demo/telemetry.cpp demo/showcase.cpp) + target_include_directories(hqtui_cpp_demo_test PRIVATE demo ${CMAKE_CURRENT_BINARY_DIR}/generated) + target_link_libraries(hqtui_cpp_demo_test PRIVATE hqtui::cpp Threads::Threads) + add_test(NAME cpp_demo_input_and_safety COMMAND hqtui_cpp_demo_test) + set_tests_properties(cpp_demo_input_and_safety PROPERTIES TIMEOUT 60) + endif() + add_executable(hqtui_cpp_dashboard_reference tests/dashboard_reference.cpp demo/dashboard.cpp demo/telemetry.cpp demo/showcase.cpp) + target_include_directories(hqtui_cpp_dashboard_reference PRIVATE demo ${CMAKE_CURRENT_BINARY_DIR}/generated) + target_link_libraries(hqtui_cpp_dashboard_reference PRIVATE hqtui::cpp) + if(Python3_Interpreter_FOUND) + if(CMAKE_SYSTEM_NAME STREQUAL "Linux") + add_test(NAME cpp_demo_parity COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tests/demo_parity.py $) + endif() + if(UNIX) + add_test(NAME cpp_demo_terminal COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tests/demo_terminal.py $) + set_tests_properties(cpp_demo_terminal PROPERTIES TIMEOUT 60) + endif() + if(CMAKE_SYSTEM_NAME STREQUAL "Linux") + add_test(NAME cpp_demo_real_data COMMAND ${CMAKE_COMMAND} -E env HQTUI_CPP_DEMO=$ HQTUI_NATIVE_LANGUAGES=cpp ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/../../apps/demo/scripts/check-native-data.py) + set_tests_properties(cpp_demo_real_data PROPERTIES TIMEOUT 120) + endif() + endif() add_executable(hqtui_cpp_test tests/core.cpp) target_link_libraries(hqtui_cpp_test PRIVATE hqtui::cpp) add_test(NAME cpp_core COMMAND hqtui_cpp_test) @@ -27,5 +68,6 @@ if(BUILD_TESTING) target_link_libraries(hqtui_cpp_bench PRIVATE hqtui::cpp) add_test(NAME cpp_warm_allocations COMMAND hqtui_cpp_bench 100) endif() -install(TARGETS hqtui_cpp EXPORT hqtuiTargets) +install(TARGETS hqtui_cpp hqtui_cpp_widgets EXPORT hqtuiTargets) install(FILES include/hqtui.hpp DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}) +install(DIRECTORY include/hqtui DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}) diff --git a/ports/cpp/README.md b/ports/cpp/README.md index acb60e5..992fea4 100644 --- a/ports/cpp/README.md +++ b/ports/cpp/README.md @@ -1,13 +1,48 @@ -# hqtui — C++ (in development) +# hqtui — C++ native demo (experimental library API) C++17 API over the native C11 core. No JS runtime, virtual dispatch or per-cell C++ objects. `Buffer` and `Encoder` own their native objects with move-only RAII; rendered output is borrowed as `std::string_view` until the next encode call. Both static and shared linking work. Public APIs remain experimental. -**This is the rendering foundation, not yet the complete library or ten-screen -demo.** [Acceptance checklist](../c/STATUS.md). C++ is not advertised as a finished -port on hqtui.com. +The ten-screen native demo now renders the TypeScript reference layouts, using +the C renderer plus C++ widgets and Linux collectors. All 120 screen-body reference +frames match exactly on Linux. This is not a claim that every library API, OS +collector or interaction is complete. [Acceptance checklist](../c/STATUS.md). + +## Update and launch + +```sh +curl -fsSL https://hqtui.com/demo.sh | sh -s -- --mise cpp +curl -fsSL https://hqtui.com/demo.sh | sh -s -- --system cpp +``` + +Both fetch latest main and build optimized code in a private cache. C++17 build +tools (GCC/Clang) must be installed. `--mise` supplies CMake 4.4.3; `--system` +uses installed CMake 3.20+. Subsequent invocations reuse the build until the +revision changes. `c++` is an alias for `cpp`. + +The terminal supports Linux/macOS; live collectors require Linux. macOS defaults +to explicitly labelled sample data. Use `--sim` +for sample data, `--snapshot` for headless text, `--screen traffic` for a specific +tab. Use 1–9/0 or Tab to switch, F2 for themes, F3 for process filtering, F6 for +sorting, Ctrl+K for the palette and q to quit. Mouse selection/scrolling is native. +The first frame does not wait for collectors. Utility subprocesses have bounded +output/deadlines and are cancelled when quitting; no service is modified. + +From an updated checkout: `mise run demo:cpp` updates before launch; +`mise run demo-local:cpp` runs local edits. Or build directly: + +```sh +cmake -S ports/cpp -B ports/cpp/build-demo -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=OFF +cmake --build ports/cpp/build-demo --target hqtui-demo-cpp --parallel 2 +ports/cpp/build-demo/hqtui-demo-cpp +``` + +The library's new `` exposes deferred rows/columns/panels, +Braille graphs, meters, gauges, tables, key/value lists and logs. Its builder uses +frame-local callbacks and containers; the zero-allocation core benchmark below +does **not** imply the complete demo performs zero allocations. ## Build and check diff --git a/ports/cpp/demo/collect.cpp b/ports/cpp/demo/collect.cpp new file mode 100644 index 0000000..486d6e8 --- /dev/null +++ b/ports/cpp/demo/collect.cpp @@ -0,0 +1,904 @@ +#include "collect.hpp" +#include +#include +#include +#include +#include +#if defined(__linux__) +#include +#include +#include +#endif +#if defined(__unix__) || defined(__APPLE__) +#include +#include +#include +#include +#include +#include +#include +#include +extern char **environ; +#endif +namespace demo { +std::string read_file(const std::string &path, std::size_t limit) { + std::ifstream in(path, std::ios::binary); + if (!in) + return {}; + std::string out; + char buffer[4096]; + while (in && out.size() < limit) { + in.read(buffer, + std::streamsize(std::min(sizeof buffer, limit - out.size()))); + out.append(buffer, std::size_t(in.gcount())); + } + return out; +} +static double now() { + return std::chrono::duration( + std::chrono::steady_clock::now().time_since_epoch()) + .count(); +} +std::string command(const std::vector &args, + const std::atomic *cancel, int timeout_ms) { +#if defined(__unix__) || defined(__APPLE__) + if (args.empty() || (cancel && cancel->load())) + return {}; + int fd[2]; + if (pipe(fd)) + return {}; + fcntl(fd[0], F_SETFD, FD_CLOEXEC); + fcntl(fd[1], F_SETFD, FD_CLOEXEC); + posix_spawn_file_actions_t actions; + posix_spawn_file_actions_init(&actions); + posix_spawn_file_actions_addopen(&actions, 0, "/dev/null", O_RDONLY, 0); + posix_spawn_file_actions_addopen(&actions, 2, "/dev/null", O_WRONLY, 0); + posix_spawn_file_actions_adddup2(&actions, fd[1], 1); + posix_spawn_file_actions_addclose(&actions, fd[0]); + posix_spawn_file_actions_addclose(&actions, fd[1]); + posix_spawnattr_t attr; + posix_spawnattr_init(&attr); + posix_spawnattr_setflags(&attr, POSIX_SPAWN_SETPGROUP); + posix_spawnattr_setpgroup(&attr, 0); + std::vector argv; + for (auto &s : args) + argv.push_back(const_cast(s.c_str())); + argv.push_back(nullptr); + pid_t pid = 0; + int error = + posix_spawnp(&pid, argv[0], &actions, &attr, argv.data(), environ); + posix_spawn_file_actions_destroy(&actions); + posix_spawnattr_destroy(&attr); + close(fd[1]); + if (error) { + close(fd[0]); + return {}; + } + fcntl(fd[0], F_SETFL, fcntl(fd[0], F_GETFL) | O_NONBLOCK); + std::string out; + double deadline = now() + timeout_ms / 1000.; + int status = 0; + bool done = false; + while (now() < deadline && out.size() < 262144 && + !(cancel && cancel->load())) { + char buffer[8192]; + ssize_t n = + read(fd[0], buffer, std::min(sizeof buffer, 262144 - out.size())); + if (n > 0) + out.append(buffer, std::size_t(n)); + else if (n == 0) { + done = true; + break; + } else if (errno != EAGAIN && errno != EINTR) + break; + struct pollfd pfd{fd[0], POLLIN, 0}; + poll(&pfd, 1, 20); + } + // A child may close stdout and continue running. Reaping is bounded too. + pid_t reaped; + do { + reaped = waitpid(pid, &status, WNOHANG); + } while (reaped < 0 && errno == EINTR); + if (!done || reaped == 0) { + kill(-pid, SIGKILL); + while (waitpid(pid, &status, 0) < 0 && errno == EINTR) { + } + } + close(fd[0]); + return out; +#else + (void)args; + (void)cancel; + (void)timeout_ms; + return {}; +#endif +} +static std::vector lines(const std::string &s) { + std::vector out; + std::istringstream in(s); + std::string line; + while (std::getline(in, line)) + out.push_back(line); + return out; +} +static std::vector words(const std::string &s) { + std::vector out; + std::istringstream in(s); + std::string word; + while (in >> word) + out.push_back(word); + return out; +} +static std::string trim(std::string s) { + auto first = s.find_first_not_of(" \t\r\n"), + last = s.find_last_not_of(" \t\r\n"); + return first == s.npos ? "" : s.substr(first, last - first + 1); +} +static double numeric(const std::string &s) { + char *end = nullptr; + double v = std::strtod(s.c_str(), &end); + return end != s.c_str() && std::isfinite(v) ? v : 0; +} +static Json clear(const Json &v) { + if (std::holds_alternative(v.value)) { + Json::Object out; + for (auto &item : std::get(v.value)) + out[item.first] = clear(item.second); + return out; + } + if (std::holds_alternative(v.value)) + return Json::Array{}; + if (std::holds_alternative(v.value)) + return 0; + if (std::holds_alternative(v.value)) + return ""; + if (std::holds_alternative(v.value)) + return false; + return {}; +} +static void history(Json &j, double value) { + auto &a = j.array(); + if (a.size() >= 240) + a.erase(a.begin()); + a.emplace_back(value); +} +Collector::Collector(const Json &shape) : data(clear(shape)) { + data["telemetry"]["http"] = Json(); + data["telemetry"]["power"] = Json(); +} +void Collector::refresh(const std::atomic *cancel) { +#ifndef __linux__ + (void)cancel; + throw std::runtime_error( + "Live collectors require Linux; use --sim on this platform."); +#else + double time = now(), elapsed = last ? std::max(.001, time - last) : 0; + auto delta = [&](std::string key, double value) { + auto it = counters.find(key); + double out = it != counters.end() && elapsed + ? std::max(0., value - it->second) / elapsed + : 0; + counters[key] = value; + return out; + }; + auto &cpu = data["cpu"], &memory = data["memory"], &system = data["system"], + &network = data["network"], &telemetry = data["telemetry"], + &kernel = telemetry["kernel"]; + data["time"] = time; + struct utsname os{}; + uname(&os); + system["kernel"] = os.release; + system["hostname"] = os.nodename; + system["os"] = os.sysname; + for (auto &line : lines(read_file("/etc/os-release"))) + if (line.rfind("PRETTY_NAME=", 0) == 0) { + auto value = line.substr(12); + if (value.size() >= 2 && value.front() == '"' && value.back() == '"') + value = value.substr(1, value.size() - 2); + system["os"] = value; + } + system["uptime"] = numeric(read_file("/proc/uptime")); + const char *shell = std::getenv("SHELL"); + system["shell"] = shell ? shell : ""; + Json::Array cores; + for (auto &line : lines(read_file("/proc/stat"))) { + auto v = words(line); + if (v.size() < 2) + continue; + if (v[0].rfind("cpu", 0) == 0 && v.size() >= 5) { + double total = 0; + for (std::size_t i = 1; i < std::min(v.size(), std::size_t(9)); i++) + total += numeric(v[i]); + double idle = numeric(v[4]) + (v.size() > 5 ? numeric(v[5]) : 0), + dt = delta(v[0] + ".total", total), + di = delta(v[0] + ".idle", idle), + used = dt ? ratio((dt - di) / dt) : 0; + if (v[0] == "cpu") { + cpu["total"] = used; + history(cpu["history"], used * 100); + } else + cores.emplace_back(used); + } else if (v[0] == "ctxt") { + system["contextSwitches"] = numeric(v[1]); + kernel["contextSwitchRate"] = delta("ctxt", numeric(v[1])); + } else if (v[0] == "intr") + kernel["interruptRate"] = delta("intr", numeric(v[1])); + else if (v[0] == "processes") + kernel["forkRate"] = delta("forks", numeric(v[1])); + else if (v[0] == "procs_running") + kernel["procsRunning"] = numeric(v[1]); + else if (v[0] == "procs_blocked") + kernel["procsBlocked"] = numeric(v[1]); + } + cpu["cores"] = cores; + Json::Array sensors, temps; + std::size_t core = 0; + double mhz = 0; + for (auto &line : lines(read_file("/proc/cpuinfo"))) { + auto colon = line.find(':'); + if (colon == line.npos) + continue; + auto key = trim(line.substr(0, colon)), + value = trim(line.substr(colon + 1)); + if (key == "model name") + cpu["model"] = value; + if (key == "cpu MHz") { + double n = numeric(value); + mhz += n; + sensors.push_back( + Json::Object{{"label", "cpu" + std::to_string(core++) + " Clock"}, + {"value", fixed(n) + " MHz"}}); + } + } + cpu["frequencyGhz"] = core ? mhz / core / 1000 : 0; + Json::Array load; + auto averages = words(read_file("/proc/loadavg")); + for (int i = 0; i < 3 && i < int(averages.size()); i++) + load.emplace_back(numeric(averages[i])); + cpu["load"] = load; + std::map mem; + for (auto &line : lines(read_file("/proc/meminfo"))) { + auto v = words(line); + if (v.size() > 1) + mem[v[0]] = numeric(v[1]) * 1024; + } + memory["total"] = mem["MemTotal:"]; + memory["available"] = mem["MemAvailable:"]; + memory["used"] = std::max(0., mem["MemTotal:"] - mem["MemAvailable:"]); + memory["free"] = mem["MemFree:"]; + memory["cached"] = mem["Cached:"]; + memory["buffers"] = mem["Buffers:"]; + memory["swapTotal"] = mem["SwapTotal:"]; + memory["swapUsed"] = std::max(0., mem["SwapTotal:"] - mem["SwapFree:"]); + history(memory["history"], + memory["used"].n() / std::max(1., memory["total"].n()) * 100); + std::error_code ec; + for (auto device : + std::filesystem::directory_iterator("/sys/class/hwmon", ec)) { + auto chip = trim(read_file(device.path().string() + "/name", 256)); + for (auto file : std::filesystem::directory_iterator(device.path(), ec)) { + auto name = file.path().filename().string(); + if (name.size() < 7 || name.substr(name.size() - 6) != "_input") + continue; + std::string prefix = name.substr(0, name.size() - 6), + label = trim(read_file( + device.path().string() + "/" + prefix + "_label", 256)); + if (label.empty()) + label = chip + " " + prefix; + auto raw = trim(read_file(file.path().string(), 80)); + if (raw.empty()) + continue; + double value = numeric(raw); + if (prefix.rfind("temp", 0) == 0 && value >= -40000 && value <= 150000) { + double maximum = + numeric(read_file(device.path().string() + "/" + prefix + "_crit", + 80)) / + 1000; + temps.push_back(Json::Object{{"label", label}, + {"value", value / 1000}, + {"max", maximum > 0 ? maximum : 100}}); + } else if (prefix.rfind("fan", 0) == 0 && value > 0) + sensors.push_back( + Json::Object{{"label", label}, {"value", fixed(value) + " RPM"}}); + else if (prefix.rfind("in", 0) == 0) + sensors.push_back(Json::Object{ + {"label", label}, {"value", fixed(value / 1000, 2) + " V"}}); + else if (prefix.rfind("curr", 0) == 0) + sensors.push_back(Json::Object{ + {"label", label}, {"value", fixed(value / 1000, 2) + " A"}}); + else if (prefix.rfind("power", 0) == 0) + sensors.push_back(Json::Object{ + {"label", label}, {"value", fixed(value / 1e6, 2) + " W"}}); + } + } + data["sensors"] = sensors; + data["temperatures"] = temps; + Json::Array interfaces; + std::map addresses; + ifaddrs *raw_addresses = nullptr; + if (getifaddrs(&raw_addresses) == 0) { + std::unique_ptr owned(raw_addresses, + freeifaddrs); + for (auto *entry = owned.get(); entry; entry = entry->ifa_next) { + if (!entry->ifa_addr || !entry->ifa_name) + continue; + int family = entry->ifa_addr->sa_family; + if (family != AF_INET && family != AF_INET6) + continue; + const void *address = + family == AF_INET + ? static_cast( + &reinterpret_cast(entry->ifa_addr)->sin_addr) + : static_cast( + &reinterpret_cast(entry->ifa_addr) + ->sin6_addr); + char formatted[INET6_ADDRSTRLEN]; + if (inet_ntop(family, address, formatted, sizeof formatted) && + (family == AF_INET || !addresses.count(entry->ifa_name))) + addresses[entry->ifa_name] = formatted; + } + } + double rx = 0, tx = 0, rxrate = 0, txrate = 0; + for (auto &line : lines(read_file("/proc/net/dev"))) { + auto colon = line.find(':'); + if (colon == line.npos) + continue; + auto name = trim(line.substr(0, colon)); + auto v = words(line.substr(colon + 1)); + if (v.size() < 16) + continue; + double a = numeric(v[0]), b = numeric(v[8]), ar = delta("rx." + name, a), + br = delta("tx." + name, b); + Json iface = Json::Object{ + {"name", name}, + {"state", + trim(read_file("/sys/class/net/" + name + "/operstate", 128))}, + {"ip", addresses[name]}, + {"mac", trim(read_file("/sys/class/net/" + name + "/address", 128))}, + {"mtu", numeric(read_file("/sys/class/net/" + name + "/mtu", 128))}, + {"rxTotal", a}, + {"txTotal", b}, + {"rxRate", ar}, + {"txRate", br}, + {"errors", numeric(v[2]) + numeric(v[10])}, + {"drops", numeric(v[3]) + numeric(v[11])}}; + for (auto &old : telemetry["interfaces"].array()) + if (old["name"].s() == name) { + iface["rxHistory"] = old["rxHistory"]; + iface["txHistory"] = old["txHistory"]; + } + history(iface["rxHistory"], ar); + history(iface["txHistory"], br); + interfaces.push_back(iface); + if (name != "lo") { + rx += a; + tx += b; + rxrate += ar; + txrate += br; + } + } + telemetry["interfaces"] = interfaces; + network["downRate"] = rxrate; + network["upRate"] = txrate; + network["downTotal"] = rx; + network["upTotal"] = tx; + network["downPeak"] = std::max(network["downPeak"].n(), rxrate); + network["upPeak"] = std::max(network["upPeak"].n(), txrate); + history(network["downHistory"], rxrate); + history(network["upHistory"], txrate); + Json::Array processes; + int threads = 0, running = 0, sleeping = 0, stopped = 0, zombie = 0; + for (auto &line : lines(command( + {"ps", "-eo", "pid=,comm=,pcpu=,pmem=,rss=,nlwp=,stat=,user=,args="}, + cancel))) { + auto v = words(line); + if (v.size() < 8) + continue; + std::string cmd; + for (std::size_t i = 8; i < v.size(); i++) { + if (!cmd.empty()) + cmd += ' '; + cmd += v[i]; + } + auto state = v[6].substr(0, 1); + running += state == "R"; + stopped += state == "T"; + zombie += state == "Z"; + sleeping += state != "R" && state != "T" && state != "Z"; + threads += int(numeric(v[5])); + processes.push_back(Json::Object{{"pid", numeric(v[0])}, + {"name", v[1]}, + {"cpu", numeric(v[2])}, + {"mem", numeric(v[3])}, + {"rss", numeric(v[4]) * 1024}, + {"threads", numeric(v[5])}, + {"state", state}, + {"user", v[7]}, + {"command", cmd}}); + } + data["processes"] = processes; + system["processCount"] = int(processes.size()); + system["threadCount"] = threads; + telemetry["states"] = Json::Object{{"total", int(processes.size())}, + {"running", running}, + {"sleeping", sleeping}, + {"stopped", stopped}, + {"zombie", zombie}}; + Json::Array disks, filesystems; + std::map> io; + for (auto &line : lines(read_file("/proc/diskstats"))) { + auto v = words(line); + if (v.size() < 14) + continue; + io[v[2]] = {delta("diskr." + v[2], numeric(v[5]) * 512), + delta("diskw." + v[2], numeric(v[9]) * 512)}; + } + std::map devices; + for (auto &line : lines(read_file("/proc/mounts"))) { + auto v = words(line); + if (v.size() < 3 || devices[v[0]] || + !(v[0].rfind("/dev/", 0) == 0 || v[1] == "/")) + continue; + struct statvfs stat{}; + if (statvfs(v[1].c_str(), &stat) || !stat.f_blocks) + continue; + devices[v[0]] = true; + double total = double(stat.f_blocks) * stat.f_frsize, + used = double(stat.f_blocks - stat.f_bfree) * stat.f_frsize; + std::string name = v[0].substr(v[0].find_last_of('/') + 1); + Json disk = Json::Object{{"device", name}, + {"mount", v[1]}, + {"type", v[2]}, + {"total", total}, + {"used", used}, + {"readRate", io[name].first}, + {"writeRate", io[name].second}}; + for (auto &old : data["disks"].array()) + if (old["device"].s() == name) { + disk["readHistory"] = old["readHistory"]; + disk["writeHistory"] = old["writeHistory"]; + } + history(disk["readHistory"], io[name].first); + history(disk["writeHistory"], io[name].second); + disks.push_back(disk); + filesystems.push_back( + Json::Object{{"mount", v[1]}, + {"device", v[0]}, + {"type", v[2]}, + {"size", total}, + {"used", used}, + {"inodesUsed", double(stat.f_files - stat.f_ffree)}, + {"inodesTotal", double(stat.f_files)}}); + } + data["disks"] = disks; + telemetry["filesystems"] = filesystems; + auto file_nr = words(read_file("/proc/sys/fs/file-nr")); + kernel["openFiles"] = file_nr.empty() ? 0 : numeric(file_nr[0]); + kernel["entropy"] = + numeric(read_file("/proc/sys/kernel/random/entropy_avail")); + for (auto &line : lines(read_file("/proc/vmstat"))) { + auto v = words(line); + if (v.size() == 2 && v[0] == "pgpgin") + kernel["pageIn"] = numeric(v[1]); + if (v.size() == 2 && v[0] == "pgpgout") + kernel["pageOut"] = numeric(v[1]); + } + Json::Array sessions; + for (auto &line : lines(command({"who"}, cancel))) { + auto v = words(line); + if (v.size() < 4) + continue; + auto from = v.size() > 4 ? v.back() : ""; + if (from.size() > 1 && from.front() == '(' && from.back() == ')') + from = from.substr(1, from.size() - 2); + sessions.push_back(Json::Object{{"user", v[0]}, + {"tty", v[1]}, + {"from", from}, + {"loginAt", v[2] + " " + v[3]}, + {"idle", ""}}); + } + telemetry["sessions"] = sessions; + history(telemetry["sessionHistory"], double(sessions.size())); + auto login_rows = [&](std::vector args) { + Json::Array rows; + for (auto &line : lines(command(args, cancel))) { + auto v = words(line); + if (v.size() < 5 || v[0] == "reboot" || v[0] == "wtmp" || v[0] == "btmp") + continue; + std::string when; + for (std::size_t i = 3; i < std::min(v.size(), std::size_t(8)); i++) { + if (!when.empty()) + when += ' '; + when += v[i]; + } + rows.push_back(Json::Object{ + {"user", v[0]}, + {"tty", v[1]}, + {"from", v[2]}, + {"when", when}, + {"status", line.find("still") != line.npos ? "still" : "closed"}}); + } + return rows; + }; + telemetry["logins"] = login_rows({"last", "-n", "30", "-w"}); + telemetry["failedLogins"] = login_rows({"lastb", "-n", "20", "-w"}); + for (auto &row : telemetry["failedLogins"].array()) + row["status"] = "failed"; + Json::Array services; + for (auto &line : + lines(command({"systemctl", "list-units", "--type=service", "--all", + "--no-legend", "--no-pager", "--plain"}, + cancel))) { + auto v = words(line); + if (v.size() < 4 || v[0].find(".service") == v[0].npos) + continue; + std::string desc; + for (std::size_t i = 4; i < v.size(); i++) { + if (!desc.empty()) + desc += ' '; + desc += v[i]; + } + services.push_back(Json::Object{{"name", v[0]}, + {"active", v[2]}, + {"sub", v[3]}, + {"description", desc}}); + } + telemetry["services"] = services; + Json::Array connections, listeners, protocols, remotes; + std::map protocol_counts, remote_counts; + int inbound = 0, outbound = 0; + for (auto &line : lines(command({"ss", "-H", "-tunap"}, cancel))) { + auto v = words(line); + if (v.size() < 6) + continue; + std::string proto = v[0], state = v[1], local = v[4], remote = v[5], + process = v.size() > 6 ? v[6] : ""; + auto port = [](const std::string &a) { + auto colon = a.find_last_of(':'); + return colon == a.npos ? 0 : int(numeric(a.substr(colon + 1))); + }; + int lp = port(local), rp = port(remote); + if (state == "LISTEN" || (state == "UNCONN" && rp == 0)) { + listeners.push_back(Json::Object{{"proto", proto}, + {"port", lp}, + {"address", local}, + {"process", process}}); + continue; + } + connections.push_back(Json::Object{{"proto", proto}, + {"local", local}, + {"remote", remote}, + {"state", state}, + {"process", process}}); + int service = lp < rp ? lp : rp; + std::string label = service == 22 ? "SSH" + : service == 80 || service == 8080 ? "HTTP" + : service == 443 || service == 8443 ? "HTTPS" + : service == 53 ? "DNS" + : service == 5432 ? "Postgres" + : service == 6379 ? "Redis" + : proto == "udp" ? "UDP" + : "TCP"; + protocol_counts[label]++; + if (lp < rp) + inbound++; + else + outbound++; + auto colon = remote.find_last_of(':'); + remote_counts[remote.substr(0, colon)]++; + } + for (auto &v : protocol_counts) + protocols.push_back( + Json::Object{{"protocol", v.first}, {"total", v.second}}); + for (auto &v : remote_counts) + remotes.push_back(Json::Object{ + {"host", v.first}, {"connections", v.second}, {"protocols", ""}}); + std::sort(remotes.begin(), remotes.end(), [](const Json &a, const Json &b) { + return a["connections"].n() > b["connections"].n(); + }); + telemetry["connections"] = connections; + telemetry["listeners"] = listeners; + telemetry["protocols"] = protocols; + telemetry["remotes"] = remotes; + telemetry["inboundConnections"] = inbound; + telemetry["outboundConnections"] = outbound; + history(telemetry["connectionHistory"], double(connections.size())); + auto snmp = lines(read_file("/proc/net/snmp")); + std::map netstats; + for (std::size_t i = 0; i + 1 < snmp.size(); i += 2) { + auto keys = words(snmp[i]), values = words(snmp[i + 1]); + for (std::size_t k = 1; k < std::min(keys.size(), values.size()); k++) + netstats[keys[0] + keys[k]] = numeric(values[k]); + } + auto &net = telemetry["net"], &rates = net["rates"]; + net["tcpEstablished"] = netstats["Tcp:CurrEstab"]; + net["tcpOutRsts"] = netstats["Tcp:OutRsts"]; + net["icmpInMsgs"] = netstats["Icmp:InMsgs"]; + net["icmpOutMsgs"] = netstats["Icmp:OutMsgs"]; + const char *in[] = {"Tcp:InSegs", "Tcp:OutSegs", "Tcp:PassiveOpens", + "Tcp:ActiveOpens", "Udp:InDatagrams", "Udp:OutDatagrams", + "Tcp:RetransSegs"}, + *out[] = {"inSegs", "outSegs", "passiveOpens", "activeOpens", + "udpIn", "udpOut", "retransSegs"}; + for (int i = 0; i < 7; i++) + rates[out[i]] = delta(in[i], netstats[in[i]]); + net["retransRatio"] = rates["outSegs"].n() + ? rates["retransSegs"].n() / rates["outSegs"].n() + : 0; + history(telemetry["netInHistory"], rates["inSegs"].n()); + history(telemetry["netOutHistory"], rates["outSegs"].n()); + history(telemetry["retransHistory"], net["retransRatio"].n() * 100); + Json::Array logs, ssh; + for (auto &line : lines( + command({"journalctl", "-n", "80", "--no-pager", "-o", "short-iso"}, + cancel))) { + auto v = words(line); + if (v.size() < 4 || line.rfind("--", 0) == 0) + continue; + std::string time = v[0].size() >= 19 ? v[0].substr(11, 8) : v[0], message; + for (std::size_t i = 3; i < v.size(); i++) { + if (!message.empty()) + message += ' '; + message += v[i]; + } + logs.push_back(Json::Object{ + {"time", time}, + {"level", message.find("error") != message.npos ? "ERROR" : "INFO"}, + {"message", message}, + {"meta", v[2]}}); + if (v[2].find("sshd") != v[2].npos) { + auto words_ = words(message); + auto from = std::find(words_.begin(), words_.end(), "from"), + user = std::find(words_.begin(), words_.end(), "for"); + std::string action = message.find("Accepted") != message.npos ? "accepted" + : message.find("Failed") != message.npos + ? "failed" + : "disconnect"; + ssh.push_back(Json::Object{ + {"time", time}, + {"action", action}, + {"user", + user != words_.end() && user + 1 != words_.end() ? *(user + 1) : ""}, + {"from", + from != words_.end() && from + 1 != words_.end() ? *(from + 1) : ""}, + {"method", words_.size() > 1 ? words_[1] : ""}}); + } + } + data["logs"] = logs; + telemetry["ssh"] = ssh; + + // SSH is queried separately because it can be absent from the short general + // journal tail. Parse both ISO and traditional syslog timestamps. + ssh.clear(); + for (auto &line : + lines(command({"journalctl", "-u", "ssh", "-u", "sshd", "-n", "100", + "--no-pager", "-o", "short-iso"}, + cancel))) { + auto marker = line.find("sshd["); + if (marker == line.npos) + continue; + auto colon = line.find(": ", marker); + if (colon == line.npos) + continue; + auto prefix = words(line.substr(0, marker)), + tokens = words(line.substr(colon + 2)); + if (tokens.empty()) + continue; + std::string time = prefix.empty() ? "" + : prefix[0].size() >= 19 ? prefix[0].substr(11, 8) + : prefix.size() > 2 ? prefix[2] + : ""; + std::string action = + tokens[0] == "Accepted" ? "accepted" + : tokens[0] == "Failed" ? "failed" + : tokens[0] == "Disconnected" || tokens[0] == "Connection" + ? "disconnect" + : ""; + if (action.empty()) + continue; + auto from = std::find(tokens.begin(), tokens.end(), "from"), + user = std::find(tokens.begin(), tokens.end(), "for"); + std::string username, address; + if (user != tokens.end() && user + 1 != tokens.end()) { + auto i = user + 1; + if (*i == "invalid" && tokens.end() - i > 2) + i += 2; + username = *i; + } + if (from != tokens.end() && from + 1 != tokens.end()) + address = *(from + 1); + if (action == "disconnect") { + auto u = std::find(tokens.begin(), tokens.end(), "user"); + if (u != tokens.end() && tokens.end() - u > 2) { + username = *(u + 1); + address = *(u + 2); + } + } + ssh.push_back(Json::Object{{"time", time}, + {"action", action}, + {"user", username}, + {"from", address}, + {"method", tokens.size() > 1 ? tokens[1] : ""}}); + } + telemetry["ssh"] = ssh; + + Json::Array gpus; + for (auto &line : + lines(command({"nvidia-smi", + "--query-gpu=name,utilization.gpu,memory.used,memory." + "total,temperature.gpu,power.draw", + "--format=csv,noheader,nounits"}, + cancel))) { + std::vector fields; + std::istringstream in(line); + std::string field; + while (std::getline(in, field, ',')) + fields.push_back(trim(field)); + if (fields.size() < 6) + continue; + auto numeric_or_null = [](const std::string &v) -> Json { + char *end = nullptr; + double n = std::strtod(v.c_str(), &end); + return end != v.c_str() && *end == 0 && std::isfinite(n) ? Json(n) + : Json(); + }; + auto util = numeric_or_null(fields[1]), used = numeric_or_null(fields[2]), + total = numeric_or_null(fields[3]), temp = numeric_or_null(fields[4]); + gpus.push_back(Json::Object{ + {"name", fields[0]}, + {"utilization", util.null() ? Json() : Json(util.n() / 100)}, + {"memoryUsed", used.null() ? Json() : Json(used.n() * 1048576)}, + {"memoryTotal", total.null() ? Json() : Json(total.n() * 1048576)}, + {"temperature", temp}}); + sensors.push_back( + Json::Object{{"label", fields[0]}, + {"value", util.null() ? "—" : percent(util.n() / 100)}}); + if (!temp.null()) + temps.push_back( + Json::Object{{"label", fields[0]}, {"value", temp}, {"max", 100}}); + } + telemetry["gpus"] = gpus; + data["sensors"] = sensors; + data["temperatures"] = temps; + telemetry["power"] = Json(); + bool ac = false; + for (auto &device : + std::filesystem::directory_iterator("/sys/class/power_supply", ec)) { + auto path = device.path().string(), + type = trim(read_file(path + "/type", 80)); + if (type == "Mains" || type == "USB" || type == "USB_C") + ac = ac || numeric(read_file(path + "/online", 80)) == 1; + } + for (auto &device : + std::filesystem::directory_iterator("/sys/class/power_supply", ec)) { + auto path = device.path().string(); + if (trim(read_file(path + "/type", 80)) != "Battery") + continue; + auto capacity = trim(read_file(path + "/capacity", 80)), + draw = trim(read_file(path + "/power_now", 80)); + Json watts = draw.empty() ? Json() : Json(numeric(draw) / 1e6); + telemetry["power"] = Json::Object{ + {"battery", capacity.empty() ? Json() : Json(numeric(capacity))}, + {"acConnected", ac}, + {"timeRemaining", "—"}, + {"powerDraw", watts}}; + break; + } + Json::Array containers; + for (auto &line : + lines(command({"docker", "--host", "unix:///var/run/docker.sock", "ps", + "--format", "{{json .}}"}, + cancel))) { + try { + auto container = Json::parse(line); + containers.push_back(Json::Object{{"name", container["Names"]}, + {"image", container["Image"]}, + {"status", container["Status"]}}); + } catch (const std::exception &) { + } + } + telemetry["containers"] = containers; + + // Access-log samples are bounded. The rate is explicitly described as an + // estimate in the Traffic screen; a rotation/reset never produces a spike. + Json http; + for (auto path : {"/var/log/nginx/access.log", "/var/log/apache2/access.log", + "/var/log/httpd/access_log", "/var/log/caddy/access.log"}) { + if (cancel && cancel->load()) + break; + auto content = command({"tail", "-n", "300", "--", path}, cancel); + if (content.empty()) + continue; + Json::Array recent; + std::map classes, paths; + int upgrades = 0, parsed = 0; + for (auto &line : lines(content)) { + if (line.size() > 16384) + continue; + std::string client, method, target, time; + double status = 0, bytes = 0; + if (!line.empty() && line[0] == '{') { + try { + auto record = Json::parse(line); + client = record.path("request.remote_ip").s(""); + method = record.path("request.method").s(""); + target = record.path("request.uri").s(""); + status = record["status"].n(); + bytes = record["size"].n(); + time = record["ts"].s(""); + } catch (const std::exception &) { + continue; + } + } else { + auto quote = line.find('"'), + end = quote == line.npos ? line.npos : line.find('"', quote + 1); + if (end == line.npos) + continue; + auto request = words(line.substr(quote + 1, end - quote - 1)), + result = words(line.substr(end + 1)); + if (request.size() < 2 || result.size() < 2) + continue; + auto begin = line.find('['), close = line.find(']', begin); + auto fields = words(line.substr(0, quote)); + if (fields.empty()) + continue; + client = fields[0]; + method = request[0]; + target = request[1]; + status = numeric(result[0]); + bytes = numeric(result[1]); + if (begin != line.npos && close != line.npos) { + auto stamp = line.substr(begin + 1, close - begin - 1); + auto colon = stamp.find(':'); + time = colon != stamp.npos ? stamp.substr(colon + 1, 8) : stamp; + } + } + if (method.empty() || target.empty() || status < 100 || status > 599) + continue; + // Routes, not request secrets: never display query strings or fragments. + target = target.substr(0, target.find_first_of("?#")); + parsed++; + upgrades += status == 101; + classes[std::to_string(int(status) / 100) + "xx"]++; + paths[target]++; + recent.push_back(Json::Object{{"time", time}, + {"method", method}, + {"path", target}, + {"status", int(status)}, + {"client", client}, + {"bytes", bytes}}); + } + if (!parsed) + continue; + Json::Array status_classes, top_paths; + for (auto &item : classes) + status_classes.push_back( + Json::Object{{"class", item.first}, {"count", item.second}}); + for (auto &item : paths) + top_paths.push_back( + Json::Object{{"path", item.first}, {"count", item.second}}); + std::stable_sort(top_paths.begin(), top_paths.end(), + [](const Json &a, const Json &b) { + return a["count"].n() > b["count"].n(); + }); + std::reverse(recent.begin(), recent.end()); + if (recent.size() > 50) + recent.resize(50); + auto size = std::filesystem::file_size(path, ec); + double rps = ec ? 0 + : delta(std::string("http:") + path, double(size)) / + std::max(1., double(content.size()) / parsed); + http = + Json::Object{{"source", path}, {"requestsPerSecond", rps}, + {"upgrades", upgrades}, {"statusClasses", status_classes}, + {"topPaths", top_paths}, {"recent", recent}}; + if (telemetry["http"]["source"].s("") == path) + http["history"] = telemetry["http"]["history"]; + history(http["history"], rps); + break; + } + telemetry["http"] = http; + last = time; +#endif +} +} // namespace demo diff --git a/ports/cpp/demo/collect.hpp b/ports/cpp/demo/collect.hpp new file mode 100644 index 0000000..4382782 --- /dev/null +++ b/ports/cpp/demo/collect.hpp @@ -0,0 +1,16 @@ +#pragma once +#include "model.hpp" +#include +namespace demo { +std::string read_file(const std::string &, std::size_t limit = 1048576); +std::string command(const std::vector &, + const std::atomic *cancel = nullptr, + int timeout_ms = 800); +struct Collector { + Json data; + double last = 0; + std::map counters; + explicit Collector(const Json &shape); + void refresh(const std::atomic *cancel = nullptr); +}; +} // namespace demo diff --git a/ports/cpp/demo/dashboard.cpp b/ports/cpp/demo/dashboard.cpp new file mode 100644 index 0000000..92159d7 --- /dev/null +++ b/ports/cpp/demo/dashboard.cpp @@ -0,0 +1,358 @@ +#include "model.hpp" +namespace demo { +static void cpu_panel(UI &ui, State &s, int columns) { + const auto &c = s.data["cpu"]; + ui.panel( + "CPU Overview", + [&, columns](UI &p) { + auto t = p.t(); + p.label(c["model"].s() + " " + fixed(c["frequencyGhz"].n(), 1) + + " GHz"); + p.graph(graph(c["history"], t.success, 100)); + std::vector items; + int i = 0; + for (auto &v : c["cores"].array()) { + Meter m; + m.value = v.n(); + m.label = "P" + std::to_string(i++); + m.label_width = 4; + m.value_width = 5; + items.push_back(m); + } + p.meters(items, columns); + p.divider(); + std::string load; + for (auto &v : c["load"].array()) { + if (!load.empty()) + load += " "; + load += fixed(v.n(), 2); + } + p.keys({kv("Load Avg", load, t.warning)}); + }, + fr(), percent(c["total"].n())); +} +static void memory_panel(UI &ui, State &s) { + const auto &m = s.data["memory"]; + ui.panel("Memory & Swap", [&](UI &p) { + auto t = p.t(); + double used = m["used"].n() / std::max(1., m["total"].n()), + swap = m["swapUsed"].n() / std::max(1., m["swapTotal"].n()); + p.text("Memory " + bytes(m["used"].n()) + " / " + + bytes(m["total"].n()) + " (" + percent(used) + ")"); + p.meter(meter(used)); + p.spacer(cells(1)); + p.keys({kv("Used:", bytes(m["used"].n()), t.warning), + kv("Available:", bytes(m["available"].n()), t.success), + kv("Cached:", bytes(m["cached"].n()), t.accent), + kv("Buffers:", bytes(m["buffers"].n()), t.secondary), + kv("Free:", bytes(m["free"].n()), t.muted)}); + p.spacer(); + p.divider(); + p.text("Swap " + bytes(m["swapUsed"].n()) + " / " + + bytes(m["swapTotal"].n()) + " (" + percent(swap) + ")"); + p.meter(meter(swap, t.secondary)); + p.keys( + {kv("Used:", bytes(m["swapUsed"].n()), t.secondary), + kv("Free:", bytes(m["swapTotal"].n() - m["swapUsed"].n()), t.muted)}); + }); +} +static void disks_panel(UI &ui, State &s) { + ui.panel("Disks", [&](UI &p) { + auto t = p.t(); + auto &disks = s.data["disks"].array(); + if (disks.empty()) { + p.label("No disks reported"); + return; + } + for (std::size_t i = 0; i < std::min(std::size_t(2), disks.size()); i++) { + auto d = disks[i]; + double used = d["used"].n() / std::max(1., d["total"].n()); + p.text(d["device"].s() + " — " + bytes(d["total"].n()) + " (" + + d["type"].s() + ")"); + p.text("Used: " + bytes(d["used"].n()) + " (" + percent(used) + ")", + t.muted); + p.meter(meter(used)); + p.text("Free: " + bytes(d["total"].n() - d["used"].n()), t.muted); + p.row(cells(1), 0, [=](UI &r) { + r.text("Read: " + byte_rate(d["readRate"].n()), t.success); + r.text("Write: " + byte_rate(d["writeRate"].n()), t.secondary, + HQ_RIGHT); + }); + p.graph( + multi(d["readHistory"], d["writeHistory"], t.success, t.secondary)); + if (i == 0 && disks.size() > 1) + p.divider(); + } + }); +} +static void system_panel(UI &ui, State &s) { + ui.panel("System", [&](UI &p) { + auto t = p.t(); + auto sys = s.data["system"], c = s.data["cpu"], m = s.data["memory"]; + double used = m["used"].n() / std::max(1., m["total"].n()); + auto count = sys["processCount"].n() + ? sys["processCount"].s() + : std::to_string(s.data["processes"].array().size()); + p.row(cells(6, 6), 2, [=, &s](UI &r) { + r.keys({kv("OS:", sys["os"].s()), kv("Kernel:", sys["kernel"].s()), + kv("Uptime:", duration(sys["uptime"].n())), + kv("Hostname:", sys["hostname"].s()), + kv("Shell:", sys["shell"].s()), + kv("Source:", s.real ? "linux/proc" : "simulated", t.accent)}, + false); + std::string load; + for (auto &v : c["load"].array()) { + if (!load.empty()) + load += ' '; + load += fixed(v.n(), 2); + } + r.keys({kv("CPU:", percent(c["total"].n()), hq_heat(&t, c["total"].n())), + kv("Memory:", percent(used) + " (" + bytes(m["used"].n()) + ")", + t.warning), + kv("Swap:", + m["swapTotal"].n() + ? percent(m["swapUsed"].n() / m["swapTotal"].n()) + : "—", + t.secondary), + kv("Load:", load), kv("Processes:", count), + kv("Threads:", sys["threadCount"].s())}, + false); + }); + p.panel( + "CPU History", + [=](UI &g) { g.graph(graph(c["history"], t.success, 100, true)); }, + fr(1, 5)); + if (p.width() >= 46 && p.height() >= 16) + p.row(cells(6), 1, [=, &s](UI &r) { + r.panel("Quick Stats", [=, &s](UI &q) { + q.keys( + {kv("Uptime", duration(sys["uptime"].n()), t.accent), + kv("Procs", count, t.accent), + kv("Threads", + sys["threadCount"].n() ? sys["threadCount"].s() : "—", + t.accent), + kv("Ctx/s", + fixed(s.data.path("telemetry.kernel.contextSwitchRate").n() / + 1000, + 1) + + "K", + t.accent)}); + }); + r.panel("Memory", [=](UI &q) { + q.text(percent(used), t.warning); + q.graph(graph(m["history"], t.primary, 100)); + }); + auto temp = s.data["temperatures"].at(0); + r.panel( + "Temp", + [=](UI &q) { + q.gauge(temp.null() + ? c["total"].n() + : std::min(1., temp["value"].n() / + std::max(1., temp["max"].n(100))), + temp.null() + ? percent(c["total"].n()) + : fixed(std::floor(temp["value"].n() + .5)) + "°C"); + }, + cells(14)); + }); + else { + p.divider(); + p.keys({kv("Threads", sys["threadCount"].s(), t.accent), + kv("Ctx switches", + fixed(sys["contextSwitches"].n() / 1000, 1) + "K", t.accent)}); + } + }); +} +static void processes_panel(UI &ui, State &s) { + std::string sort = + std::vector{"CPU", "MEM", "PID", "NAME"}[s.sort]; + ui.panel( + "Processes (sorted by " + sort + ")", + [&](UI &p) { + auto t = p.t(); + table(p, s, "dashboard.processes", s.processes(), + {dc("pid", "PID", 7, 1, 0, true), + dc("name", "Name", -1, 8, t.primary), + dc( + "cpu", "CPU%", 6, 1, 0, true, + [](const Json &d) { return fixed(d["cpu"].n(), 1); }, + [=](const Json &d) { + return hq_heat(&t, std::min(1., d["cpu"].n() / 100)); + }), + dc("mem", "MEM%", 6, 1, t.warning, true, + [](const Json &d) { return fixed(d["mem"].n(), 1); }), + dc("rss", "RSS", 9, 1, 0, true, + [](const Json &d) { return bytes(d["rss"].n(), 0); }), + dc("threads", "Threads", 7, 1, 0, true), + dc("state", "S", 2, 1, 0, false, {}, + [=](const Json &d) { + return d["state"].s() == "R" ? t.success : t.muted; + }), + dc("user", "User", 10, 1, t.muted), + dc("command", "Command", -1, 10, t.muted)}); + }, + fr(), s.filter.empty() ? "" : "filter: " + s.filter, + ui.t().border_focused); +} +static void network_panel(UI &ui, State &s) { + ui.panel("Network", [&](UI &p) { + auto t = p.t(); + auto n = s.data["network"]; + p.row(cells(1), 0, [=](UI &r) { + r.text("Download: " + bit_rate(n["downRate"].n()), t.primary); + r.text("Upload: " + bit_rate(n["upRate"].n()), t.secondary, HQ_RIGHT); + }); + for (auto prefix : {"down", "up"}) { + auto g = graph(n[std::string(prefix) + "History"], + std::string(prefix) == "down" ? t.primary : t.secondary, + {}, true); + g.axis_format = [](double v) { + auto value = bit_rate(v); + value.erase(std::remove(value.begin(), value.end(), ' '), value.end()); + return value; + }; + p.graph(g); + } + p.divider(); + p.row(cells(3), 2, [=](UI &r) { + for (auto prefix : {"down", "up"}) { + auto color = std::string(prefix) == "down" ? t.primary : t.secondary; + r.keys( + {kv("Total:", bytes(n[std::string(prefix) + "Total"].n()), color), + kv("Current:", bit_rate(n[std::string(prefix) + "Rate"].n()), + color), + kv("Peak:", bit_rate(n[std::string(prefix) + "Peak"].n()), color)}, + false); + } + }); + }); +} +static void disk_usage_panel(UI &ui, State &s) { + ui.panel( + "Disk Usage", + [&](UI &p) { + auto t = p.t(); + for (auto &d : s.data["disks"].array()) { + double used = d["used"].n() / std::max(1., d["total"].n()); + Meter m; + m.value = used; + m.readout = percent(used) + " " + bytes(d["used"].n(), 0) + " / " + + bytes(d["total"].n(), 0); + p.meter(m); + p.label(d["mount"].s() + " (" + d["device"].s() + ")"); + } + p.spacer(cells(1)); + auto first = s.data["disks"].at(0); + p.panel("I/O Summary", [=](UI &io) { + io.row(fr(), 2, [=](UI &r) { + r.col(fr(), 0, [=](UI &c) { + c.text("Read: " + byte_rate(first["readRate"].n()), t.success); + c.graph(graph(first["readHistory"], t.success)); + }); + r.col(fr(), 0, [=](UI &c) { + c.text("Write: " + byte_rate(first["writeRate"].n()), + t.secondary); + c.graph(graph(first["writeHistory"], t.secondary)); + }); + }); + }); + }, + fr(), s.data["disks"].at(0)["device"].s("")); +} +static void temperatures_panel(UI &ui, State &s) { + ui.panel("Temperatures", [&](UI &p) { + auto temps = s.data["temperatures"].array(); + auto t = p.t(); + if (temps.empty()) { + p.text("No thermal sensors on this host.", t.muted); + p.spacer(cells(1)); + p.label("Run with --sim to see this panel populated."); + return; + } + int count = 0; + for (auto &temp : temps) { + if (count++ == 10) + break; + double v = + std::min(1., temp["value"].n() / std::max(1., temp["max"].n(100))); + p.row(cells(1), 0, [=](UI &r) { + r.text(temp["label"].s(), t.muted, HQ_LEFT, cells(16)); + r.draw([=](Surface surface) { + int w = surface.rect().width, filled = iround(v * w); + for (int x = 0; x < w; x++) + surface.set(x, 0, 0x25ae, + Style().foreground( + x < filled + ? hq_heat(&t, w <= 1 ? v : double(x) / (w - 1)) + : hq_mix(t.background, t.border, .75))); + }); + r.text(fixed(std::floor(temp["value"].n() + .5)) + "°C", hq_heat(&t, v), + HQ_RIGHT, cells(6)); + }); + } + }); +} +static void sensors_panel(UI &ui, State &s) { + ui.panel("Sensors", [&](UI &p) { + auto &sensors = s.data["sensors"].array(); + if (sensors.empty()) { + p.label("No hardware sensors on this host."); + p.spacer(cells(1)); + p.label("Probed: /sys/class/hwmon, thermal zones, lm-sensors,"); + p.label("power supplies and nvidia-smi."); + return; + } + std::vector rows; + for (auto &v : sensors) + rows.push_back(kv(v["label"].s(), v["value"].s(), p.t().accent)); + p.keys(rows); + }); +} +static void logs_panel(UI &ui, State &s) { + ui.panel("Logs", [&](UI &p) { + p.log(logs(s.data["logs"]), &s.panes["dashboard.logs"], "dashboard.logs"); + }); +} +void dashboard(UI &ui, State &s) { + if (ui.width() >= 150) { + ui.row(cells(ui.height() >= 44 ? 19 : 16), 1, [&](UI &r) { + r.col(fr(), 0, [&](UI &c) { cpu_panel(c, s, 2); }); + r.col(fr(.95), 0, [&](UI &c) { memory_panel(c, s); }); + r.col(fr(.95), 0, [&](UI &c) { disks_panel(c, s); }); + r.col(fr(1.35), 0, [&](UI &c) { system_panel(c, s); }); + }); + ui.row(fr(), 1, [&](UI &r) { + r.col(fr(2), 0, [&](UI &c) { processes_panel(c, s); }); + r.col(fr(1.2), 0, [&](UI &c) { network_panel(c, s); }); + r.col(fr(1.2), 0, [&](UI &c) { disk_usage_panel(c, s); }); + }); + ui.row(cells(12), 1, [&](UI &r) { + temperatures_panel(r, s); + sensors_panel(r, s); + r.col(fr(1.6), 0, [&](UI &c) { logs_panel(c, s); }); + }); + } else if (ui.width() >= 100) { + ui.row(cells(14), 1, [&](UI &r) { + cpu_panel(r, s, 2); + memory_panel(r, s); + system_panel(r, s); + }); + ui.row(fr(), 1, [&](UI &r) { + r.col(fr(1.6), 0, [&](UI &c) { processes_panel(c, s); }); + r.col(fr(), 0, [&](UI &c) { network_panel(c, s); }); + }); + ui.row(cells(10), 1, [&](UI &r) { + temperatures_panel(r, s); + logs_panel(r, s); + }); + } else { + ui.row(cells(10), 1, [&](UI &r) { + cpu_panel(r, s, 1); + memory_panel(r, s); + }); + ui.col(fr(), 0, [&](UI &c) { processes_panel(c, s); }); + ui.row(cells(8), 1, [&](UI &r) { network_panel(r, s); }); + } +} +} // namespace demo diff --git a/ports/cpp/demo/json.hpp b/ports/cpp/demo/json.hpp new file mode 100644 index 0000000..dca862b --- /dev/null +++ b/ports/cpp/demo/json.hpp @@ -0,0 +1,284 @@ +#pragma once +// Small, bounded JSON value/parser for telemetry and test data. Not a renderer; +// simulated samples contain measurements, never pre-rendered cells or frames. +#include +#include +#include +#include +#include +#include +#include +#include +#include +namespace demo { +struct Json { + using Array = std::vector; + using Object = std::map>; + std::variant value; + Json() = default; + Json(double n) : value(n) {} + Json(int n) : value(double(n)) {} + Json(bool n) : value(n) {} + Json(std::string s) : value(std::move(s)) {} + Json(const char *s) : value(std::string(s)) {} + Json(Array a) : value(std::move(a)) {} + Json(Object o) : value(std::move(o)) {} + bool null() const { return std::holds_alternative(value); } + double n(double fallback = 0) const { + auto p = std::get_if(&value); + return p ? *p : fallback; + } + std::string s(std::string fallback = "—") const { + if (auto p = std::get_if(&value)) + return *p; + if (auto p = std::get_if(&value)) { + char b[80]; + if (*p == std::floor(*p) && std::abs(*p) < 1e21) { + std::snprintf(b, sizeof b, "%.0f", *p); + return b; + } + auto result = std::to_chars(b, b + sizeof b, *p); + return std::string(b, result.ptr); + } + if (auto p = std::get_if(&value)) + return *p ? "true" : "false"; + return fallback; + } + const Array &array() const { + static const Array empty; + auto p = std::get_if(&value); + return p ? *p : empty; + } + Array &array() { + if (!std::holds_alternative(value)) + value = Array{}; + return std::get(value); + } + const Json &operator[](std::string_view key) const { + static const Json empty; + if (auto p = std::get_if(&value)) { + auto it = p->find(key); + if (it != p->end()) + return it->second; + } + return empty; + } + Json &operator[](std::string_view key) { + if (!std::holds_alternative(value)) + value = Object{}; + return std::get(value)[std::string(key)]; + } + const Json &at(std::size_t i) const { + static const Json empty; + auto &a = array(); + return i < a.size() ? a[i] : empty; + } + const Json &path(std::string_view key) const { + auto dot = key.find('.'); + return dot == key.npos + ? (*this)[key] + : (*this)[key.substr(0, dot)].path(key.substr(dot + 1)); + } + static Json parse(std::string_view source) { + if (source.size() > 16 * 1024 * 1024) + throw std::runtime_error("JSON input too large"); + struct Parser { + std::string_view s; + std::size_t i = 0; + [[noreturn]] void fail() { + throw std::runtime_error("invalid JSON at byte " + std::to_string(i)); + } + void ws() { + while (i < s.size() && + (s[i] == ' ' || s[i] == '\n' || s[i] == '\r' || s[i] == '\t')) + i++; + } + char take() { + if (i == s.size()) + fail(); + return s[i++]; + } + unsigned hex() { + unsigned n = 0; + for (int j = 0; j < 4; j++) { + char c = take(); + n *= 16; + if (c >= '0' && c <= '9') + n += c - '0'; + else if (c >= 'a' && c <= 'f') + n += c - 'a' + 10; + else if (c >= 'A' && c <= 'F') + n += c - 'A' + 10; + else + fail(); + } + return n; + } + std::string string() { + if (take() != '"') + fail(); + std::string out; + for (;;) { + unsigned char c = take(); + if (c == '"') + break; + if (c < 32) + fail(); + if (c != '\\') { + out += char(c); + continue; + } + switch (take()) { + case '"': + out += '"'; + break; + case '\\': + out += '\\'; + break; + case '/': + out += '/'; + break; + case 'b': + out += '\b'; + break; + case 'f': + out += '\f'; + break; + case 'n': + out += '\n'; + break; + case 'r': + out += '\r'; + break; + case 't': + out += '\t'; + break; + case 'u': { + unsigned cp = hex(); + if (cp >= 0xd800 && cp <= 0xdbff) { + if (take() != '\\' || take() != 'u') + fail(); + unsigned lo = hex(); + if (lo < 0xdc00 || lo > 0xdfff) + fail(); + cp = 0x10000 + ((cp - 0xd800) << 10) + (lo - 0xdc00); + } else if (cp >= 0xdc00 && cp <= 0xdfff) + fail(); + out += hqtui::utf8(cp); + break; + } + default: + fail(); + } + } + return out; + } + Json parse(int depth = 0) { + if (depth > 64) + fail(); + ws(); + if (i == s.size()) + fail(); + char c = s[i]; + if (c == '"') + return Json(string()); + if (c == '[') { + i++; + Array a; + ws(); + if (i < s.size() && s[i] == ']') { + i++; + return a; + } + for (;;) { + a.push_back(parse(depth + 1)); + ws(); + char end = take(); + if (end == ']') + return a; + if (end != ',') + fail(); + } + } + if (c == '{') { + i++; + Object o; + ws(); + if (i < s.size() && s[i] == '}') { + i++; + return o; + } + for (;;) { + ws(); + auto key = string(); + ws(); + if (take() != ':') + fail(); + auto v = parse(depth + 1); + if (!o.emplace(std::move(key), std::move(v)).second) + fail(); + ws(); + char end = take(); + if (end == '}') + return o; + if (end != ',') + fail(); + } + } + for (auto literal : {"null", "true", "false"}) + if (s.substr(i, std::char_traits::length(literal)) == literal) { + i += std::char_traits::length(literal); + return literal[0] == 'n' ? Json() : Json(literal[0] == 't'); + } + std::size_t start = i; + if (s[i] == '-') + i++; + if (i == s.size()) + fail(); + if (s[i] == '0') + i++; + else { + if (s[i] < '1' || s[i] > '9') + fail(); + while (i < s.size() && s[i] >= '0' && s[i] <= '9') + i++; + } + if (i < s.size() && s[i] == '.') { + i++; + auto begin = i; + while (i < s.size() && s[i] >= '0' && s[i] <= '9') + i++; + if (i == begin) + fail(); + } + if (i < s.size() && (s[i] == 'e' || s[i] == 'E')) { + i++; + if (i < s.size() && (s[i] == '+' || s[i] == '-')) + i++; + auto begin = i; + while (i < s.size() && s[i] >= '0' && s[i] <= '9') + i++; + if (i == begin) + fail(); + } + double n = std::strtod(std::string(s.substr(start, i - start)).c_str(), + nullptr); + if (!std::isfinite(n)) + fail(); + return n; + } + } p{source}; + Json result = p.parse(); + p.ws(); + if (p.i != source.size()) + p.fail(); + return result; + } +}; +inline std::vector numbers(const Json &j) { + std::vector result; + for (auto &v : j.array()) + result.push_back(v.n()); + return result; +} +} // namespace demo diff --git a/ports/cpp/demo/main.cpp b/ports/cpp/demo/main.cpp new file mode 100644 index 0000000..e8f9244 --- /dev/null +++ b/ports/cpp/demo/main.cpp @@ -0,0 +1,695 @@ +#include "collect.hpp" +#include "model.hpp" +#include "sample.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +namespace demo { +static volatile std::sig_atomic_t interrupted = 0; +static void signal_handler(int value) { interrupted = value; } +static bool write_all(std::string_view bytes) { + while (!bytes.empty()) { + ssize_t n = ::write(STDOUT_FILENO, bytes.data(), bytes.size()); + if (n < 0) { + if (errno == EINTR && !interrupted) + continue; + return false; + } + if (!n) + return false; + bytes.remove_prefix(std::size_t(n)); + } + return true; +} +class Terminal { + termios before_{}; + bool active_ = false; + using Handler = void (*)(int); + Handler old_int_, old_term_, old_hup_, old_pipe_; + +public: + Terminal() { + if (!isatty(0) || !isatty(1) || tcgetattr(0, &before_)) + throw std::runtime_error("An interactive terminal is required. Use " + "--snapshot for headless output."); + auto raw = before_; + cfmakeraw(&raw); + raw.c_cc[VMIN] = 0; + raw.c_cc[VTIME] = 0; + if (tcsetattr(0, TCSANOW, &raw)) + throw std::runtime_error("Cannot acquire terminal"); + active_ = true; + old_int_ = std::signal(SIGINT, signal_handler); + old_term_ = std::signal(SIGTERM, signal_handler); + old_hup_ = std::signal(SIGHUP, signal_handler); + old_pipe_ = std::signal(SIGPIPE, signal_handler); + write_all("\x1b[?1049h\x1b[?25l\x1b[?1000h\x1b[?1002h\x1b[?1006h\x1b[?" + "2004h\x1b[?1004h"); + } + ~Terminal() { restore(); } + void restore() { + if (!active_) + return; + write_all("\x1b[0m\x1b[?1000l\x1b[?1002l\x1b[?1006l\x1b[?2004l\x1b[?" + "1004l\x1b[?25h\x1b[?1049l"); + int restored; + do { + restored = tcsetattr(0, TCSANOW, &before_); + } while (restored < 0 && errno == EINTR); + if (restored < 0) + std::cerr << "Cannot restore terminal: " << std::strerror(errno) << "\n"; + std::signal(SIGINT, old_int_); + std::signal(SIGTERM, old_term_); + std::signal(SIGHUP, old_hup_); + std::signal(SIGPIPE, old_pipe_); + active_ = false; + } + std::pair size() { + winsize size{}; + if (ioctl(1, TIOCGWINSZ, &size)) + return {120, 40}; + return {std::clamp(int(size.ws_col), 1, 500), + std::clamp(int(size.ws_row), 1, 200)}; + } +}; +class Worker { + Collector collector_; + std::mutex mutex_; + std::condition_variable wake_; + std::thread thread_; + std::optional ready_; + std::string error_; + +public: + std::atomic stop{false}; + Worker(const Json &shape, double interval) : collector_(shape) { + thread_ = std::thread([this, interval] { + while (!stop) { + try { + collector_.refresh(&stop); + if (stop) + break; + std::lock_guard lock(mutex_); + ready_ = collector_.data; + } catch (const std::exception &e) { + std::lock_guard lock(mutex_); + error_ = e.what(); + } + std::unique_lock lock(mutex_); + wake_.wait_for(lock, std::chrono::duration(interval), + [&] { return stop.load(); }); + } + }); + } + ~Worker() { + stop = true; + wake_.notify_all(); + if (thread_.joinable()) + thread_.join(); + } + bool update(State &s) { + std::lock_guard lock(mutex_); + if (!error_.empty()) + throw std::runtime_error(error_); + if (!ready_ || s.paused) + return false; + s.data = std::move(*ready_); + ready_.reset(); + return true; + } +}; +static void body(UI &ui, State &s) { + if (s.screen == 0) + dashboard(ui, s); + else if (s.screen < 5) + telemetry(ui, s); + else + showcase(ui, s); +} +void render(UI &ui, State &s, bool body_only) { + if (body_only) { + body(ui, s); + return; + } + auto t = ui.t(); + auto regions = ui.regions; + ui.row(cells(1), 0, [&, t, regions](UI &r) { + r.text(" hqtui.com", t.title, HQ_LEFT, cells(12), HQ_BOLD); + r.draw([&, t, regions](Surface surface) { + int x = 0; + for (int i = 0; i < 10; i++) { + std::string label = + " " + std::to_string((i + 1) % 10) + " " + screens[i] + " "; + int w = int(width(label)); + if (x >= surface.rect().width) + break; + bool active = i == s.screen; + text(surface, x, 0, label, active ? t.background : t.muted, + active ? HQ_BOLD : 0, + active ? std::optional(t.primary) : std::nullopt); + if (regions) + regions->push_back( + {hq_intersect(surface.rect(), + {surface.rect().x + x, surface.rect().y, w, 1}), + "tab:" + std::to_string(i), 0}); + x += w; + } + }); + r.text(std::string(s.paused ? "paused" : "live") + " " + + (s.real ? "real" : "simulated") + " " + fixed(s.fps) + "fps " + + s.clock + " ", + s.paused ? t.warning : t.success, HQ_RIGHT, cells(38)); + }); + ui.spacer(cells(1)); + ui.col(cells(std::max(0, ui.height() - 4)), 0, [&](UI &p) { body(p, s); }); + ui.spacer(cells(1)); + ui.draw( + [&, t](Surface surface) { + int x = 0; + std::vector> items = { + {"F1", "Help"}, + {"F2", "Theme (" + std::string(themes[s.theme_index]) + ")"}, + {"F3", s.filtering ? "Filter: " + s.filter + "_" : "Filter"}, + {"F6", "Sort: " + std::vector{"cpu", "mem", "pid", + "name"}[s.sort]}, + {"^K", "Palette"}, + {"Tab", "Screen"}, + {"q", "Quit"}}; + for (auto &item : items) { + text(surface, x, 0, " " + item.first + " ", t.background, HQ_BOLD, + t.primary); + x += int(width(item.first)) + 2; + text(surface, x, 0, " " + item.second + " ", t.muted); + x += int(width(item.second)) + 2; + } + auto right = fixed(s.render_ms, 2) + "ms " + + std::to_string(s.changed_cells) + " cells " + + std::to_string(s.output_bytes) + "B"; + if (surface.rect().width - int(width(right)) > x) + text(surface, surface.rect().width - int(width(right)), 0, right, + t.muted); + }, + cells(1)); +} +static void overlay(Buffer &frame, State &s) { + if (!s.help && !s.modal && !s.palette && !s.filtering) + return; + auto t = hq_theme_named(themes[s.theme_index]); + auto surface = frame.surface(t); + int w = std::min(frame.width() - 2, 72), + h = std::min(frame.height() - 2, s.palette ? 16 : 11); + if (w < 4 || h < 3) + return; + hq_box_options box{}; + box.title = s.help ? "hqtui — Help" + : s.modal ? "Read-only Demo" + : s.palette ? "Command Palette" + : "Filter Processes"; + auto p = + surface.sub({(frame.width() - w) / 2, (frame.height() - h) / 2, w, h}) + .box(box); + std::vector lines; + if (s.help) + lines = {"1–9 / 0 / Tab: change screen", + "F2: theme · F3: filter · F6: sort", + "Ctrl+K: command palette · Space: pause", + "Arrows / PgUp / PgDn / Home / End: scroll", + "Mouse: select a pane, click controls, scroll", + "e: edit text · Esc: finish · q / Ctrl+C: quit", + "Any key closes help"}; + else if (s.modal) + lines = {"No process will be killed and no service changed.", + "Press any key to close."}; + else if (s.filtering) + lines = {"Filter: " + s.filter + "_", "Enter or Esc to finish"}; + else { + lines.push_back("> " + s.query + "_"); + for (int i = 0; i < 10; i++) + lines.push_back(std::string(i == s.palette_index ? "▸ " : " ") + + screens[i]); + } + for (int y = 0; y < p.rect().height && y < int(lines.size()); y++) + text(p, 1, y, fit(lines[y], std::max(0, p.rect().width - 2)), + t->foreground); +} +static void activate(State &s, const std::string &id) { + if (id.rfind("tab:", 0) == 0) { + s.screen = std::stoi(id.substr(4)); + return; + } + if (id == "checkbox") + s.checkbox = !s.checkbox; + else if (id == "toggle") + s.toggle = !s.toggle; + else if (id == "select") + s.select_open = !s.select_open; + else if (id.rfind("button:", 0) == 0) + s.modal = true; + else + s.focused = id; +} +static bool key(State &s, std::string key) { + s.last_key = key.size() == 1 && static_cast(key[0]) < 32 + ? "Ctrl+" + std::string(1, char(key[0] + 64)) + : key; + s.key_log.push_back(s.last_key); + if (s.key_log.size() > 100) + s.key_log.erase(s.key_log.begin()); + if (key == "\x03") + return true; + if (s.help || s.modal) { + s.help = false; + s.modal = false; + return false; + } + if (s.filtering || s.editing || s.palette) { + std::string &value = s.filtering ? s.filter : s.editing ? s.input : s.query; + if (key == "escape" || key == "\r") { + if (s.palette && key == "\r") + s.screen = s.palette_index; + s.filtering = s.editing = s.palette = false; + } else if (s.palette && (key == "up" || key == "down")) + s.palette_index = (s.palette_index + (key == "up" ? 9 : 1)) % 10; + else if (key == "\x7f" || key == "\b") { + if (!value.empty()) { + std::size_t i = value.size() - 1; + while (i && ((static_cast(value[i]) & 192) == 128)) + i--; + value.resize(i); + } + } else if ((key.size() == 1 || static_cast(key[0]) >= 128) && + key.size() <= 4 && static_cast(key[0]) >= 32 && + value.size() + key.size() <= 4096) + value += key; + return false; + } + if (s.select_open) { + if (key == "up" || key == "down") + s.select_index = (s.select_index + (key == "up" ? 3 : 1)) % 4; + else if (key == "\r" || key == "escape") + s.select_open = false; + return false; + } + if (key == "q") + return true; + if (key == "f1" || key == "?") + s.help = true; + else if (key == "f2") + s.theme_index = (s.theme_index + 1) % 9; + else if (key == "f3" || key == "/") + s.filtering = true; + else if (key == "f6") + s.sort = (s.sort + 1) % 4; + else if (key == "\x0b") + s.palette = true; + else if (key == "e") + s.editing = true; + else if (key == " ") + s.paused = !s.paused; + else if (key == "\t") + s.screen = (s.screen + 1) % 10; + else if (key == "backtab") + s.screen = (s.screen + 9) % 10; + else if (key.size() == 1 && key[0] >= '0' && key[0] <= '9') + s.screen = (key[0] - '0' + 9) % 10; + else if ((key == "left" || key == "right") && s.screen == 7) + s.theme_index = (s.theme_index + (key == "left" ? 8 : 1)) % 9; + else if (key == "left" || key == "right") { + std::vector ids; + for (auto &r : s.regions) + if (s.panes.count(r.id) && + std::find(ids.begin(), ids.end(), r.id) == ids.end()) + ids.push_back(r.id); + if (!ids.empty()) { + auto it = std::find(ids.begin(), ids.end(), s.focused); + int i = it == ids.end() ? 0 : int(it - ids.begin()); + s.focused = ids[(i + (key == "left" ? int(ids.size()) - 1 : 1)) % + int(ids.size())]; + } + } else { + auto &p = s.panes[s.focused]; + if (key == "down") + p.move(1); + else if (key == "up") + p.move(-1); + else if (key == "pagedown") + p.move(std::max(1, p.capacity)); + else if (key == "pageup") + p.move(-std::max(1, p.capacity)); + else if (key == "home") { + if (p.log) + p.offset = std::max(0, p.total - p.capacity); + else + p.selected = 0; + } else if (key == "end") { + if (p.log) + p.offset = 0; + else + p.selected = std::max(0, p.total - 1); + } + } + return false; +} +class Input { + std::string pending; + bool paste = false; + std::chrono::steady_clock::time_point escape_time; + +public: + bool feed(State &s, std::string_view input) { + if (pending.size() + input.size() > 8192) + pending.clear(); + if (pending.empty()) + escape_time = std::chrono::steady_clock::now(); + pending += input; + while (!pending.empty()) { + if (paste) { + auto end = pending.find("\x1b[201~"); + if (end == pending.npos) { + if (pending.size() < 4096) + return false; + end = pending.size() - 6; + } + std::string &target = s.filtering ? s.filter + : s.palette ? s.query + : s.input; + if (s.editing || s.filtering || s.palette) + target.append(pending.substr(0, std::min(end, 4096 - target.size()))); + pending.erase(0, end); + if (pending.rfind("\x1b[201~", 0) == 0) { + pending.erase(0, 6); + paste = false; + } + continue; + } + if (pending.rfind("\x1b[200~", 0) == 0) { + pending.erase(0, 6); + paste = true; + continue; + } + if (pending.rfind("\x1b[<", 0) == 0) { + auto end = pending.find_first_of("mM", 3); + if (end == pending.npos) + return false; + auto sequence = pending.substr(0, end + 1); + pending.erase(0, end + 1); + int button, x, y; + if (std::sscanf(sequence.c_str(), "\x1b[<%d;%d;%d", &button, &x, &y) == + 3 && + x >= 1 && y >= 1 && x <= 500 && y <= 200) { + x--; + y--; + s.last_mouse = "mouse " + std::to_string(x) + "," + std::to_string(y); + if (!s.overlay()) + for (auto it = s.regions.rbegin(); it != s.regions.rend(); it++) { + auto r = it->rect; + if (x < r.x || y < r.y || x >= r.x + r.width || + y >= r.y + r.height) + continue; + if (button & 64) { + s.focused = it->id; + s.panes[it->id].move(button & 1 ? 3 : -3); + } else if (sequence.back() == 'M' && (button & 3) == 0 && + !(button & 32)) { + activate(s, it->id); + if (s.panes.count(it->id) && y - r.y >= it->header) { + auto &p = s.panes[it->id]; + p.selected = std::clamp(p.offset + y - r.y - it->header, 0, + std::max(0, p.total - 1)); + } + } + break; + } + } + continue; + } + if (pending[0] == '\x1b') { + static const std::vector> + sequences = {{"\x1b[A", "up"}, {"\x1b[B", "down"}, + {"\x1b[C", "right"}, {"\x1b[D", "left"}, + {"\x1b[H", "home"}, {"\x1b[F", "end"}, + {"\x1b[5~", "pageup"}, {"\x1b[6~", "pagedown"}, + {"\x1b[Z", "backtab"}, {"\x1bOP", "f1"}, + {"\x1bOQ", "f2"}, {"\x1bOR", "f3"}, + {"\x1b[11~", "f1"}, {"\x1b[12~", "f2"}, + {"\x1b[13~", "f3"}, {"\x1b[17~", "f6"}, + {"\x1b[I", "focus"}, {"\x1b[O", "blur"}}; + bool matched = false; + for (auto &entry : sequences) + if (pending.rfind(entry.first, 0) == 0) { + pending.erase(0, entry.first.size()); + if (key(s, entry.second)) + return true; + matched = true; + break; + } + if (matched) + continue; + if (std::chrono::steady_clock::now() - escape_time < + std::chrono::milliseconds(35)) + return false; + pending.erase(0, 1); + if (key(s, "escape")) + return true; + continue; + } + unsigned char c = pending[0]; + std::size_t n = c < 128 ? 1 : c < 224 ? 2 : c < 240 ? 3 : 4; + if (pending.size() < n) + return false; + auto token = pending.substr(0, n); + pending.erase(0, n); + if (key(s, token)) + return true; + } + return false; + } +}; +static void simulate(State &s) { + double time = s.data["time"].n() + .1; + s.data["time"] = time; + auto &cpu = s.data["cpu"]; + double used = .45 + std::sin(time * .35) * .22; + cpu["total"] = used; + auto &cores = cpu["cores"].array(); + for (std::size_t i = 0; i < cores.size(); i++) + cores[i] = ratio(used + std::sin(time + i) * .15); + auto &history = cpu["history"].array(); + if (history.size() >= 240) + history.erase(history.begin()); + history.emplace_back(used * 100); +} +} // namespace demo +#ifndef HQTUI_DEMO_TEST +int main(int argc, char **argv) { + using namespace demo; + try { + bool snapshot = false, body_only = false, sim = false, real = false; + int w = 160, h = 50, fps = 30, ticks = 0; + double interval = 1; + std::string format = "text"; + State state; + state.data = Json::parse(sample_json); + for (int i = 1; i < argc; i++) { + std::string arg = argv[i]; + if (arg == "--help" || arg == "-h") { + std::cout + << "hqtui-demo-cpp — native C++ reference demo\n--sim | --real " + "--snapshot --screen " + "dashboard|traffic|sessions|network|services|components|" + "graphics|themes|input|stress\n--theme " + "dark|dracula|nord|tokyo-night|gruvbox|matrix|monochrome|high-" + "contrast|light\n--width N --height N --fps 1–120 --interval " + "0.05–60 --ticks N --format text|ansi\n--version\n"; + return 0; + } + if (arg == "--version") { + std::cout << HQTUI_DEMO_VERSION << "\n"; + return 0; + } + if (arg == "--sim") + sim = true; + else if (arg == "--real") + real = true; + else if (arg == "--snapshot") + snapshot = true; + else if (arg == "--body") + body_only = true; + else { + if (++i == argc) + throw std::runtime_error("Missing value for " + arg); + std::string v = argv[i]; + if (arg == "--screen") { + int n = 0; + for (; n < 10 && v != screens[n]; n++) { + } + if (n == 10) + throw std::runtime_error("Unknown screen"); + state.screen = n; + } else if (arg == "--theme") { + int n = 0; + for (; n < 9 && v != themes[n]; n++) { + } + if (n == 9) + throw std::runtime_error("Unknown theme"); + state.theme_index = n; + } else if (arg == "--format") + format = v; + else { + std::size_t end = 0; + double number = std::stod(v, &end); + if (end != v.size() || !std::isfinite(number)) + throw std::runtime_error("Invalid number"); + if (arg == "--interval") + interval = number; + else { + if (number != std::floor(number) || number < 0 || number > 10000) + throw std::runtime_error("Invalid integer"); + if (arg == "--width") + w = int(number); + else if (arg == "--height") + h = int(number); + else if (arg == "--fps") + fps = int(number); + else if (arg == "--ticks") + ticks = int(number); + else + throw std::runtime_error("Unknown option " + arg); + } + } + } + } + if (w < 1 || w > 500 || h < 1 || h > 200 || fps < 1 || fps > 120 || + interval < .05 || interval > 60 || sim && real || + (format != "text" && format != "ansi")) + throw std::runtime_error("Invalid options"); +#if defined(__linux__) + state.real = real || (!sim && !snapshot); +#else + state.real = + real; // Non-Linux hosts default to explicitly labelled sample data. +#endif + if (state.real) { + Collector collector(state.data); + if (snapshot) { + collector.refresh(); + state.data = collector.data; + } else + state.data = collector.data; + } else + for (int i = 0; i < ticks; i++) + simulate(state); + if (snapshot) { + auto t = hq_theme_named(themes[state.theme_index]); + Buffer frame(w, h); + frame.clear(t->background, t->foreground); + UI ui(frame.surface(t), false, 0, &state.regions); + render(ui, state, body_only); + ui.flush(); + if (format == "ansi") { + Buffer previous(w, h); + Encoder encoder; + write_all(encoder.encode(previous, frame, true).output); + } else + for (int y = 0; y < h; y++) + std::cout << frame.row(y) << "\n"; + return 0; + } + Terminal terminal; + std::unique_ptr worker; + if (state.real) + worker = std::make_unique(Json::parse(sample_json), interval); + auto size = terminal.size(); + Buffer previous(size.first, size.second), frame(size.first, size.second); + Encoder encoder; + Input input; + bool first = true; + auto last = std::chrono::steady_clock::now(), last_sim = last; + while (!interrupted) { + auto start = std::chrono::steady_clock::now(); + if (worker) + worker->update(state); + else if (!state.paused && + start - last_sim >= std::chrono::milliseconds(100)) { + simulate(state); + last_sim = start; + } + size = terminal.size(); + if (size.first != frame.width() || size.second != frame.height()) { + frame.resize(size.first, size.second); + previous.resize(size.first, size.second); + first = true; + } + auto t = hq_theme_named(themes[state.theme_index]); + frame.clear(t->background, t->foreground); + state.regions.clear(); + std::time_t wall = std::time(nullptr); + std::tm utc{}; + gmtime_r(&wall, &utc); + char clock[9]; + std::strftime(clock, sizeof clock, "%H:%M:%S", &utc); + state.clock = clock; + state.fps = + first ? fps + : 1 / std::max( + .0001, + std::chrono::duration(start - last).count()); + last = start; + UI ui(frame.surface(t), false, 0, &state.regions); + render(ui, state); + ui.flush(); + if (!state.overlay() && + std::none_of( + state.regions.begin(), state.regions.end(), + [&](const Region &r) { return r.id == state.focused; })) { + for (const auto ®ion : state.regions) + if (state.panes.count(region.id)) { + state.focused = region.id; + break; + } + } + overlay(frame, state); + auto result = encoder.encode(previous, frame, first); + first = false; + if (!write_all(result.output)) + break; + state.changed_cells = result.changed_cells; + state.output_bytes = result.output.size(); + previous.copy_from(frame); + state.render_ms = std::chrono::duration( + std::chrono::steady_clock::now() - start) + .count(); + if (input.feed(state, {})) + break; + auto elapsed = std::chrono::duration_cast( + std::chrono::steady_clock::now() - start) + .count(); + struct pollfd pfd{0, POLLIN, 0}; + int ready = poll(&pfd, 1, std::max(0, 1000 / fps - int(elapsed))); + if (ready > 0) { + if (pfd.revents & (POLLHUP | POLLERR)) + break; + char buffer[4096]; + ssize_t n = read(0, buffer, sizeof buffer); + if (n > 0 && + input.feed(state, std::string_view(buffer, std::size_t(n)))) + break; + } + } + terminal.restore(); + return interrupted ? 128 + interrupted : 0; + } catch (const std::exception &e) { + std::cerr << "hqtui-demo-cpp: " << e.what() << "\n"; + return 2; + } +} +#endif diff --git a/ports/cpp/demo/model.hpp b/ports/cpp/demo/model.hpp new file mode 100644 index 0000000..0be2f3e --- /dev/null +++ b/ports/cpp/demo/model.hpp @@ -0,0 +1,170 @@ +#pragma once +#include "json.hpp" +#include +#include +#include +namespace demo { +using namespace hqtui; +inline constexpr const char *screens[] = { + "dashboard", "traffic", "sessions", "network", "services", + "components", "graphics", "themes", "input", "stress"}; +inline constexpr const char *themes[] = { + "dark", "dracula", "nord", "tokyo-night", "gruvbox", + "matrix", "monochrome", "high-contrast", "light"}; +inline std::string fixed(double v, int digits = 0) { + if (!std::isfinite(v)) + return "—"; + std::ostringstream s; + s.imbue(std::locale::classic()); + s << std::fixed << std::setprecision(digits) << v; + return s.str(); +} +inline std::string bytes(double v, int digits = 2) { + if (!std::isfinite(v)) + return "—"; + v = std::max(0., v); + const char *units[] = {"B", "KiB", "MiB", "GiB", "TiB", "PiB"}; + int i = 0; + while (v >= 1024 && i < 5) { + v /= 1024; + i++; + } + return fixed(v, i ? digits : 0) + " " + units[i]; +} +inline std::string percent(double v) { + return fixed(std::floor(ratio(v) * 100 + .5)) + "%"; +} +inline std::string byte_rate(double v) { + for (auto item : {std::pair{1e9, "GB/s"}, + {1e6, "MB/s"}, + {1e3, "KB/s"}}) + if (v >= item.first) + return fixed(v / item.first, 1) + " " + item.second; + return fixed(std::max(0., v)) + " B/s"; +} +inline std::string bit_rate(double v) { + v = std::max(0., v) * 8; + for (auto item : {std::pair{1e9, "Gb/s"}, + {1e6, "Mb/s"}, + {1e3, "Kb/s"}}) + if (v >= item.first) + return fixed(v / item.first, 1) + " " + item.second; + return fixed(v) + " b/s"; +} +inline std::string duration(double v) { + auto n = static_cast(std::max(0., v)); + auto d = n / 86400, h = n % 86400 / 3600, m = n % 3600 / 60; + if (d) + return std::to_string(d) + "d " + std::to_string(h) + "h " + + std::to_string(m) + "m"; + if (h) + return std::to_string(h) + "h " + std::to_string(m) + "m"; + return std::to_string(m) + "m " + std::to_string(n % 60) + "s"; +} +inline KeyValue kv(std::string label, std::string value, Color color = 0) { + return {std::move(label), std::move(value), color}; +} +inline Graph graph(const Json &values, Color color, + std::optional max = {}, bool axis = false) { + Graph g; + g.series.push_back({numbers(values), color, "", true}); + g.max = max; + g.axis = axis; + return g; +} +inline Graph multi(const Json &a, const Json &b, Color ca, Color cb) { + Graph g; + g.series = {{numbers(a), ca, "", true}, {numbers(b), cb, "", true}}; + return g; +} +inline Meter meter(double value, Color color = 0) { + Meter m; + m.value = value; + m.color = color; + m.show_value = false; + return m; +} +struct State { + Json data; + bool real = false, paused = false, help = false, modal = false, + palette = false, filtering = false, editing = false, toggle = true, + checkbox = true, select_open = false; + int screen = 0, theme_index = 0, sort = 0, select_index = 0, + palette_index = 0; + double slider = .7, fps = 0, render_ms = 0; + std::size_t changed_cells = 0, output_bytes = 0; + std::string clock = "12:00:00", input = "", filter, query, last_key = "—", + last_mouse = "—", focused = "dashboard.processes"; + std::vector key_log; + std::map panes; + std::vector regions; + bool overlay() const { return help || modal || palette || filtering; } + std::vector processes() const { + auto rows = data["processes"].array(); + rows.erase(std::remove_if(rows.begin(), rows.end(), + [&](const Json &p) { + return !filter.empty() && + p["name"].s().find(filter) == + std::string::npos && + p["command"].s().find(filter) == + std::string::npos; + }), + rows.end()); + std::stable_sort( + rows.begin(), rows.end(), [&](const Json &a, const Json &b) { + if (sort == 3) + return a["name"].s() < b["name"].s(); + auto key = sort == 0 ? "cpu" : sort == 1 ? "mem" : "pid"; + return sort == 2 ? a[key].n() < b[key].n() : a[key].n() > b[key].n(); + }); + return rows; + } +}; +struct DataColumn { + std::string key; + Column column; + std::function format; + std::function color; +}; +inline DataColumn dc(std::string key, std::string title, int width = -1, + int min = 1, Color color = 0, bool right = false, + std::function format = {}, + std::function tint = {}) { + return {std::move(key), + {std::move(title), width, min, color, right ? HQ_RIGHT : HQ_LEFT}, + std::move(format), + std::move(tint)}; +} +inline void table(UI &p, State &s, std::string name, + const std::vector &rows, std::vector cols, + bool zebra = false, bool header = true, + bool scrollbar = true) { + Table t; + t.pane = &s.panes[name]; + t.zebra = zebra; + t.header = header; + t.scrollbar = scrollbar; + for (auto &c : cols) + t.columns.push_back(c.column); + for (auto &r : rows) { + TableRow row; + for (auto &c : cols) { + row.cells.push_back(c.format ? c.format(r) : r[c.key].s()); + row.colors.push_back(c.color ? c.color(r) : c.column.color); + } + t.rows.push_back(std::move(row)); + } + p.table(std::move(t), name); +} +inline std::vector logs(const Json &data) { + std::vector rows; + for (auto &r : data.array()) + rows.push_back({r["time"].s(""), r["level"].s(""), r["message"].s(""), + "{" + r["meta"].s("") + "}"}); + return rows; +} +void dashboard(UI &, State &); +void telemetry(UI &, State &); +void showcase(UI &, State &); +void render(UI &, State &, bool body = false); +} // namespace demo diff --git a/ports/cpp/demo/sample.hpp.in b/ports/cpp/demo/sample.hpp.in new file mode 100644 index 0000000..97a499b --- /dev/null +++ b/ports/cpp/demo/sample.hpp.in @@ -0,0 +1,2 @@ +#pragma once +namespace demo { inline constexpr const char* sample_json=R"hqtuisample(@HQTUI_DEMO_SAMPLE@)hqtuisample"; } diff --git a/ports/cpp/demo/showcase.cpp b/ports/cpp/demo/showcase.cpp new file mode 100644 index 0000000..23a060f --- /dev/null +++ b/ports/cpp/demo/showcase.cpp @@ -0,0 +1,581 @@ +#include "model.hpp" +namespace demo { +static void button(UI &p, std::string label, Color color, int width, + std::string id) { + auto regions = p.regions; + p.draw( + [=](Surface surface) { + auto t = theme(surface); + bool focused = id == "button:A"; + Color bg = focused ? color : hq_mix(t.surface, color, .16), + fg = focused ? (t.dark ? t.background : t.surface) : color; + aligned(surface, 0, " " + label + " ", fg, HQ_CENTER, HQ_BOLD, bg); + if (regions) + regions->push_back({surface.rect(), id, 0}); + }, + cells(width)); +} +static void badge(UI &p, std::string label, Color color, int width, + std::string variant = "filled", + std::optional background = {}) { + p.draw( + [=](Surface surface) { + auto t = theme(surface); + std::optional bg = + variant == "filled" ? std::optional(color) + : variant == "subtle" + ? std::optional(hq_mix(t.surface, color, .18)) + : background; + auto fg = + variant == "filled" ? (t.dark ? t.background : t.surface) : color; + text(surface, 0, 0, " " + label + " ", fg, + variant == "subtle" ? 0 : HQ_BOLD, bg); + }, + cells(width)); +} +static void checkbox(UI &p, State &s, std::string label, int width, + bool toggle = false) { + auto regions = p.regions; + bool checked = toggle ? s.toggle : s.checkbox; + p.draw( + [=](Surface surface) { + auto t = theme(surface); + text(surface, 0, 0, + toggle ? (checked ? "[▮ ]" : "[ ▮]") : (checked ? "[✓]" : "[ ]"), + checked ? t.success : t.muted); + text(surface, toggle ? 4 : 3, 0, " " + label, t.muted); + if (regions) + regions->push_back( + {surface.rect(), toggle ? "toggle" : "checkbox", 0}); + }, + cells(width)); +} +static void spark(UI &p, std::string label, const Json &data, std::string value, + Color color) { + auto values = numbers(data); + p.draw( + [=](Surface surface) { + auto t = theme(surface); + int lw = int(width(label)) + 1, vw = int(width(value)) + 1, + w = surface.rect().width - lw - vw; + text(surface, 0, 0, label, t.muted); + double hi = 1; + for (int i = std::max(0, int(values.size()) - w); + i < int(values.size()); i++) + hi = std::max(hi, values[i]); + int count = std::min(std::max(0, w), int(values.size())); + for (int i = 0; i < count; i++) { + int n = std::clamp( + iround(ratio(values[values.size() - count + i] / hi) * 8), 0, 8); + surface.set(lw + w - count + i, 0, n ? 0x2580 + n : ' ', + Style().foreground(color)); + } + text(surface, surface.rect().width - vw, 0, fit(value, vw, HQ_RIGHT), + color, HQ_BOLD); + }, + cells(1)); +} +static void donut(UI &p, double a, double b, Color ca, Color cb) { + p.draw([=](Surface surface) { + Braille canvas(surface.rect().width, surface.rect().height); + double total = std::max(1., a + b), cx = canvas.w / 2., cy = canvas.h / 2., + outer = std::min(canvas.w / 2., canvas.h / 2.) - 1, + inner = outer * .55, angle = -std::acos(-1.) / 2; + std::vector colors(canvas.dots.size()); + double vals[] = {a, b}; + Color tint[] = {ca, cb}; + for (int i = 0; i < 2; i++) { + double sweep = std::max(0., vals[i]) / total * std::acos(-1.) * 2; + int steps = std::max(8, iround(sweep * outer * 3)); + for (int step = 0; step <= steps; step++) { + double a = angle + sweep * step / steps; + for (double r = inner; r <= outer; r += .4) { + int x = iround(cx + std::cos(a) * r), + y = iround(cy + std::sin(a) * r * .9); + canvas.pixel(x, y); + if (x >= 0 && y >= 0 && x < canvas.w && y < canvas.h) + colors[std::size_t(y / 4) * canvas.cols + x / 2] = tint[i]; + } + } + angle += sweep; + } + for (int y = 0; y < canvas.rows; y++) + for (int x = 0; x < canvas.cols; x++) { + auto index = std::size_t(y) * canvas.cols + x; + if (canvas.dots[index]) + surface.set(x, y, 0x2800 + canvas.dots[index], + Style().foreground(colors[index] ? colors[index] + : theme(surface).muted)); + } + }); +} +static void list(UI &p, State &s, std::string id, + std::vector items, std::string bullet = "", + bool selected = true, bool scrollbar = false) { + auto regions = p.regions; + auto *pane = &s.panes[id]; + p.draw([=](Surface surface) { + auto t = theme(surface); + int h = surface.rect().height, w = surface.rect().width - int(scrollbar); + pane->total = int(items.size()); + pane->capacity = h; + pane->offset = std::clamp(pane->offset, 0, std::max(0, pane->total - h)); + if (selected && h > 0) { + if (pane->selected < pane->offset) + pane->offset = pane->selected; + if (pane->selected >= pane->offset + h) + pane->offset = pane->selected - h + 1; + } + for (int y = 0; y < h && pane->offset + y < pane->total; y++) { + int i = pane->offset + y; + bool active = selected && i == pane->selected; + std::optional bg; + if (active) + bg = t.selection; + auto fg = active ? t.selection_text : i == 0 ? t.primary : t.foreground; + if (bg) + surface.sub({0, y, w, 1}).fill(' ', Style().background(*bg)); + text(surface, 0, y, + fit((bullet.empty() ? "" : bullet + " ") + items[i], w), fg, + active ? HQ_BOLD : 0, bg); + } + if (scrollbar) + draw_scrollbar(surface, surface.rect().width - 1, 0, h, pane->total, + pane->offset); + if (regions) + regions->push_back({surface.rect(), id, 0}); + }); +} +static void graphics(UI &ui, State &s) { + auto t = ui.t(); + double time = s.data["time"].n(); + auto wave = [](double phase, double freq) { + std::vector a; + for (int i = 0; i < 240; i++) + a.push_back(std::sin(i / freq + phase) * 50 + 50); + return a; + }; + auto a = wave(time / 3, 9), b = wave(time / 3 + 2, 5), c = wave(time / 2, 17); + ui.row(fr(), 1, [=](UI &r) { + r.col(fr(), 1, [=](UI &p) { + p.panel("Braille (2×4 pixels per cell)", [=](UI &g) { + Graph o; + o.series = {{a, t.accent, "", true}}; + o.max = 100; + o.grid = true; + g.graph(o); + }); + p.panel("Block elements", [=](UI &g) { + Graph o; + o.series = {{a, 0, "", false}}; + o.max = 100; + o.mode = "block"; + o.colors.assign(t.heat, t.heat + t.heat_count); + g.graph(o); + }); + p.panel("ASCII fallback", [=](UI &g) { + Graph o; + o.series = {{a, t.foreground, "", false}}; + o.max = 100; + o.mode = "ascii"; + g.graph(o); + }); + }); + r.col(fr(), 1, [=](UI &p) { + p.panel("Multi-series", [=](UI &g) { + Graph o; + o.series = {{a, t.primary, "alpha", false}, + {b, t.success, "beta", false}, + {c, t.secondary, "gamma", false}}; + o.max = 100; + o.axis = true; + o.legend = true; + g.graph(o); + }); + p.panel("Gradients", [=](UI &g) { + g.draw([=](Surface surface) { + for (int y = 0; y < surface.rect().height; y++) + for (int x = 0; x < surface.rect().width; x++) + surface.set(x, y, 0x2588, + Style().foreground(hq_gradient( + t.heat, t.heat_count, + surface.rect().width <= 1 + ? 0 + : double(x) / (surface.rect().width - 1)))); + }); + }); + p.panel("Raw Braille canvas", [=](UI &g) { + g.draw([=](Surface surface) { + Braille canvas(surface.rect().width, surface.rect().height); + double cx = canvas.w / 2., cy = canvas.h / 2., + radius = std::min(cx, cy) - 2; + int x = iround( + std::min(std::abs(radius), double(canvas.w + canvas.h))), + y = 0, err = 1 - x; + while (x >= y) { + canvas.pixel(cx + x, cy + y); + canvas.pixel(cx + y, cy + x); + canvas.pixel(cx - y, cy + x); + canvas.pixel(cx - x, cy + y); + canvas.pixel(cx - x, cy - y); + canvas.pixel(cx - y, cy - x); + canvas.pixel(cx + y, cy - x); + canvas.pixel(cx + x, cy - y); + y++; + if (err < 0) + err += 2 * y + 1; + else { + x--; + err += 2 * (y - x) + 1; + } + } + for (int i = 0; i < 12; i++) { + double a = double(i) / 12 * std::acos(-1.) * 2 + time / 4; + canvas.line(cx, cy, cx + std::cos(a) * radius, + cy + std::sin(a) * radius * .9); + } + canvas.blit(surface, t.accent); + }); + }); + }); + }); +} +static void theme_screen(UI &ui, State &s) { + ui.label("Theme " + std::to_string(s.theme_index + 1) + "/9: " + ui.t().name + + " ←/→ or F2 to change"); + ui.spacer(cells(1)); + ui.col(fr(), 1, [&](UI &grid) { + for (int row = 0; row < 3; row++) + grid.row(fr(), 1, [&, row](UI &r) { + for (int column = 0; column < 3; column++) { + int i = row * 3 + column; + auto e = *hq_theme_named(themes[i]); + r.panel( + e.name, + [&, e](UI &p) { + p.row(cells(1), 1, [=](UI &r) { + badge(r, "primary", e.primary, 10); + badge(r, "ok", e.success, 5); + badge(r, "warn", e.warning, 7); + badge(r, "err", e.danger, 6); + r.spacer(); + }); + Meter m; + m.value = .72; + m.label = "cpu"; + m.segmented = false; + m.background = e.background; + p.meter(m); + auto g = graph(s.data.path("cpu.history"), e.graph[0], 100); + g.background = e.background; + p.graph(g); + p.draw( + [=](Surface surface) { + for (std::size_t i = 0; i < e.graph_count; i++) + for (int x = 0; x < 3; x++) + surface.set(int(i) * 4 + x, 0, 0x2588, + Style() + .foreground(e.graph[i]) + .background(e.background)); + }, + cells(1)); + }, + fr(), "", i == s.theme_index ? e.border_focused : e.border, + e.background); + } + }); + }); +} +static void input_screen(UI &ui, State &s) { + auto t = ui.t(); + ui.row(fr(), 1, [&, t](UI &r) { + r.panel("Last Events", [&, t](UI &p) { + p.keys({kv("Key", s.last_key, t.accent), + kv("Mouse", s.last_mouse, t.primary)}); + p.spacer(cells(1)); + p.divider("history"); + auto history = s.key_log; + std::reverse(history.begin(), history.end()); + list(p, s, "input.history", history, "", false); + }); + r.panel("Try it", [&, t](UI &p) { + p.text("Press any key — modifiers are normalized."); + p.label("Arrows, Function keys, Ctrl/Alt/Shift combinations,"); + p.label("paste, focus, mouse move, click, drag and scroll."); + p.spacer(cells(1)); + p.divider("focusable controls"); + p.spacer(cells(1)); + p.row(cells(1), 2, [&, t](UI &r) { + button(r, "Button A", t.primary, 12, "button:A"); + button(r, "Button B", t.success, 12, "button:B"); + checkbox(r, s, "Check", 12); + r.spacer(); + }); + p.spacer(cells(1)); + p.label("Tab / Shift+Tab moves focus. Enter activates."); + p.spacer(); + p.keys({kv("Mouse tracking", "on"), kv("Bracketed paste", "on"), + kv("Focus events", "on")}); + }); + }); +} +static void stress(UI &ui, State &s) { + auto t = ui.t(); + ui.row(cells(3), 1, [&, t](UI &r) { + std::vector titles = {"Render", "Changed cells", "Bytes/frame", + "FPS"}, + values = {fixed(s.render_ms, 2) + " ms/frame", + std::to_string(s.changed_cells), + std::to_string(s.output_bytes), + fixed(s.fps, 1)}; + Color colors[] = {t.success, t.warning, t.primary, t.accent}; + for (int i = 0; i < 4; i++) { + auto value = values[i]; + auto color = colors[i]; + r.panel(titles[i], [=](UI &p) { p.text(value, color); }); + } + }); + double time = s.data["time"].n(); + ui.panel("Full-screen churn", [=](UI &p) { + p.draw([=](Surface surface) { + uint32_t chars[] = {0x2596, 0x2597, 0x2598, 0x2599, 0x259a, + 0x259b, 0x259c, 0x259d, 0x259e, 0x259f, + 0x2588, 0x2593, 0x2592, 0x2591}; + for (int y = 0; y < surface.rect().height; y++) + for (int x = 0; x < surface.rect().width; x++) { + double n = + ((std::sin(x / 6. + time) + std::cos(y / 4. - time)) / 2 + 1) / 2; + surface.set( + x, y, chars[std::clamp(int(std::floor(n * 13)), 0, 13)], + Style().foreground(hq_gradient(t.graph, t.graph_count, n))); + } + }); + }); +} +static void components(UI &ui, State &s) { + auto t = ui.t(); + const auto &c = s.data["cpu"], &m = s.data["memory"], &n = s.data["network"]; + ui.row(fr(), 1, [&, t](UI &r) { + r.col(fr(), 1, [&, t](UI &left) { + left.panel( + "Buttons & Inputs", + [&, t](UI &p) { + p.row(cells(1), 1, [&, t](UI &r) { + button(r, "Primary", t.primary, 11, "button:Primary"); + button(r, "Success", t.success, 11, "button:Success"); + button(r, "Warning", t.warning, 11, "button:Warning"); + button(r, "Danger", t.danger, 10, "button:Danger"); + r.spacer(); + }); + p.spacer(cells(1)); + p.row(cells(1), 2, [&, t](UI &r) { + auto regions = r.regions; + r.draw( + [&, t, regions](Surface surface) { + const char *options[] = {"Dark", "Dracula", "Nord", + "Tokyo Night"}; + auto &th = theme(surface); + auto bg = + hq_mix(th.surface, + hq_rgb(th.dark ? 255 : 0, th.dark ? 255 : 0, + th.dark ? 255 : 0), + .05); + text( + surface, 0, 0, + fit(" " + std::string(options[s.select_index % 4]), 18), + th.foreground, 0, bg); + text(surface, 18, 0, s.select_open ? " ▴" : " ▾", th.border, + 0, bg); + if (regions) + regions->push_back({surface.rect(), "select", 0}); + }, + cells(20)); + checkbox(r, s, "Toggle", 12, true); + checkbox(r, s, "Checkbox", 14); + r.spacer(); + }); + p.spacer(cells(1)); + p.draw( + [&, t](Surface surface) { + auto regions = p.regions; + (void)regions; + std::string value = + s.input.empty() ? "type to filter…" : s.input; + text(surface, 0, 0, "Search", t.muted); + auto field = surface.sub( + {7, 0, std::max(0, surface.rect().width - 7), 1}); + auto bg = hq_mix(t.surface, + hq_rgb(t.dark ? 255 : 0, t.dark ? 255 : 0, + t.dark ? 255 : 0), + .1); + field.fill(' ', Style().background(bg)); + text(field, 1, 0, + fit(value, std::min(std::max(0, field.rect().width - 2), + int(width(value)))), + s.input.empty() ? t.muted : t.foreground, 0, bg); + if (field.rect().width > 0) { + auto cursor = field.rect(); + cursor.x += std::min(field.rect().width - 1, + 1 + int(width(s.input))); + cursor.width = 1; + cursor.height = 1; + hq_buffer_style( + field.native().buffer, cursor, + Style().foreground(t.background).background(t.cursor)); + } + }, + cells(1)); + p.spacer(cells(1)); + Meter slider; + slider.value = s.slider; + slider.label = "Slider"; + slider.color = t.primary; + slider.segmented = false; + p.meter(slider); + Meter progress; + progress.value = 37. / 120; + progress.label = "Progress"; + progress.readout = "37/120"; + progress.color = t.primary; + progress.segmented = false; + p.meter(progress); + }, + cells(13)); + left.panel("Table Widget", [&, t](UI &p) { + static const char *files[][4] = { + {"src", "4.2 KB", "dir", "2m ago"}, + {"test", "1.1 KB", "dir", "5m ago"}, + {"package.json", "1.2 KB", "file", "10m ago"}, + {"README.md", "3.4 KB", "file", "1h ago"}, + {"bun.lockb", "12 KB", "file", "1h ago"}}; + std::vector rows; + for (auto &v : files) + rows.push_back(Json::Object{{"name", v[0]}, + {"size", v[1]}, + {"type", v[2]}, + {"modified", v[3]}}); + table(p, s, "components.files", rows, + {dc("name", "Name", -1, 10, t.primary), + dc("size", "Size", 9, 1, 0, true), dc("type", "Type", 6), + dc("modified", "Modified", 10, 1, t.muted, true)}, + true, true, false); + }); + left.panel( + "Log Viewer", + [&](UI &p) { + p.log(logs(s.data["logs"]), &s.panes["components.logs"], + "components.logs"); + }, + cells(11)); + }); + r.col(fr(), 1, [&, t](UI &right) { + right.panel( + "Process Tree", + [&, t](UI &p) { + p.row(cells(1), 0, [=](UI &r) { + r.text("Name", t.muted, HQ_LEFT, automatic(1), HQ_BOLD); + r.text("CPU% MEM%", t.muted, HQ_RIGHT, automatic(1), HQ_BOLD); + }); + auto pane = &s.panes["components.tree"]; + auto regions = p.regions; + p.draw([=](Surface surface) { + const char *names[] = {"systemd", "bash", "bun", + "bun:worker", "bun:worker", "node", + "node:worker", "postgres"}; + const char *prefix[] = {"└─ ", " ├─ ", " ├─ ", + " │ ├─ ", " │ └─ ", " ├─ ", + " │ └─ ", " └─ "}; + const char *cpu[] = {"1.3", "0.1", "32.8", "12.4", + "8.7", "18.1", "6.1", "6.7"}, + *mem[] = {"0.1", "0.2", "4.2", "1.8", + "1.3", "2.1", "0.8", "1.8"}; + int w = surface.rect().width, h = surface.rect().height, + lw = std::max(0, w - 14); + pane->total = 8; + pane->capacity = h; + pane->offset = std::clamp(pane->offset, 0, std::max(0, 8 - h)); + if (h > 0) { + if (pane->selected < pane->offset) + pane->offset = pane->selected; + if (pane->selected >= pane->offset + h) + pane->offset = pane->selected - h + 1; + } + for (int y = 0; y < h && pane->offset + y < 8; y++) { + int i = pane->offset + y; + bool selected = i == pane->selected; + std::optional bg; + if (selected) { + bg = t.selection; + surface.sub({0, y, w, 1}).fill(' ', Style().background(*bg)); + } + int px = std::min(lw, int(width(prefix[i]))); + text(surface, 0, y, fit(prefix[i], px), + hq_mix(t.border, t.foreground, .15), 0, bg); + text(surface, px, y, + fit(names[i], + std::min(std::max(0, lw - px), int(width(names[i])))), + selected ? t.selection_text : t.foreground, + selected ? HQ_BOLD : 0, bg); + text(surface, lw, y, fit(cpu[i], 6, HQ_RIGHT), + selected ? t.selection_text : t.foreground, 0, bg); + text(surface, lw + 7, y, fit(mem[i], 6, HQ_RIGHT), + selected ? t.selection_text : t.foreground, 0, bg); + } + if (regions) + regions->push_back({surface.rect(), "components.tree", 0}); + }); + }, + cells(13)); + right.panel( + "Sparklines & Gauges", + [&, t](UI &p) { + spark(p, "CPU ", c["history"], percent(c["total"].n()), t.success); + spark(p, "Mem ", m["history"], + percent(m["used"].n() / std::max(1., m["total"].n())), + t.warning); + spark(p, "Net ", n["downHistory"], bytes(n["downRate"].n()) + "/s", + t.primary); + p.spacer(cells(1)); + p.row(fr(), 2, [&, t](UI &r) { + r.gauge(c["total"].n(), percent(c["total"].n())); + donut(r, m["used"].n(), m["available"].n(), t.primary, t.warning); + }); + }, + cells(12)); + right.panel("Lists & Badges", [&, t](UI &p) { + p.row(cells(1), 1, [=](UI &r) { + badge(r, "active", t.success, 10); + badge(r, "idle", t.warning, 8, "subtle"); + badge(r, "failed", t.danger, 10, "outline"); + r.spacer(); + }); + p.spacer(cells(1)); + list(p, s, "components.list", + {"apps/demo", "packages/hqtui", "apps/web", "docs"}, "▸", true, + true); + }); + }); + }); +} +void showcase(UI &ui, State &s) { + switch (s.screen) { + case 5: + components(ui, s); + break; + case 6: + graphics(ui, s); + break; + case 7: + theme_screen(ui, s); + break; + case 8: + input_screen(ui, s); + break; + case 9: + stress(ui, s); + break; + default: + break; + } +} +} // namespace demo diff --git a/ports/cpp/demo/telemetry.cpp b/ports/cpp/demo/telemetry.cpp new file mode 100644 index 0000000..f88fe6e --- /dev/null +++ b/ports/cpp/demo/telemetry.cpp @@ -0,0 +1,541 @@ +#include "model.hpp" +namespace demo { +static std::string rate(double v) { + return v >= 1e6 ? fixed(v / 1e6, 1) + "M/s" + : v >= 1000 ? fixed(v / 1000, 1) + "K/s" + : fixed(v) + "/s"; +} +static std::string commas(double v) { + auto s = fixed(v); + for (int i = int(s.size()) - 3; i > 0; i -= 3) + s.insert(i, ","); + return s; +} +static Color status_color(const hq_theme &t, std::string c) { + return c == "1xx" ? t.secondary + : c == "2xx" ? t.success + : c == "3xx" ? t.accent + : c == "4xx" ? t.warning + : c == "5xx" ? t.danger + : t.muted; +} +static void traffic(UI &ui, State &s) { + if (s.real) + ui.label("Protocol/direction: port-based estimates; HTTP rate: estimated " + "from log growth"); + auto t = ui.t(); + const auto &d = s.data["telemetry"], &net = d["net"], &rates = net["rates"], + &http = d["http"]; + ui.row(cells(13), 1, [&, t](UI &r) { + r.panel( + "Protocols", + [&, t](UI &p) { + auto &data = d["protocols"].array(); + if (data.empty()) { + p.label("No sockets visible."); + return; + } + double maximum = 1; + for (auto &v : data) + maximum = std::max(maximum, v["total"].n()); + std::vector items; + int i = 0; + for (auto &b : data) { + if (i == 9) + break; + Meter m; + m.label = b["protocol"].s(); + m.value = b["total"].n() / maximum; + m.color = hq_series(&t, i++); + m.readout = b["total"].s(); + m.label_width = 13; + m.value_width = 5; + m.segmented = false; + items.push_back(m); + } + p.meters(items); + }, + fr(), + d["inboundConnections"].s() + " in / " + d["outboundConnections"].s() + + " out", + t.accent); + r.panel( + "TCP", + [&, t](UI &p) { + p.row(cells(1), 0, [&, t](UI &r) { + r.text("↓ " + rate(rates["inSegs"].n()) + " seg", t.primary); + r.text("↑ " + rate(rates["outSegs"].n()) + " seg", t.secondary, + HQ_RIGHT); + }); + p.graph(multi(d["netInHistory"], d["netOutHistory"], t.primary, + t.secondary)); + p.divider(); + p.keys({kv("Established", net["tcpEstablished"].s(), t.success), + kv("Opens in/out", + rate(rates["passiveOpens"].n()) + " / " + + rate(rates["activeOpens"].n()), + t.accent), + kv("Resets sent", commas(net["tcpOutRsts"].n()), t.muted)}); + }, + fr(.9), "", t.primary); + auto color = net["retransRatio"].n() > .02 ? t.danger : t.success; + r.panel( + "Retransmits", + [&, t, color](UI &p) { + p.text(fixed(ratio(net["retransRatio"].n()) * 100, 2) + "%", color, + HQ_LEFT, cells(1), HQ_BOLD); + p.label("of outbound segments"); + p.graph(graph(d["retransHistory"], t.danger)); + p.keys( + {kv("UDP in/out", + rate(rates["udpIn"].n()) + " / " + rate(rates["udpOut"].n()), + t.muted), + kv("ICMP", + net["icmpInMsgs"].s() + " / " + net["icmpOutMsgs"].s(), + t.muted)}); + }, + fr(.7), "", color); + }); + ui.row(fr(), 1, [&, t](UI &r) { + r.col(fr(), 1, [&, t](UI &c) { + c.panel( + "HTTP", + [&, t](UI &p) { + if (http.null()) { + p.label("No readable HTTP access log."); + p.label("nginx, apache, httpd and caddy logs are"); + p.label("root/adm readable — run with sudo to track requests."); + return; + } + p.row(cells(1), 0, [&, t](UI &r) { + r.text(http["source"].s(), t.muted); + r.text(http["upgrades"].s() + " upgrades (ws)", t.secondary, + HQ_RIGHT); + }); + p.graph(graph(http["history"], t.success), cells(6)); + p.divider("status"); + double maximum = 1; + for (auto &b : http["statusClasses"].array()) + maximum = std::max(maximum, b["count"].n()); + std::vector items; + for (auto &b : http["statusClasses"].array()) { + Meter m; + m.label = b["class"].s(); + m.value = b["count"].n() / maximum; + m.color = status_color(t, m.label); + m.readout = b["count"].s(); + m.label_width = 5; + m.value_width = 7; + m.segmented = false; + items.push_back(m); + } + p.meters(items); + p.divider("top paths"); + table(p, s, "traffic.paths", http["topPaths"].array(), + {dc("path", "Path", -1, 20, t.primary), + dc("count", "Hits", 7, 1, t.accent, true)}, + false, false); + }, + fr(), + http.null() ? "no access log" + : fixed(http["requestsPerSecond"].n(), 1) + " req/s", + t.success); + }); + r.col(fr(.85), 1, [&, t](UI &c) { + c.panel( + "SSH Activity", + [&, t](UI &p) { + auto rows = d["ssh"].array(); + if (rows.empty()) { + p.label("No sshd events in the journal."); + return; + } + std::reverse(rows.begin(), rows.end()); + table(p, s, "traffic.ssh", rows, + {dc("time", "Time", 9, 1, t.muted), + dc("action", "Action", 11, 1, 0, false, {}, + [=](const Json &d) { + return d["action"].s() == "accepted" ? t.success + : d["action"].s() == "disconnect" ? t.muted + : t.danger; + }), + dc("user", "User", 12, 1, t.primary), + dc("from", "From", -1, 14, t.accent), + dc("method", "Method", 10, 1, t.muted)}, + true); + }, + fr(), std::to_string(d["ssh"].array().size()), t.warning); + c.panel( + "Top Remote Hosts", + [&, t](UI &p) { + if (d["remotes"].array().empty()) { + p.label("No remote peers."); + return; + } + table(p, s, "traffic.remotes", d["remotes"].array(), + {dc("host", "Host", -1, 16, t.accent), + dc("connections", "Conns", 6, 1, t.success, true), + dc("protocols", "Protocols", -1, 12, t.muted)}, + true); + }, + cells(10), "", t.secondary); + }); + }); + if (!http.null() && !http["recent"].array().empty()) + ui.panel( + "Recent Requests", + [&, t](UI &p) { + table(p, s, "traffic.requests", http["recent"].array(), + {dc("time", "Time", 9, 1, t.muted), + dc("method", "Method", 7, 1, t.secondary), + dc("path", "Path", -1, 24, t.primary), + dc("status", "Status", 7, 1, 0, true, {}, + [=](const Json &d) { + return status_color(t, + d["status"].s().substr(0, 1) + "xx"); + }), + dc("client", "Client", 16, 1, t.accent), + dc("bytes", "Bytes", 9, 1, t.muted, true)}, + true); + }, + cells(10), "", t.primary); +} +static void sessions(UI &ui, State &s) { + auto t = ui.t(); + const auto &d = s.data["telemetry"]; + ui.row(cells(9), 1, [&, t](UI &r) { + r.panel( + "Active Sessions", + [&, t](UI &p) { + if (d["sessions"].array().empty()) { + p.label("No interactive sessions."); + p.label("(`who` reports nothing on this host)"); + return; + } + table(p, s, "sessions.active", d["sessions"].array(), + {dc("user", "User", 12, 1, t.primary), dc("tty", "TTY", 10), + dc("from", "From", -1, 12, t.accent), + dc("loginAt", "Login", 14, 1, t.muted), + dc("idle", "Idle", 8, 1, 0, true)}); + }, + fr(), std::to_string(d["sessions"].array().size()), t.success); + r.panel( + "Process States", + [&, t](UI &p) { + auto states = d["states"]; + const char *keys[] = {"running", "sleeping", "stopped", "zombie"}, + *labels[] = {"run ", "slp ", "stop", "zomb"}; + Color colors[] = {t.success, t.primary, t.warning, t.danger}; + for (int i = 0; i < 4; i++) { + Meter m; + m.value = states[keys[i]].n() / std::max(1., states["total"].n()); + m.label = labels[i]; + m.readout = states[keys[i]].s(); + m.color = colors[i]; + m.segmented = false; + p.meter(m); + } + p.spacer(cells(1)); + p.keys({kv("Total", states["total"].s(), t.accent)}); + }, + cells(34), "", t.primary); + }); + ui.row(fr(), 1, [&, t](UI &r) { + r.panel( + "Recent Logins", + [&, t](UI &p) { + if (d["logins"].array().empty()) { + p.label("No login history available."); + return; + } + table(p, s, "sessions.logins", d["logins"].array(), + {dc("user", "User", 12, 1, t.primary), + dc("tty", "TTY", 12, 1, t.muted), + dc("from", "From", -1, 14, t.accent), + dc("when", "When", -1, 16, t.muted), + dc("status", "Status", 8, 1, 0, false, {}, + [=](const Json &d) { + return d["status"].s() == "still" ? t.success : t.muted; + })}, + true); + }, + fr(), std::to_string(d["logins"].array().size()) + " from wtmp", + t.accent); + r.col(fr(.8), 1, [&, t](UI &c) { + c.panel( + "Failed Logins", + [&, t](UI &p) { + if (d["failedLogins"].array().empty()) { + p.label("None recorded."); + p.label("(btmp is usually root-only)"); + return; + } + table(p, s, "sessions.failed", d["failedLogins"].array(), + {dc("user", "User", 12, 1, t.danger), + dc("from", "From", -1, 12), + dc("when", "When", -1, 14, t.muted)}); + }, + fr(), "", t.danger); + c.panel( + "Session History", + [&, t](UI &p) { + p.label("concurrent sessions"); + p.graph(graph(d["sessionHistory"], t.success)); + }, + cells(8), "", t.secondary); + }); + }); +} +static void network(UI &ui, State &s) { + auto t = ui.t(); + const auto &d = s.data["telemetry"]; + auto shown = d["interfaces"].array(); + std::vector active; + for (auto &i : shown) + if (i["rxTotal"].n() > 0 || i["state"].s() == "up") + active.push_back(i); + if (!active.empty()) + shown = active; + if (shown.size() > 3) + shown.resize(3); + ui.row(cells(13), 1, [&, t, shown](UI &r) { + if (shown.empty()) { + r.panel("Interfaces", [](UI &p) { p.label("No interfaces reported."); }); + return; + } + for (std::size_t i = 0; i < shown.size(); i++) { + auto iface = shown[i]; + Color color = i == 0 ? t.primary : i == 1 ? t.success : t.secondary; + r.panel( + iface["name"].s() + " (" + iface["state"].s() + ")", + [=](UI &p) { + p.row(cells(1), 0, [=](UI &r) { + r.text("↓ " + byte_rate(iface["rxRate"].n()), t.primary); + r.text("↑ " + byte_rate(iface["txRate"].n()), t.secondary, + HQ_RIGHT); + }); + p.graph(multi(iface["rxHistory"], iface["txHistory"], t.primary, + t.secondary)); + p.divider(); + p.keys({kv("RX total", bytes(iface["rxTotal"].n()), t.primary), + kv("TX total", bytes(iface["txTotal"].n()), t.secondary), + kv("MAC", iface["mac"].s(), t.muted), + kv("MTU / err / drop", + iface["mtu"].s() + " / " + iface["errors"].s() + " / " + + iface["drops"].s(), + t.muted)}); + }, + fr(), iface["ip"].s(""), color); + } + }); + ui.row(fr(), 1, [&, t](UI &r) { + r.panel( + "Connections", + [&, t](UI &p) { + if (d["connections"].array().empty()) { + p.label("No connections visible (`ss` unavailable)."); + return; + } + table(p, s, "network.connections", d["connections"].array(), + {dc("proto", "Proto", 6, 1, t.muted), + dc("local", "Local", -1, 18), + dc("remote", "Remote", -1, 18, t.accent), + dc("state", "State", 10, 1, t.success), + dc("process", "Process", -1, 12, t.primary)}, + true); + }, + fr(), std::to_string(d["connections"].array().size()) + " open", + t.accent); + r.col(fr(.7), 1, [&, t](UI &c) { + c.panel( + "Listening Ports", + [&, t](UI &p) { + table(p, s, "network.listeners", d["listeners"].array(), + {dc("proto", "Proto", 6, 1, t.muted), + dc("port", "Port", 7, 1, t.warning, true), + dc("address", "Address", -1, 10, t.muted), + dc("process", "Process", -1, 10, t.primary)}, + true); + }, + fr(), std::to_string(d["listeners"].array().size()), t.warning); + c.panel( + "Open Connections", + [&, t](UI &p) { p.graph(graph(d["connectionHistory"], t.accent)); }, + cells(6), "", t.secondary); + }); + }); +} +static void services(UI &ui, State &s) { + auto t = ui.t(); + const auto &d = s.data["telemetry"]; + int failed = 0; + for (auto &v : d["services"].array()) + failed += v["active"].s() == "failed"; + ui.row(fr(), 1, [&, t, failed](UI &r) { + r.panel( + "Services", + [&, t](UI &p) { + if (d["services"].array().empty()) { + p.label("systemd not available on this host."); + return; + } + table(p, s, "services.units", d["services"].array(), + {dc("name", "Unit", -1, 18, t.primary), + dc("active", "Active", 10, 1, 0, false, {}, + [=](const Json &d) { + return d["active"].s() == "failed" ? t.danger + : d["active"].s() == "active" ? t.success + : t.muted; + }), + dc("sub", "Sub", 10, 1, t.muted), + dc("description", "Description", -1, 16, t.muted)}, + true); + }, + fr(), + failed ? std::to_string(failed) + " failed" + : std::to_string(d["services"].array().size()) + " units", + failed ? t.danger : t.success, {}, failed ? t.danger : t.muted); + r.col(fr(.85), 1, [&, t](UI &c) { + c.panel( + "Kernel", + [&, t](UI &p) { + auto k = d["kernel"]; + auto krate = [](double v) { + return v >= 1000 ? fixed(v / 1000, 1) + "K/s" : fixed(v) + "/s"; + }; + p.keys({kv("Context switches", krate(k["contextSwitchRate"].n()), + t.accent), + kv("Interrupts", krate(k["interruptRate"].n()), t.accent), + kv("Forks", krate(k["forkRate"].n()), t.accent), + kv("Procs running", k["procsRunning"].s(), t.success), + kv("Procs blocked", k["procsBlocked"].s(), + k["procsBlocked"].n() ? t.warning : t.muted), + kv("Open file descriptors", commas(k["openFiles"].n()), + t.primary), + kv("Entropy available", k["entropy"].s(), + k["entropy"].n() < 200 ? t.warning : t.success), + kv("Page in / out", + fixed(k["pageIn"].n() / 1000) + "K / " + + fixed(k["pageOut"].n() / 1000) + "K", + t.muted)}); + }, + cells(11), "", t.accent); + c.panel( + "Containers", + [&, t](UI &p) { + if (d["containers"].array().empty()) { + p.label("No running containers."); + p.label("(docker not installed or not reachable)"); + return; + } + table(p, s, "services.containers", d["containers"].array(), + {dc("name", "Name", -1, 12, t.primary), + dc("image", "Image", -1, 14, t.muted), + dc("status", "Status", -1, 12, t.success)}, + true); + }, + cells(9), "", t.primary); + c.panel( + "Hardware", + [&, t](UI &p) { + std::vector rows; + auto power = d["power"]; + if (!power.null()) { + rows.push_back(kv("Battery", + power["battery"].s() + "% (" + + power["timeRemaining"].s() + ")", + t.success)); + rows.push_back(kv("AC", + power["acConnected"].s() == "true" + ? "connected" + : "on battery", + t.muted)); + rows.push_back(kv("Draw", + power["powerDraw"].null() + ? "—" + : fixed(power["powerDraw"].n(), 1) + " W", + t.warning)); + } + for (auto &g : d["gpus"].array()) { + rows.push_back( + kv(g["name"].s(), + (g["utilization"].null() ? "—" + : percent(g["utilization"].n())) + + " · " + g["temperature"].s() + "°C", + t.accent)); + rows.push_back(kv( + "GPU memory", + (g["memoryUsed"].null() ? "—" : bytes(g["memoryUsed"].n())) + + " / " + + (g["memoryTotal"].null() ? "—" + : bytes(g["memoryTotal"].n())), + t.muted)); + } + if (rows.empty()) { + p.label("No battery or GPU telemetry on this host."); + return; + } + p.keys(rows); + }, + fr(), "", t.warning); + }); + }); + ui.panel( + "Filesystems", + [&, t](UI &p) { + if (d["filesystems"].array().empty()) { + p.label("No filesystems reported."); + return; + } + table(p, s, "services.filesystems", d["filesystems"].array(), + {dc("mount", "Mount", -1, 14, t.primary), + dc("device", "Device", -1, 12, t.muted), + dc("type", "Type", 8, 1, t.muted), + dc("size", "Size", 10, 1, 0, true, + [](const Json &d) { return bytes(d["size"].n(), 0); }), + dc("used", "Used", 10, 1, 0, true, + [](const Json &d) { return bytes(d["used"].n(), 0); }), + dc( + "pct", "Use%", 6, 1, 0, true, + [](const Json &d) { + return d["size"].n() + ? percent(d["used"].n() / d["size"].n()) + : "-"; + }, + [=](const Json &d) { + return d["size"].n() && d["used"].n() / d["size"].n() > .9 + ? t.danger + : t.warning; + }), + dc("inodes", "Inodes", 16, 1, t.muted, true, + [](const Json &d) { + return d["inodesTotal"].n() + ? percent(d["inodesUsed"].n() / + d["inodesTotal"].n()) + + " of " + + fixed(d["inodesTotal"].n() / 1e6, 1) + "M" + : "-"; + })}, + true); + }, + cells(10), "", t.secondary); +} +void telemetry(UI &ui, State &s) { + switch (s.screen) { + case 1: + traffic(ui, s); + break; + case 2: + sessions(ui, s); + break; + case 3: + network(ui, s); + break; + case 4: + services(ui, s); + break; + default: + break; + } +} +} // namespace demo diff --git a/ports/cpp/include/hqtui.hpp b/ports/cpp/include/hqtui.hpp index 73f2db1..15b21b6 100644 --- a/ports/cpp/include/hqtui.hpp +++ b/ports/cpp/include/hqtui.hpp @@ -44,6 +44,7 @@ class Surface { public: // A borrowed view: keep its Buffer and any custom theme alive. explicit Surface(hq_surface surface) noexcept : surface_(surface) {} + hq_surface native() const noexcept { return surface_; } Rect rect() const noexcept { return surface_.rect; } Surface sub(Rect local) const noexcept { return Surface(hq_surface_sub(surface_,local)); } Surface region(Rect absolute) const noexcept { return Surface(hq_surface_region(surface_,absolute)); } diff --git a/ports/cpp/include/hqtui/widgets.hpp b/ports/cpp/include/hqtui/widgets.hpp new file mode 100644 index 0000000..829b68d --- /dev/null +++ b/ports/cpp/include/hqtui/widgets.hpp @@ -0,0 +1,385 @@ +#ifndef HQTUI_WIDGETS_HPP +#define HQTUI_WIDGETS_HPP +#include +#include +#include +#include +#include +#include + +namespace hqtui { +using Paint = std::function; +inline int iround(double n) { + return std::isfinite(n) ? int(std::floor(n + .5)) : 0; +} +inline double ratio(double n) { return n > 0 ? std::min(1., n) : 0; } +inline std::string utf8(uint32_t c) { + std::string s; + if (c < 128) + s += char(c); + else if (c < 2048) { + s += char(192 | (c >> 6)); + s += char(128 | (c & 63)); + } else if (c < 65536) { + s += char(224 | (c >> 12)); + s += char(128 | ((c >> 6) & 63)); + s += char(128 | (c & 63)); + } else { + s += char(240 | (c >> 18)); + s += char(128 | ((c >> 12) & 63)); + s += char(128 | ((c >> 6) & 63)); + s += char(128 | (c & 63)); + } + return s; +} +inline std::string fit(std::string_view s, int columns, int align = HQ_LEFT, + bool ellipsis = true) { + if (columns <= 0) + return {}; + std::string out(s); + int n = int(width(s)); + if (n > columns) { + int budget = columns - (ellipsis ? 1 : 0); + std::size_t i = 0, end = 0; + int used = 0; + while (i < s.size()) { + unsigned char c = s[i]; + std::size_t len = c < 128 ? 1 : c < 224 ? 2 : c < 240 ? 3 : 4; + len = std::min(len, s.size() - i); + int w = int(width(s.substr(i, len))); + if (used + w > budget) + break; + used += w; + i += len; + end = i; + } + out = std::string(s.substr(0, end)) + (ellipsis ? "…" : ""); + n = used + (ellipsis ? 1 : 0); + } + int padding = std::max(0, columns - n), left = align == HQ_RIGHT ? padding + : align == HQ_CENTER + ? padding / 2 + : 0; + return std::string(left, ' ') + out + std::string(padding - left, ' '); +} +inline const hq_theme &theme(Surface s) { return *s.native().theme; } +inline void text(Surface s, int x, int y, std::string_view value, Color fg, + uint16_t attrs = 0, std::optional bg = {}) { + hq_text_options o{}; + o.style = Style().foreground(fg).attributes(attrs); + if (bg) + o.style = Style(o.style.fg, *bg, attrs); + s.text(x, y, std::string(value).c_str(), o); +} +inline void aligned(Surface s, int y, std::string_view value, Color fg, + int align = HQ_LEFT, uint16_t attrs = 0, + std::optional bg = {}) { + text(s, 0, y, fit(value, s.rect().width, align), fg, attrs, bg); +} +inline Constraint cells(int n, int min = 0) { + return {HQ_CELLS, double(n), min, -1, 0}; +} +inline Constraint fr(double n = 1, int min = 0) { + return {HQ_FR, n, min, -1, 0}; +} +inline Constraint automatic(int intrinsic, int min = 0) { + return {HQ_AUTO, 0., min, -1, intrinsic}; +} +struct KeyValue { + std::string label, value; + Color color = 0; +}; +struct Series { + std::vector values; + Color color = 0; + std::string label; + bool fill = false; +}; +struct Graph { + std::vector series; + double min = 0; + std::optional max; + bool axis = false, grid = false, legend = false; + std::string mode = "braille"; + std::vector colors; + std::optional background; + std::function axis_format; +}; +class Braille { +public: + int cols, rows, w, h; + std::vector dots; + Braille(int columns, int rows) + : cols(std::max(0, columns)), rows(std::max(0, rows)), w(cols * 2), + h(this->rows * 4), dots(std::size_t(cols) * this->rows) {} + void pixel(double dx, double dy) { + if (!std::isfinite(dx) || !std::isfinite(dy) || dx < -.5 || dy < -.5 || + dx >= w || dy >= h) + return; + int x = iround(dx), y = iround(dy); + if (x < 0 || y < 0 || x >= w || y >= h) + return; + constexpr int bits[4][2] = {{1, 8}, {2, 16}, {4, 32}, {64, 128}}; + dots[std::size_t(y / 4) * cols + x / 2] |= bits[y % 4][x % 2]; + } + void line(double ax, double ay, double bx, double by) { + if (!std::isfinite(ax) || !std::isfinite(ay) || !std::isfinite(bx) || + !std::isfinite(by)) + return; + // Clip pathological walks before integer conversion; ordinary short + // off-screen lines retain the reference's Bresenham rasterization. + if (std::max(std::abs(ax - bx), std::abs(ay - by)) > 100000 || + std::max({std::abs(ax), std::abs(ay), std::abs(bx), std::abs(by)}) > + 1e7) { + double dx = bx - ax, dy = by - ay, t0 = 0, t1 = 1; + if (!std::isfinite(dx) || !std::isfinite(dy)) + return; + double p[] = {-dx, dx, -dy, dy}, q[] = {ax, w - 1 - ax, ay, h - 1 - ay}; + for (int i = 0; i < 4; i++) { + if (p[i] == 0) { + if (q[i] < 0) + return; + } else { + double t = q[i] / p[i]; + if (p[i] < 0) + t0 = std::max(t0, t); + else + t1 = std::min(t1, t); + if (t0 > t1) + return; + } + } + bx = ax + t1 * dx; + by = ay + t1 * dy; + ax += t0 * dx; + ay += t0 * dy; + } + int x = iround(ax), y = iround(ay), ex = iround(bx), ey = iround(by), + dx = std::abs(ex - x), dy = -std::abs(ey - y), sx = x < ex ? 1 : -1, + sy = y < ey ? 1 : -1, err = dx + dy; + for (;;) { + pixel(x, y); + if (x == ex && y == ey) + break; + int e = 2 * err; + if (e >= dy) { + err += dy; + x += sx; + } + if (e <= dx) { + err += dx; + y += sy; + } + } + } + void blit(Surface s, Color color, std::optional bg = {}) const { + for (int y = 0; y < rows; y++) + for (int x = 0; x < cols; x++) + if (auto d = dots[std::size_t(y) * cols + x]) { + auto st = Style().foreground(color); + if (bg) + st = st.background(*bg); + s.set(x, y, 0x2800 + d, st); + } + } +}; +struct Meter { + double value = 0; + std::string label, readout; + Color color = 0; + int label_width = -1, value_width = -1; + bool show_value = true, segmented = true; + std::optional background; +}; +void draw_meter(Surface, const Meter &); +void draw_graph(Surface, const Graph &); +void draw_gauge(Surface, double, std::string_view); +void draw_keys(Surface, const std::vector &, bool spread = true); +void draw_scrollbar(Surface, int, int, int, int, int); +struct Column { + std::string title; + int width = -1, min = 1; + Color color = 0; + int align = HQ_LEFT; +}; +struct TableRow { + std::vector cells; + std::vector colors; +}; +struct Pane { + int selected = 0, offset = 0, total = 0, capacity = 0; + bool log = false; + void move(int delta) { + if (log) + offset = std::clamp(offset - delta, 0, std::max(0, total - 1)); + else + selected = std::clamp(selected + delta, 0, std::max(0, total - 1)); + } +}; +struct Region { + Rect rect; + std::string id; + int header = 0; +}; +struct Table { + std::vector columns; + std::vector rows; + Pane *pane = nullptr; + bool zebra = false, header = true, scrollbar = true; +}; +void draw_table(Surface, const Table &); +struct LogEntry { + std::string time, level, message, meta; +}; +void draw_log(Surface, const std::vector &, Pane *); + +// A deferred builder: callbacks receive their final viewport, so tables follow +// selection using the space actually rendered. All rendering stays in C/C++. +class UI { + struct Node { + Constraint size; + Paint draw; + }; + Surface surface_; + bool horizontal_; + int gap_; + std::vector nodes_; + +public: + std::vector *regions; + UI(Surface s, bool horizontal = false, int gap = 0, + std::vector *regions = nullptr) + : surface_(s), horizontal_(horizontal), gap_(gap), regions(regions) {} + int width() const { return surface_.rect().width; } + int height() const { return surface_.rect().height; } + const hq_theme &t() const { return theme(surface_); } + void draw(Paint fn, Constraint size = fr()) { + nodes_.push_back({size, std::move(fn)}); + } + void flush() { + if (width() <= 0 || height() <= 0 || nodes_.empty()) + return; + std::vector sizes; + for (auto &n : nodes_) + sizes.push_back(n.size); + std::vector rects(nodes_.size()); + if (!hq_stack(surface_.rect(), sizes.data(), sizes.size(), horizontal_, + gap_, rects.data())) + throw std::runtime_error("hqtui: invalid layout"); + for (std::size_t i = 0; i < nodes_.size(); i++) + if (rects[i].width > 0 && rects[i].height > 0) + nodes_[i].draw(surface_.region(rects[i])); + } + void group(Constraint size, int gap, bool horizontal, + std::function body) { + auto regions_ = regions; + draw( + [=](Surface s) { + UI p(s, horizontal, gap, regions_); + body(p); + p.flush(); + }, + size); + } + void row(Constraint size, int gap, std::function body) { + group(size, gap, true, std::move(body)); + } + void col(Constraint size, int gap, std::function body) { + group(size, gap, false, std::move(body)); + } + void panel(std::string title, std::function body, + Constraint size = fr(), std::string subtitle = {}, + Color border = 0, std::optional bg = {}, + Color subtitle_color = 0) { + auto regions_ = regions; + draw( + [=](Surface s) { + hq_box_options o{}; + o.title = title.empty() ? nullptr : title.c_str(); + o.subtitle = subtitle.empty() ? nullptr : subtitle.c_str(); + if (border) + o.border_style = Style().foreground(border); + if (subtitle_color) + o.subtitle_style = Style().foreground(subtitle_color); + if (bg) { + o.has_background = 1; + o.background = *bg; + } + auto inner = s.box(o); + inner = inner.sub( + {1, 0, std::max(0, inner.rect().width - 2), inner.rect().height}); + UI p(inner, false, 0, regions_); + body(p); + p.flush(); + }, + size); + } + void spacer(Constraint size = fr()) { + draw([](Surface) {}, size); + } + void text(std::string value, Color color = 0, int align = HQ_LEFT, + Constraint size = automatic(1), uint16_t attrs = 0) { + if (size.kind == HQ_AUTO) + size = horizontal_ ? fr() : cells(1); + draw( + [=](Surface s) { + aligned(s, 0, value, color ? color : theme(s).foreground, align, + attrs); + }, + size); + } + void label(std::string value) { text(std::move(value), t().muted); } + void divider(std::string label = {}) { + draw( + [=](Surface s) { + for (int x = 0; x < s.rect().width; x++) + s.set(x, 0, 0x2500, Style().foreground(theme(s).border)); + if (!label.empty()) + hqtui::text(s, 1, 0, " " + label + " ", theme(s).muted); + }, + cells(1)); + } + void keys(std::vector rows, bool spread = true) { + auto size = horizontal_ ? fr() : cells(int(rows.size())); + draw([=](Surface s) { draw_keys(s, rows, spread); }, size); + } + void meter(Meter o) { + draw([=](Surface s) { draw_meter(s, o); }, cells(1)); + } + void meters(std::vector items, int columns = 1) { + draw( + [=](Surface s) { + int count = int(items.size()), rows = (count + columns - 1) / columns, + cw = (s.rect().width - 2 * (columns - 1)) / columns; + for (int i = 0; i < count && rows; i++) + draw_meter( + s.sub({(i / rows) * (cw + 2), i % rows, std::max(0, cw), 1}), + items[i]); + }, + cells((int(items.size()) + columns - 1) / columns)); + } + void graph(Graph graph, Constraint size = fr()) { + draw([=](Surface s) { draw_graph(s, graph); }, size); + } + void gauge(double value, std::string label) { + draw([=](Surface s) { draw_gauge(s, value, label); }); + } + void table(Table data, std::string id = {}) { + auto regions_ = regions; + draw([=](Surface s) { + draw_table(s, data); + if (regions_ && !id.empty()) + regions_->push_back({s.rect(), id, int(data.header)}); + }); + } + void log(std::vector entries, Pane *pane, std::string id) { + auto regions_ = regions; + draw([=](Surface s) { + draw_log(s, entries, pane); + if (regions_) + regions_->push_back({s.rect(), id, 0}); + }); + } +}; +} // namespace hqtui +#endif diff --git a/ports/cpp/src/widgets.cpp b/ports/cpp/src/widgets.cpp new file mode 100644 index 0000000..4ec6144 --- /dev/null +++ b/ports/cpp/src/widgets.cpp @@ -0,0 +1,380 @@ +#include +#include +#include +namespace hqtui { +static std::string number(double v) { + char b[80]; + std::snprintf(b, sizeof b, v == std::floor(v) ? "%.0f" : "%.1f", v); + return b; +} +static uint32_t vertical(double v, const std::string &mode = "block") { + int n = std::clamp(iround(ratio(v) * 8), 0, 8); + if (mode == "ascii") { + return v <= 0 ? ' ' : v < .4 ? '.' : v < .7 ? '=' : '#'; + } + return n ? 0x2580 + n : ' '; +} +void draw_keys(Surface s, const std::vector &rows, bool spread) { + auto &t = theme(s); + int w = s.rect().width, h = s.rect().height, lw = 0; + for (auto &r : rows) + lw = std::max(lw, int(width(r.label))); + lw = std::min(lw + 1, std::max(4, int(w * .6))); + for (int y = 0; y < h && y < int(rows.size()); y++) { + auto &r = rows[y]; + text(s, 0, y, fit(r.label, lw), t.muted); + int x = lw + 1, vw = std::max(0, w - x); + if (vw) + text(s, x, y, + spread ? fit(r.value, vw, HQ_RIGHT) + : fit(r.value, std::min(vw, int(width(r.value)))), + r.color ? r.color : t.foreground); + } +} +void draw_meter(Surface s, const Meter &o) { + auto &t = theme(s); + int w = s.rect().width; + if (w <= 0 || s.rect().height <= 0) + return; + double v = ratio(o.value); + std::string value = + o.show_value + ? (o.readout.empty() ? number(iround(v * 100)) + "%" : o.readout) + : ""; + int lw = o.label.empty() ? 0 + : o.label_width < 0 ? int(width(o.label)) + 1 + : o.label_width, + vw = value.empty() ? 0 + : o.value_width < 0 ? int(width(value)) + 1 + : o.value_width, + bw = std::max(0, w - lw - vw); + if (lw) + text(s, 0, 0, fit(o.label, lw), t.muted, 0, o.background); + auto track = hq_mix(t.background, t.border, .8); + double filled = v * bw; + for (int x = 0; x < bw; x++) { + uint32_t ch = ' '; + Color color = track; + if (o.segmented) { + ch = 0x25ae; + if (x < int(std::floor(filled))) + color = + o.color ? o.color : hq_heat(&t, bw <= 1 ? v : double(x) / (bw - 1)); + } else { + double remainder = std::clamp(filled - x, 0., 1.); + int eighth = iround(remainder * 8); + ch = eighth == 8 ? 0x2588 : eighth ? 0x2590 - eighth : 0x2500; + if (eighth) + color = o.color ? o.color + : hq_heat(&t, x < int(std::floor(filled)) && bw > 1 + ? double(x) / (bw - 1) + : v); + } + auto st = Style().foreground(color); + if (o.background) + st = st.background(*o.background); + s.set(lw + x, 0, ch, st); + } + if (vw) + text(s, w - vw, 0, fit(value, vw, HQ_RIGHT), + o.color ? o.color : hq_heat(&t, v), HQ_BOLD, o.background); +} +void draw_graph(Surface surface, const Graph &o) { + if (surface.rect().width <= 0 || surface.rect().height <= 0) + return; + auto &t = theme(surface); + Surface s = surface; + int mult = o.mode == "braille" ? 2 : 1; + auto highFor = [&](int window) { + double hi = -INFINITY; + for (auto &series : o.series) + for (int i = std::max(0, int(series.values.size()) - window); + i < int(series.values.size()); i++) + if (std::isfinite(series.values[i])) + hi = std::max(hi, series.values[i]); + return std::isfinite(hi) ? hi : 1.; + }; + auto label = o.axis_format ? o.axis_format : [](double v) { + return std::abs(v) >= 1000 ? number(std::floor(v / 100 + .5) / 10) + "k" + : number(v); + }; + if (o.axis) { + double hi = o.max.value_or(highFor(s.rect().width * mult)); + int lw = std::max(width(label(hi)), width(label(o.min))) + 1; + text(s, 0, 0, fit(label(hi), lw, HQ_RIGHT), t.muted); + if (s.rect().height > 1) + text(s, 0, s.rect().height - 1, fit(label(o.min), lw, HQ_RIGHT), t.muted); + s = s.sub({lw, 0, std::max(0, s.rect().width - lw), s.rect().height}); + } + int w = s.rect().width, h = s.rect().height; + if (w <= 0 || h <= 0) + return; + double low = o.min, high = o.max.value_or(highFor(w * mult)); + if (high <= low) + high = low + 1; + double span = high - low; + if (o.grid) + for (int y = 0; y < h; y += std::max(2, h / 4)) + for (int x = 0; x < w; x += 2) + s.set(x, y, 0xb7, + Style().foreground(hq_mix(t.border, t.background, .4))); + for (std::size_t si = 0; si < o.series.size(); si++) { + auto &series = o.series[si]; + auto &values = series.values; + Color color = series.color ? series.color : hq_series(&t, si); + int count = std::min(int(values.size()), w * mult), + start = int(values.size()) - count; + if (!count) + continue; + if (o.mode != "braille") { + for (int x = 0; x < w; x++) { + int index = int(values.size()) - w + x; + if (index < 0 || !std::isfinite(values[index])) + continue; + double r = (values[index] - low) / span; + int full = int(std::floor(r * h)); + auto fg = o.colors.empty() + ? color + : hq_gradient(o.colors.data(), o.colors.size(), r); + auto st = Style().foreground(fg); + if (o.background) + st = st.background(*o.background); + for (int k = 0; k < std::min(full, h); k++) + s.set(x, h - 1 - k, 0x2588, st); + if (full < h && full >= 0) { + auto cp = vertical(r * h - full, o.mode); + if (cp != ' ') + s.set(x, h - 1 - full, cp, st); + } + } + continue; + } + Braille canvas(w, h); + int px = w * 2, py = h * 4, prevx = 0, prevy = 0; + bool previous = false; + for (int i = 0; i < count; i++) { + double v = values[start + i]; + if (!std::isfinite(v)) + continue; + int x = count == 1 ? px - 1 : iround(double(i) / (count - 1) * (px - 1)), + y = iround((1 - ratio((v - low) / span)) * (py - 1)); + if (previous) + canvas.line(prevx, prevy, x, y); + else + canvas.pixel(x, y); + prevx = x; + prevy = y; + previous = true; + } + if (series.fill) { + Color base = o.background.value_or(t.background); + for (int x = 0; x < w; x++) { + int from = start + int(double(x) / w * count), + to = std::max(from + 1, start + int(double(x + 1) / w * count)); + double total = 0; + int seen = 0; + for (int i = from; i < std::min(to, int(values.size())); i++) + if (std::isfinite(values[i])) { + total += values[i]; + seen++; + } + if (!seen) + continue; + double filled = ratio((total / seen - low) / span) * h; + int full = int(std::floor(filled)); + auto paint = [&](int y, uint32_t cp, double extra) { + auto fg = hq_mix(base, color, + .5 * (1 - (h <= 1 ? 0 : double(y) / (h - 1)) * .3) + + extra); + auto st = Style().foreground(fg); + if (o.background) + st = st.background(*o.background); + s.set(x, y, cp, st); + }; + for (int k = 0; k < std::min(full, h); k++) + paint(h - 1 - k, 0x2588, 0); + if (full < h) { + auto cp = vertical(filled - full); + if (cp != ' ') + paint(h - 1 - full, cp, .12); + } + } + } + if (o.colors.empty()) + canvas.blit(s, color, o.background); + else + for (int y = 0; y < h; y++) + for (int x = 0; x < w; x++) + if (auto d = canvas.dots[std::size_t(y) * w + x]) { + auto st = Style().foreground( + hq_gradient(o.colors.data(), o.colors.size(), + 1 - double(y) / std::max(1, h - 1))); + if (o.background) + st = st.background(*o.background); + s.set(x, y, 0x2800 + d, st); + } + } + if (o.legend) { + int x = 0, y = h > 3 ? h - 1 : 0; + for (std::size_t i = 0; i < o.series.size(); i++) { + auto &series = o.series[i]; + if (series.label.empty()) + continue; + text(s, x, y, "■ ", series.color ? series.color : hq_series(&t, i)); + x += 2; + text(s, x, y, series.label + " ", t.muted); + x += int(width(series.label)) + 1; + } + } +} +void draw_gauge(Surface s, double value, std::string_view label) { + int w = s.rect().width, h = s.rect().height; + if (w <= 0 || h <= 0) + return; + if (h < 3) { + Meter m; + m.value = value; + m.show_value = false; + m.segmented = false; + draw_meter(s, m); + return; + } + Braille a(w, h), rest(w, h); + double v = ratio(value), cx = a.w / 2., cy = a.h - 2, + radius = std::min(a.w / 2. - 1, a.h - 3.); + int steps = std::max(24, iround(radius * 4)); + for (int i = 0; i <= steps; i++) { + double p = double(i) / steps, angle = std::acos(-1.) * (1 - p), + x = cx + std::cos(angle) * radius, + y = cy - std::sin(angle) * radius * .85; + if (p <= v) { + a.pixel(x, y); + a.pixel(x, y - 1); + } else + rest.pixel(x, y); + } + auto &t = theme(s); + auto color = hq_heat(&t, v); + a.blit(s, color); + rest.blit(s, hq_mix(t.background, t.border, .9)); + if (!label.empty()) + aligned(s, h - 1, label, color, HQ_CENTER, HQ_BOLD); +} +void draw_scrollbar(Surface s, int x, int y, int h, int total, int offset) { + if (h <= 0 || total <= h) + return; + auto &t = theme(s); + int thumb = std::max(1, iround(double(h) / total * h)), + pos = iround(double(offset) / std::max(1, total - h) * (h - thumb)); + for (int i = 0; i < h; i++) + s.set(x, y + i, i >= pos && i < pos + thumb ? 0x2588 : 0x2502, + Style().foreground(i >= pos && i < pos + thumb + ? t.accent + : hq_mix(t.background, t.border, .7))); +} +void draw_table(Surface s, const Table &o) { + int w = s.rect().width, h = s.rect().height, + bodyw = std::max(0, w - int(o.scrollbar)), + capacity = std::max(0, h - int(o.header)); + auto &t = theme(s); + std::vector constraints; + for (std::size_t ci = 0; ci < o.columns.size(); ci++) { + auto &c = o.columns[ci]; + int intrinsic = int(width(c.title)); + for (int i = 0; i < std::min(200, int(o.rows.size())); i++) + if (ci < o.rows[i].cells.size()) + intrinsic = std::max(intrinsic, int(width(o.rows[i].cells[ci]))); + constraints.push_back(c.width < 0 ? automatic(intrinsic, c.min) + : cells(c.width, c.min)); + } + std::vector widths(constraints.size()); + hq_solve(bodyw, constraints.data(), constraints.size(), 1, widths.data()); + if (o.header) { + int x = 0; + for (std::size_t i = 0; i < o.columns.size(); i++) + if (widths[i] > 0) { + text(s, x, 0, fit(o.columns[i].title, widths[i], o.columns[i].align), + t.muted, HQ_BOLD); + x += widths[i] + 1; + } + } + Pane local; + Pane &p = o.pane ? *o.pane : local; + p.total = int(o.rows.size()); + p.capacity = capacity; + p.offset = std::clamp(p.offset, 0, std::max(0, p.total - capacity)); + if (o.pane && capacity > 0) { + if (p.selected < p.offset) + p.offset = p.selected; + if (p.selected >= p.offset + capacity) + p.offset = p.selected - capacity + 1; + p.offset = std::clamp(p.offset, 0, std::max(0, p.total - capacity)); + } + for (int i = 0; i < capacity && p.offset + i < p.total; i++) { + int index = p.offset + i, y = i + int(o.header); + auto &row = o.rows[index]; + bool selected = o.pane && index == p.selected; + std::optional bg; + if (selected) + bg = t.selection; + else if (o.zebra && index % 2) + bg = hq_mix(t.surface, + hq_rgb(t.dark ? 255 : 0, t.dark ? 255 : 0, t.dark ? 255 : 0), + .04); + if (bg) + s.sub({0, y, bodyw, 1}).fill(' ', Style().background(*bg)); + int x = 0; + for (std::size_t c = 0; c < o.columns.size(); c++) + if (widths[c] > 0) { + Color fg = selected ? t.selection_text + : c < row.colors.size() && row.colors[c] ? row.colors[c] + : o.columns[c].color ? o.columns[c].color + : t.foreground; + text(s, x, y, + fit(c < row.cells.size() ? row.cells[c] : "", widths[c], + o.columns[c].align), + fg, selected ? HQ_BOLD : 0, bg); + x += widths[c] + 1; + } + } + if (o.scrollbar) + draw_scrollbar(s, w - 1, int(o.header), capacity, p.total, p.offset); +} +void draw_log(Surface s, const std::vector &entries, Pane *pane) { + auto &t = theme(s); + int h = s.rect().height, w = s.rect().width - 1, total = int(entries.size()), + offset = pane ? pane->offset : 0, + start = std::clamp(total - h - offset, 0, std::max(0, total - h)); + if (pane) { + pane->total = total; + pane->capacity = h; + pane->log = true; + } + for (int i = 0; i < h && start + i < total; i++) { + auto &e = entries[start + i]; + int x = 0; + if (!e.time.empty()) { + text(s, x, i, e.time + " ", t.muted); + x += int(width(e.time)) + 1; + } + if (!e.level.empty()) { + auto level = e.level; + std::transform(level.begin(), level.end(), level.begin(), + [](unsigned char c) { return char(std::toupper(c)); }); + Color color = level == "ERROR" || level == "FATAL" ? t.danger + : level == "WARN" ? t.warning + : level == "INFO" ? t.success + : t.muted; + text(s, x, i, fit(level, 5), color, HQ_BOLD); + x += 5; + text(s, x++, i, " ", t.foreground); + } + int mw = e.meta.empty() ? 0 : int(width(e.meta)) + 1, + available = std::max(0, w - x - mw); + text(s, x, i, fit(e.message, available), t.foreground); + if (mw && mw < w) + text(s, w - mw + 1, i, e.meta, t.muted); + } + // Match the reference log: reserve its scrollbar column without painting it. +} +} // namespace hqtui diff --git a/ports/cpp/tests/dashboard_reference.cpp b/ports/cpp/tests/dashboard_reference.cpp new file mode 100644 index 0000000..d2b983f --- /dev/null +++ b/ports/cpp/tests/dashboard_reference.cpp @@ -0,0 +1,76 @@ +#include "model.hpp" +#include "sample.hpp" +#include +#include +int main(int argc, char **argv) { + try { + int w = argc > 1 ? std::stoi(argv[1]) : 168, + h = argc > 2 ? std::stoi(argv[2]) : 46; + const hq_theme *t = hq_theme_named(argc > 3 ? argv[3] : "dark"); + if (!t || w < 1 || h < 1 || w > 500 || h > 200) + return 2; + demo::State s; + s.data = demo::Json::parse(demo::sample_json); + if (argc > 5) + for (int i = 0; i < 10; i++) + if (std::string(argv[5]) == demo::screens[i]) + s.screen = i; + for (int i = 0; i < 9; i++) + if (std::string(t->name) == demo::themes[i]) + s.theme_index = i; + hqtui::Buffer frame(w, h); + frame.clear(t->background, t->foreground); + hqtui::UI ui(frame.surface(t)); + if (s.screen == 0) + demo::dashboard(ui, s); + else if (s.screen < 5) + demo::telemetry(ui, s); + else + demo::showcase(ui, s); + ui.flush(); + if (argc > 4 && std::string(argv[4]) == "--cells") { + for (int y = 0; y < h; y++) + for (int x = 0; x < w; x++) { + auto c = frame.cell(x, y); + char scratch[5]; + auto v = hq_buffer_cell_text(frame.native_handle(), x, y, scratch); + std::cout << c.fg << "," << c.bg << "," << c.attrs << ","; + for (const unsigned char *p = + reinterpret_cast(v); + *p; p++) + std::cout << std::hex << std::setw(2) << std::setfill('0') + << int(*p); + std::cout << std::dec << "\n"; + } + } else if (argc > 4 && std::string(argv[4]) == "--hashes") { + std::cout << "["; + for (int y = 0; y < h; y++) { + uint32_t hash = 2166136261u; + auto add = [&](unsigned char c) { hash = (hash ^ c) * 16777619u; }; + for (int x = 0; x < w; x++) { + char scratch[5]; + const char *str = + hq_buffer_cell_text(frame.native_handle(), x, y, scratch); + for (const unsigned char *p = + reinterpret_cast(str); + *p; p++) + add(*p); + add(0); + auto cell = frame.cell(x, y); + for (uint32_t v : {cell.fg, cell.bg, uint32_t(cell.attrs)}) + for (int i = 0; i < 4; i++) + add((v >> (i * 8)) & 255); + } + if (y) + std::cout << ","; + std::cout << hash; + } + std::cout << "]\n"; + } else + for (int y = 0; y < h; y++) + std::cout << frame.row(y) << "\n"; + } catch (const std::exception &e) { + std::cerr << e.what() << "\n"; + return 1; + } +} diff --git a/ports/cpp/tests/demo.cpp b/ports/cpp/tests/demo.cpp new file mode 100644 index 0000000..307e73f --- /dev/null +++ b/ports/cpp/tests/demo.cpp @@ -0,0 +1,108 @@ +#define HQTUI_DEMO_TEST +#include "../demo/main.cpp" + +static void require(bool value, const char *message) { + if (!value) + throw std::runtime_error(message); +} +int main() { + using namespace demo; + try { + for (auto source : {"{", "[1,]", "{\"x\":1,\"x\":2}", "1e999", "01", + "\"\\uD800\"", "true false"}) { + bool rejected = false; + try { + Json::parse(source); + } catch (const std::exception &) { + rejected = true; + } + require(rejected, "Malformed JSON accepted"); + } + require(Json::parse("\"\\uD83D\\uDE80\"").s() == "🚀", "JSON Unicode pair"); + auto shape = Json::parse(sample_json); + Collector collector(shape); + require(collector.data["sensors"].array().empty() && + collector.data["processes"].array().empty(), + "real data contains simulated rows"); + require(collector.data["telemetry"]["http"].null() && + collector.data["telemetry"]["power"].null(), + "missing sources not null"); + State s; + s.data = shape; + Input input; + require(!input.feed(s, "\x1b") && !input.feed(s, "O") && + !input.feed(s, "P") && s.help, + "split F1"); + require(!input.feed(s, "q") && !s.help, "help must consume q"); + input.feed(s, "e"); + input.feed(s, "\x1b[B"); + require(s.input.empty(), "arrow inserted text"); + input.feed(s, "q"); + require(s.input == "q", "editing swallowed q"); + input.feed(s, "\x1b[200~hello"); + input.feed(s, " world\x1b[201~"); + require(s.input == "qhello world", "split bracketed paste"); + input.feed(s, "\r"); + require(!s.editing, "finish editing"); + input.feed(s, "3"); + require(s.screen == 2, "numeric tab"); + Pane pane; + pane.selected = 49; + Table table; + table.header = false; + table.pane = &pane; + table.columns = {{"Path", -1, 1, 0, HQ_LEFT}}; + for (int i = 0; i < 50; i++) + table.rows.push_back({{"route-" + std::to_string(i)}, {}}); + Buffer frame(20, 3); + auto t = hq_theme_named("dark"); + frame.clear(t->background, t->foreground); + draw_table(frame.surface(t), table); + require(pane.offset == 47 && + frame.row(2).find("route-49") != std::string::npos, + "table used wrong viewport"); + Pane log; + log.log = true; + log.total = 50; + log.move(-3); + require(log.offset == 3, "log scroll direction"); + log.move(100); + require(log.offset == 0, "log tail clamp"); + s.regions = {{{0, 0, 20, 3}, "test.pane", 0}}; + s.panes["test.pane"].total = 50; + input.feed(s, "\x1b[<65;2;2M"); + require(s.panes["test.pane"].selected == 3, "mouse wheel"); + s.filtering = true; + input.feed(s, "\x1b[<65;2;2M"); + require(s.panes["test.pane"].selected == 3, "overlay leaked mouse input"); + std::atomic cancel{false}; + auto start = std::chrono::steady_clock::now(); + command({"sh", "-c", "exec 1>&-; sleep 20"}, &cancel, 100); + require(std::chrono::steady_clock::now() - start < std::chrono::seconds(1), + "child closing stdout blocked exit"); + start = std::chrono::steady_clock::now(); + command({"sh", "-c", "sleep 20"}, &cancel, 100); + require(std::chrono::steady_clock::now() - start < std::chrono::seconds(1), + "command deadline"); + cancel = true; + require(command({"sh", "-c", "echo must-not-run"}, &cancel).empty(), + "cancelled command ran"); + for (int width : {1, 2, 20, 80, 500}) + for (int height : {1, 2, 8, 30, 200}) + for (int screen = 0; screen < 10; screen++) { + State state; + state.data = shape; + state.screen = screen; + Buffer b(width, height); + b.clear(t->background, t->foreground); + UI ui(b.surface(t)); + render(ui, state, true); + ui.flush(); + } + std::cout << "C++ input, JSON, viewport, real-data isolation, subprocess " + "and extreme-size tests passed.\n"; + } catch (const std::exception &e) { + std::cerr << e.what() << "\n"; + return 1; + } +} diff --git a/ports/cpp/tests/demo_parity.py b/ports/cpp/tests/demo_parity.py new file mode 100644 index 0000000..4b9cab8 --- /dev/null +++ b/ports/cpp/tests/demo_parity.py @@ -0,0 +1,17 @@ +"""C++ screen bodies versus the shared, unmodified TypeScript expectations.""" +import json +from pathlib import Path +import subprocess +import sys + +root = Path(__file__).resolve().parents[2] +cases = json.loads((root / 'conformance/fixtures/demo-parity.json').read_text()) +assert len(cases) == 120 +for case in cases: + command = [sys.argv[1], str(case['width']), str(case['height']), + case['theme'], '--hashes', case['screen']] + result = subprocess.run(command, capture_output=True, text=True, check=True, timeout=20) + hashes = json.loads(result.stdout) + assert hashes == case['hashes'], (case['screen'], case['theme'], case['width'], + [i for i, (a, b) in enumerate(zip(hashes, case['hashes'])) if a != b]) +print('C++: all 120 TypeScript reference frames match exactly (glyphs/colors/attributes).') diff --git a/ports/cpp/tests/demo_terminal.py b/ports/cpp/tests/demo_terminal.py new file mode 100644 index 0000000..d4d0605 --- /dev/null +++ b/ports/cpp/tests/demo_terminal.py @@ -0,0 +1,98 @@ +"""Isolated PTY tests: cold launch, switching tabs, overlays, resize and cleanup.""" +import fcntl +import faulthandler +import os +from pathlib import Path +import pty +import select +import signal +import struct +import subprocess +import sys +import termios +import time + +executable = str(Path(sys.argv[1]).resolve()) +faulthandler.enable() +faulthandler.dump_traceback_later(20, repeat=True) + +def run(args, kill=False): + master, slave = pty.openpty() + fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack('HHHH', 60, 200, 0, 0)) + before = termios.tcgetattr(slave) + # The direct executable needs PTY descriptors, not /dev/tty. Avoid Python + # preexec_fn after fork: that can deadlock on macOS. The updater's separate + # tests exercise acquiring a controlling terminal for curl-pipe launches. + print(f'PTY launch: {args}, signal={kill}', flush=True) + proc = subprocess.Popen([executable, *args], stdin=slave, stdout=slave, stderr=slave, + cwd='/tmp', start_new_session=True) + os.set_blocking(master, False) + output = bytearray() + def wait_for(label, timeout=8): + deadline = time.monotonic() + timeout + while label not in output and time.monotonic() < deadline: + if select.select([master], [], [], .05)[0]: + try: + output.extend(os.read(master, 65536)) + except BlockingIOError: + pass + assert proc.poll() is None, ('early exit', proc.returncode, bytes(output[-1000:])) + assert label in output, ('missing frame', label, bytes(output[-1000:])) + try: + start = time.monotonic() + wait_for(b'CPU Overview', 3) + assert time.monotonic() - start < 3, 'first frame blocked by collector' + for command, label in [(b'2', b'Protocols'), (b'3', b'Active Sessions'), + (b'4', b'Connections'), (b'5', b'Filesystems'), + (b'6', b'Buttons & Inputs'), (b'7', b'Braille'), + (b'8', b'Theme'), (b'9', b'Last Events'), + (b'0', b'Full-screen churn')]: + output.clear() + os.write(master, command) + wait_for(label) + os.write(master, b'?') + output.clear() + wait_for(b'Help') + os.write(master, b'x') + fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack('HHHH', 30, 80, 0, 0)) + os.write(master, b'1') + output.clear() + wait_for(b'CPU Overview') + if kill: + os.kill(proc.pid, signal.SIGTERM) + else: + os.write(master, b'q') + # A real terminal keeps consuming output during shutdown. macOS PTYs + # have a small buffer: waiting without draining can block the demo in + # the remainder of its last frame before it reads q or restores state. + deadline = time.monotonic() + 3 + while proc.poll() is None and time.monotonic() < deadline: + if select.select([master], [], [], .05)[0]: + try: + os.read(master, 65536) + except (BlockingIOError, OSError): + pass + proc.wait(timeout=.1) + assert proc.returncode == (143 if kill else 0), proc.returncode + after = termios.tcgetattr(slave) + if sys.platform == 'darwin': + # XNU sets PENDIN (queue state, not a user setting) when ICANON is + # restored. Preserve queued input rather than flushing it merely + # to clear this bit. Compare every actual terminal setting. + # https://github.com/apple-oss-distributions/xnu/blob/main/bsd/kern/tty.c + before[3] &= ~termios.PENDIN + after[3] &= ~termios.PENDIN + assert after == before, ('terminal state was not restored', before, after) + finally: + if proc.poll() is None: + os.killpg(proc.pid, signal.SIGKILL) + proc.wait() + os.close(master) + os.close(slave) + +run(['--sim']) +run(['--sim'], kill=True) +if sys.platform.startswith('linux'): + run(['--real']) +print('C++: all ten tabs, overlay, resize, q/SIGTERM and terminal restoration passed.') +faulthandler.cancel_dump_traceback_later()